import type { AxiosRequestConfig, Method } from 'axios' import axios from 'axios' import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus' import { refreshToken } from '/@/api/common' import { i18n } from '/@/lang/index' import router from '/@/router/index' import adminBaseRoute from '/@/router/static/adminBase' import { memberCenterBaseRoutePath } from '/@/router/static/memberCenterBase' import { useAdminInfo } from '/@/stores/adminInfo' import { useConfig } from '/@/stores/config' import { SYSTEM_ZINDEX } from '/@/stores/constant/common' import { useUserInfo } from '/@/stores/userInfo' import { isAdminApp } from '/@/utils/common' /** 与后台 LoadLangPack 一致:优先与当前 i18n 语言对齐,再回落到 config */ function resolveAdminThinkLang(): string { const cfg = useConfig() try { const loc = i18n?.global?.locale?.value if (loc !== undefined && loc !== null && loc !== '') { const v = String(loc).toLowerCase().replace('_', '-') if (v === 'zh-cn' || v === 'zh') { return 'zh-cn' } if (v === 'en') { return 'en' } } } catch { // i18n 未就绪时忽略 } return cfg.lang.defaultLang } window.requests = [] window.tokenRefreshing = false window.authRedirecting = false const pendingMap = new Map() const loadingInstance: LoadingInstance = { target: null, count: 0, } /** * 根据运行环境获取基础请求URL */ export const getUrl = (): string => { const value: string = import.meta.env.VITE_AXIOS_BASE_URL as string return value == 'getCurrentDomain' ? window.location.protocol + '//' + window.location.host : value } /** * 根据运行环境获取基础请求URL的端口 */ export const getUrlPort = (): string => { const url = getUrl() return new URL(url).port } /** * 创建`Axios` * 默认开启`reductDataFormat(简洁响应)`,返回类型为`ApiPromise` * 关闭`reductDataFormat`,返回类型则为`AxiosPromise` */ function createAxios>(axiosConfig: AxiosRequestConfig, options: Options = {}, loading: LoadingOptions = {}): T { const config = useConfig() const adminInfo = useAdminInfo() const userInfo = useUserInfo() const Axios = axios.create({ baseURL: getUrl(), timeout: 1000 * 10, headers: { 'think-lang': resolveAdminThinkLang(), }, responseType: 'json', }) // 自定义后台入口 if (adminBaseRoute.path != '/admin' && isAdminApp() && /^\/admin\//.test(axiosConfig.url!)) { axiosConfig.url = axiosConfig.url!.replace(/^\/admin\//, adminBaseRoute.path + '.php/') } // 合并默认请求选项 options = Object.assign( { cancelDuplicateRequest: true, // 是否开启取消重复请求, 默认为 true loading: false, // 是否开启loading层效果, 默认为false reductDataFormat: true, // 是否开启简洁的数据结构响应, 默认为true showErrorMessage: true, // 是否开启接口错误信息展示,默认为true showCodeMessage: true, // 是否开启code不为1时的信息提示, 默认为true showSuccessMessage: false, // 是否开启code为1时的信息提示, 默认为false anotherToken: '', // 当前请求使用另外的用户token }, options ) // 请求拦截 Axios.interceptors.request.use( (config) => { removePending(config) options.cancelDuplicateRequest && addPending(config) // 创建loading实例 if (options.loading) { loadingInstance.count++ if (loadingInstance.count === 1) { loadingInstance.target = ElLoading.service(loading) } } // 自动携带token和语言(确保每次请求使用当前语言,后端据此返回对应语言的 remark 等) if (config.headers) { const token = adminInfo.getToken() if (token) (config.headers as anyObj).batoken = token const userToken = options.anotherToken || userInfo.getToken() if (userToken) (config.headers as anyObj)['ba-user-token'] = userToken ;(config.headers as anyObj)['think-lang'] = resolveAdminThinkLang() } return config }, (error) => { return Promise.reject(error) } ) // 响应拦截 Axios.interceptors.response.use( (response) => { removePending(response.config) options.loading && closeLoading(options) // 关闭loading if (response.config.responseType == 'json') { if (response.data && response.data.code !== 1) { if (response.data.code == 409) { if (!window.tokenRefreshing) { window.tokenRefreshing = true return refreshToken() .then((res) => { if (res.data.type == 'admin-refresh') { adminInfo.setToken(res.data.token, 'auth') response.headers.batoken = `${res.data.token}` window.requests.forEach((cb) => cb(res.data.token, 'admin-refresh')) } else if (res.data.type == 'user-refresh') { userInfo.setToken(res.data.token, 'auth') response.headers['ba-user-token'] = `${res.data.token}` window.requests.forEach((cb) => cb(res.data.token, 'user-refresh')) } window.requests = [] return Axios(response.config) }) .catch((err) => { if (isAdminApp()) { adminInfo.removeToken() if (router.currentRoute.value.name != 'adminLogin') { router.push({ name: 'adminLogin' }) return Promise.reject(err) } else { response.headers.batoken = '' window.requests.forEach((cb) => cb('', 'admin-refresh')) window.requests = [] return Axios(response.config) } } else { userInfo.removeToken() if (router.currentRoute.value.name != 'userLogin') { router.push({ name: 'userLogin' }) return Promise.reject(err) } else { response.headers['ba-user-token'] = '' window.requests.forEach((cb) => cb('', 'user-refresh')) window.requests = [] return Axios(response.config) } } }) .finally(() => { window.tokenRefreshing = false }) } else { return new Promise((resolve) => { // 用函数形式将 resolve 存入,等待刷新后再执行 window.requests.push((token: string, type: string) => { if (type == 'admin-refresh') { response.headers.batoken = `${token}` } else { response.headers['ba-user-token'] = `${token}` } resolve(Axios(response.config)) }) }) } } if (options.showCodeMessage) { ElNotification({ type: 'error', message: response.data.msg, zIndex: SYSTEM_ZINDEX, }) } // 自动跳转到路由name或path if (response.data.code == 302) { router.push({ path: response.data.data.routePath ?? '', name: response.data.data.routeName ?? '' }) } if (response.data.code == 303) { const isAdminAppFlag = isAdminApp() let routerPath = isAdminAppFlag ? adminBaseRoute.path : memberCenterBaseRoutePath // 需要登录,清理 token,转到登录页 if (response.data.data.type == 'need login') { if (isAdminAppFlag) { adminInfo.removeToken() } else { userInfo.removeToken() } routerPath += '/login' } router.push({ path: routerPath }) } // code不等于1, 页面then内的具体逻辑就不执行了 return Promise.reject(response.data) } else if (options.showSuccessMessage && response.data && response.data.code == 1) { ElNotification({ message: response.data.msg ? response.data.msg : i18n.global.t('axios.Operation successful'), type: 'success', zIndex: SYSTEM_ZINDEX, }) } } return options.reductDataFormat ? response.data : response }, (error) => { error.config && removePending(error.config) options.loading && closeLoading(options) // 关闭loading options.showErrorMessage && httpErrorStatusHandle(error) // 处理错误状态码 return Promise.reject(error) // 错误继续返回给到具体页面 } ) return Axios(axiosConfig) as T } export default createAxios /** * 处理异常 * @param {*} error */ /** 解析 axios 错误响应体(可能已是对象或 JSON 字符串) */ function normalizeAxiosErrorBody(data: unknown): anyObj | null { if (data === undefined || data === null) { return null } if (typeof data === 'string') { try { return JSON.parse(data) as anyObj } catch { return null } } if (typeof data === 'object') { return data as anyObj } return null } /** * 后端未登录时通过 HTTP 303 + body.data.type === 'need login' 提示登录(见 Auth::LOGIN_RESPONSE_CODE)。 * Axios 将 303 视为失败,不会进入成功拦截器里对 code==303 的分支,需在此与之一致:清 token 并跳登录页。 */ function redirectToLoginIfNeedLoginFromHttp303(error: any): boolean { if (!error?.response || error.response.status !== 303) { return false } const body = normalizeAxiosErrorBody(error.response.data) if (!body || typeof body.data !== 'object' || body.data === null) { return false } const needType = (body.data as anyObj).type if (needType !== 'need login') { return false } if (window.authRedirecting) { return true } const isAdminAppFlag = isAdminApp() const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin' const adminInfo = useAdminInfo() const userInfo = useUserInfo() if (router.currentRoute.value.name === loginRouteName) { if (isAdminAppFlag) { adminInfo.removeToken() } else { userInfo.removeToken() } return true } window.authRedirecting = true if (isAdminAppFlag) { adminInfo.removeToken() router.replace({ name: loginRouteName }).finally(() => { window.authRedirecting = false }) } else { userInfo.removeToken() const to = router.currentRoute.value.fullPath router.replace({ name: loginRouteName, query: to ? { to } : {} }).finally(() => { window.authRedirecting = false }) } return true } function httpErrorStatusHandle(error: any) { // 处理被取消的请求 if (axios.isCancel(error)) return console.error(i18n.global.t('axios.Automatic cancellation due to duplicate request:') + error.message) if (redirectToLoginIfNeedLoginFromHttp303(error)) { return } let message = '' if (error && error.response) { switch (error.response.status) { case 302: message = i18n.global.t('axios.Interface redirected!') break case 303: { const body303 = normalizeAxiosErrorBody(error.response.data) const serverMsg303 = body303 && typeof body303.msg === 'string' ? body303.msg : '' message = serverMsg303 !== '' ? serverMsg303 : i18n.global.t('axios.Interface redirected!') break } case 400: message = i18n.global.t('axios.Incorrect parameter!') break case 401: message = i18n.global.t('axios.You do not have permission to operate!') { const data = error.response.data const serverMsg = data && typeof data.msg === 'string' ? data.msg : '' if (serverMsg) { message = serverMsg } const isAdminAppFlag = isAdminApp() const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin' if (!window.authRedirecting && router.currentRoute.value.name !== loginRouteName) { window.authRedirecting = true const adminInfo = useAdminInfo() const userInfo = useUserInfo() if (isAdminAppFlag) { adminInfo.removeToken() router.replace({ name: loginRouteName }).finally(() => { window.authRedirecting = false }) } else { userInfo.removeToken() const to = router.currentRoute.value.fullPath router .replace({ name: loginRouteName, query: to ? { to } : {} }) .finally(() => { window.authRedirecting = false }) } } } break case 403: message = i18n.global.t('axios.You do not have permission to operate!') break case 404: message = i18n.global.t('axios.Error requesting address:') + error.response.config.url break case 408: message = i18n.global.t('axios.Request timed out!') break case 409: message = i18n.global.t('axios.The same data already exists in the system!') { const data = error.response.data const serverMsg = data && typeof data.msg === 'string' ? data.msg : '' const authExpiredHints = [ 'Token expiration', 'token expiration', '登录态过期', '请重新登录', 'Session expired', 'session expired', 'please login again', 'sila log masuk semula', ] const looksLikeAuthExpired = serverMsg !== '' && authExpiredHints.some((hint) => serverMsg.toLowerCase().includes(hint.toLowerCase())) if (looksLikeAuthExpired) { message = serverMsg const isAdminAppFlag = isAdminApp() const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin' if (!window.authRedirecting && router.currentRoute.value.name !== loginRouteName) { window.authRedirecting = true const adminInfo = useAdminInfo() const userInfo = useUserInfo() if (isAdminAppFlag) { adminInfo.removeToken() router.replace({ name: loginRouteName }).finally(() => { window.authRedirecting = false }) } else { userInfo.removeToken() const to = router.currentRoute.value.fullPath router .replace({ name: loginRouteName, query: to ? { to } : {} }) .finally(() => { window.authRedirecting = false }) } } } } break case 500: message = i18n.global.t('axios.Server internal error!') break case 501: message = i18n.global.t('axios.Service not implemented!') break case 502: message = i18n.global.t('axios.Gateway error!') break case 503: message = i18n.global.t('axios.Service unavailable!') break case 504: message = i18n.global.t('axios.The service is temporarily unavailable Please try again later!') break case 505: message = i18n.global.t('axios.HTTP version is not supported!') break default: message = i18n.global.t('axios.Abnormal problem, please contact the website administrator!') break } } if (error.message.includes('timeout')) message = i18n.global.t('axios.Network request timeout!') if (error.message.includes('Network')) message = window.navigator.onLine ? i18n.global.t('axios.Server exception!') : i18n.global.t('axios.You are disconnected!') ElNotification({ type: 'error', message, zIndex: SYSTEM_ZINDEX, }) } /** * 关闭Loading层实例 */ function closeLoading(options: Options) { if (options.loading && loadingInstance.count > 0) loadingInstance.count-- if (loadingInstance.count === 0) { loadingInstance.target.close() loadingInstance.target = null } } /** * 储存每个请求的唯一cancel回调, 以此为标识 */ function addPending(config: AxiosRequestConfig) { const pendingKey = getPendingKey(config) config.cancelToken = config.cancelToken || new axios.CancelToken((cancel) => { if (!pendingMap.has(pendingKey)) { pendingMap.set(pendingKey, cancel) } }) } /** * 删除重复的请求 */ function removePending(config: AxiosRequestConfig) { const pendingKey = getPendingKey(config) if (pendingMap.has(pendingKey)) { const cancelToken = pendingMap.get(pendingKey) cancelToken(pendingKey) pendingMap.delete(pendingKey) } } /** * 生成每个请求的唯一key */ function getPendingKey(config: AxiosRequestConfig) { let { data } = config const { url, method, params, headers } = config if (typeof data === 'string') data = JSON.parse(data) // response里面返回的config.data是个字符串对象 return [ url, method, headers && (headers as anyObj).batoken ? (headers as anyObj).batoken : '', headers && (headers as anyObj)['ba-user-token'] ? (headers as anyObj)['ba-user-token'] : '', JSON.stringify(params), JSON.stringify(data), ].join('&') } /** * 根据请求方法组装请求数据/参数 */ export function requestPayload(method: Method, data: anyObj) { if (method == 'GET') { return { params: data, } } else if (method == 'POST') { return { data: data, } } } interface LoadingInstance { target: any count: number } interface Options { // 是否开启取消重复请求, 默认为 true cancelDuplicateRequest?: boolean // 是否开启loading层效果, 默认为false loading?: boolean // 是否开启简洁的数据结构响应, 默认为true reductDataFormat?: boolean // 是否开启接口错误信息展示,默认为true showErrorMessage?: boolean // 是否开启code不为1时的信息提示, 默认为true showCodeMessage?: boolean // 是否开启code为1时的信息提示, 默认为false showSuccessMessage?: boolean // 当前请求使用另外的用户token anotherToken?: string } /* * 感谢掘金@橙某人提供的思路和分享 * 本axios封装详细解释请参考:https://juejin.cn/post/6968630178163458084?share_token=7831c9e0-bea0-469e-8028-b587e13681a8#heading-27 */