- 修改用户登出时的重定向逻辑,确保用户显式返回登录页,避免保留重定向参数 - 引入默认动态路由子项,简化路由配置 - 更新后端路由初始化逻辑,确保动态路由的正确处理 - 增强代码可读性,修复部分代码风格问题
256 lines
7.8 KiB
TypeScript
256 lines
7.8 KiB
TypeScript
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { Session } from '/@/utils/storage';
|
||
import qs from 'qs';
|
||
import { getChangedFields } from '/@/utils/diffUtils';
|
||
import { handleModuleNotEnabled } from '/@/utils/assetSubscribe';
|
||
|
||
// 标记是否正在处理 token 过期,避免重复弹窗
|
||
let isHandlingTokenExpired = false;
|
||
|
||
// 错误消息防抖:防止短时间内显示多个错误消息
|
||
let lastErrorTime = 0;
|
||
const ERROR_MESSAGE_INTERVAL = 2000; // 2秒内只显示一个错误
|
||
|
||
const showErrorMessage = (message: string) => {
|
||
const now = Date.now();
|
||
|
||
// 2秒内只显示一个错误消息(不管内容是否相同)
|
||
if (now - lastErrorTime < ERROR_MESSAGE_INTERVAL) {
|
||
return; // 跳过
|
||
}
|
||
|
||
lastErrorTime = now;
|
||
ElMessage.error(message);
|
||
};
|
||
|
||
// ============================================================
|
||
// Axios 实例配置
|
||
// 地址配置见 .env.development 文件
|
||
// ============================================================
|
||
|
||
// 统一服务实例(端口8000)- 全部模块共用
|
||
const service: AxiosInstance = axios.create({
|
||
baseURL: import.meta.env.VITE_API_URL,
|
||
timeout: 50000,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
paramsSerializer: {
|
||
serialize(params) {
|
||
return qs.stringify(params, { allowDots: true, arrayFormat: 'brackets' });
|
||
},
|
||
},
|
||
});
|
||
|
||
// token 过期处理函数
|
||
const handleTokenExpired = () => {
|
||
if (isHandlingTokenExpired) return;
|
||
|
||
isHandlingTokenExpired = true;
|
||
|
||
ElMessageBox.alert('登录状态已过期,请重新登录', '提示', {
|
||
confirmButtonText: '确定',
|
||
showClose: false,
|
||
closeOnClickModal: false,
|
||
closeOnPressEscape: false,
|
||
beforeClose: (action, _instance, done) => {
|
||
if (action === 'confirm') {
|
||
done();
|
||
performLogout();
|
||
}
|
||
},
|
||
})
|
||
.then(() => {
|
||
performLogout();
|
||
})
|
||
.catch(() => {
|
||
performLogout();
|
||
});
|
||
};
|
||
|
||
// 执行退出登录操作
|
||
const performLogout = () => {
|
||
Session.clear();
|
||
localStorage.clear();
|
||
isHandlingTokenExpired = false;
|
||
// Hash 路由统一回登录页,避免跳到错误地址
|
||
setTimeout(() => {
|
||
window.location.href = '/#/login';
|
||
}, 500);
|
||
};
|
||
|
||
// 请求拦截器
|
||
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
||
// 检查 token 是否有效
|
||
const token = Session.get('token');
|
||
if (token) {
|
||
// 可以在这里添加 token 有效性检查(如果需要)
|
||
config.headers!['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
|
||
// PUT 请求最小化传参处理
|
||
// 如果请求数据中包含 _originalData,则自动计算差异,只传递修改过的字段
|
||
if (config.method?.toLowerCase() === 'put' && config.data && typeof config.data === 'object') {
|
||
const { _originalData, ...currentData } = config.data;
|
||
|
||
if (_originalData && typeof _originalData === 'object') {
|
||
// 获取 id 字段(必须保留)
|
||
const idField = currentData.id || currentData.Id || currentData.ID;
|
||
|
||
// 计算差异
|
||
const changedFields = getChangedFields(_originalData, currentData, {
|
||
exclude: ['_originalData', 'id', 'Id', 'ID'],
|
||
});
|
||
|
||
// 如果有变化,只传递 id + 变化的字段
|
||
if (Object.keys(changedFields).length > 0) {
|
||
config.data = { id: idField, ...changedFields };
|
||
} else {
|
||
// 没有变化,只传递 id
|
||
config.data = { id: idField };
|
||
}
|
||
|
||
console.log('[最小化传参] 原始字段数:', Object.keys(currentData).length, '-> 传递字段数:', Object.keys(config.data).length);
|
||
}
|
||
}
|
||
|
||
return config;
|
||
};
|
||
|
||
const requestErrorHandler = (error: any) => {
|
||
return Promise.reject(error);
|
||
};
|
||
|
||
// 响应拦截器
|
||
const responseInterceptor = (response: AxiosResponse) => {
|
||
// 文件流响应直接返回
|
||
if (
|
||
response.config.responseType === 'blob' ||
|
||
response.headers['content-type']?.includes('application/zip') ||
|
||
response.headers['content-type']?.includes('application/octet-stream')
|
||
) {
|
||
return response;
|
||
}
|
||
|
||
const res = response.data;
|
||
const httpStatus = response.status;
|
||
const code = res?.code;
|
||
const message = res?.message;
|
||
|
||
// 检查 token 相关错误
|
||
if (
|
||
httpStatus === 401 ||
|
||
code === 401 ||
|
||
message?.includes('token') ||
|
||
message === 'token is invalid' ||
|
||
message === 'token 解析失败' ||
|
||
message?.includes('decrypt error')
|
||
) {
|
||
handleTokenExpired();
|
||
return Promise.reject(new Error('登录状态已过期'));
|
||
}
|
||
|
||
// 处理模块未开通错误 (403)
|
||
// 跳过资产SKU查询接口,避免弹窗内部请求触发循环
|
||
const requestUrl = response.config.url || '';
|
||
if (code === 402 && !requestUrl.includes('/assets/asset/sku/')) {
|
||
// 获取当前路由路径
|
||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||
console.log('[request.ts] 检测到403错误,当前路径:', currentPath);
|
||
handleModuleNotEnabled(currentPath);
|
||
// 直接返回,不再显示错误消息
|
||
return Promise.reject(new Error('模块未开通'));
|
||
}
|
||
|
||
// 业务逻辑错误处理(排除403,因为上面已处理)
|
||
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
|
||
const errorMsg = message || `请求失败(${code})`;
|
||
showErrorMessage(errorMsg);
|
||
return Promise.reject(new Error(errorMsg));
|
||
}
|
||
|
||
return res;
|
||
};
|
||
|
||
// 响应错误拦截器
|
||
const responseErrorHandler = (error: any) => {
|
||
console.error('API请求错误:', error);
|
||
|
||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||
showErrorMessage('请求超时,请检查网络连接');
|
||
return Promise.reject(new Error('请求超时'));
|
||
}
|
||
|
||
if (!error.response) {
|
||
if (error.message === 'Network Error') {
|
||
// ElMessage.error('网络连接错误,请检查网络设置');
|
||
} else {
|
||
// ElMessage.error('网络异常,请检查连接');
|
||
}
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
const httpStatus = error.response.status;
|
||
// 优先使用返回数据中的 message 字段
|
||
const responseMessage = error.response.data?.message;
|
||
|
||
// 处理 HTTP 错误状态
|
||
const requestUrl = error.response.config?.url || '';
|
||
switch (httpStatus) {
|
||
case 401:
|
||
handleTokenExpired();
|
||
break;
|
||
case 402:
|
||
// 模块未开通处理,跳过SKU相关接口避免循环
|
||
if (!requestUrl.includes('/assets/asset/sku/') && !requestUrl.includes('getAssetAndSku')) {
|
||
// 检查是否刚从开通页面返回(5秒内不再跳转)
|
||
const lastSubscribeTime = sessionStorage.getItem('lastSubscribeTime');
|
||
const now = Date.now();
|
||
if (lastSubscribeTime && now - parseInt(lastSubscribeTime) < 5000) {
|
||
console.log('[responseErrorHandler] 刚完成开通,跳过402处理');
|
||
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面');
|
||
return Promise.reject(new Error('模块开通中'));
|
||
}
|
||
|
||
const currentPath = window.location.hash.replace('#', '') || window.location.pathname;
|
||
console.log('[responseErrorHandler] 检测到HTTP 402错误,当前路径:', currentPath);
|
||
handleModuleNotEnabled(currentPath);
|
||
return Promise.reject(new Error('模块未开通'));
|
||
}
|
||
showErrorMessage(responseMessage || '服务未开通');
|
||
break;
|
||
case 403:
|
||
showErrorMessage(responseMessage || '没有权限访问该资源');
|
||
break;
|
||
case 404:
|
||
showErrorMessage(responseMessage || '请求的资源不存在');
|
||
break;
|
||
case 429:
|
||
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试');
|
||
handleTokenExpired();
|
||
break;
|
||
case 500:
|
||
showErrorMessage(responseMessage || '服务器内部错误');
|
||
break;
|
||
case 502:
|
||
showErrorMessage(responseMessage || '网关错误');
|
||
break;
|
||
case 503:
|
||
showErrorMessage(responseMessage || '服务不可用');
|
||
break;
|
||
default:
|
||
if (httpStatus >= 400) {
|
||
showErrorMessage(responseMessage || `请求失败(${httpStatus})`);
|
||
}
|
||
}
|
||
|
||
return Promise.reject(error);
|
||
};
|
||
|
||
// 为实例添加拦截器
|
||
service.interceptors.request.use(requestInterceptor, requestErrorHandler);
|
||
service.interceptors.response.use(responseInterceptor, responseErrorHandler);
|
||
|
||
// 导出
|
||
export default service;
|
||
export { showErrorMessage };
|