Files
tms-erp-web/src/axios.js
T

318 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全站http配置
*
* axios参数说明
* isSerialize是否开启form表单提交
* isToken是否需要token
*/
import axios from 'axios';
import store from '@/store/';
import router from '@/router/';
import { serialize } from '@/utils/util';
import { getToken, getRefreshToken, removeToken, removeRefreshToken } from '@/utils/auth';
import { isURL, validatenull } from '@/utils/validate';
import { ElMessage } from 'element-plus';
import website from '@/config/website';
import NProgress from 'nprogress'; // progress bar
import 'nprogress/nprogress.css'; // progress bar style
import { Base64 } from 'js-base64';
import { baseUrl } from '@/config/env';
import crypto from '@/utils/crypto';
// 刷新令牌授权类型,其自身失败由 401 拦截逻辑统一收口
const REFRESH_GRANT_TYPE = 'refresh_token';
// 会话失效后只提示一次并跳转登录,在重新登录前抑制所有重复的失效提示
let isSessionExpired = false;
// 标记当前是否正在刷新令牌,避免并发请求重复发起刷新
let isRefreshing = false;
// 共享的刷新令牌 Promise,令并发的失效请求复用同一次刷新结果
let refreshTokenPromise = null;
// 判断是否为刷新令牌请求
const isRefreshTokenRequest = config =>
(config.url || '').includes('/oauth/token') &&
(config.params || {}).grant_type === REFRESH_GRANT_TYPE;
// 判断是否为IAM统一身份认证登录请求
const isIamSsoLoginRequest = config => (config.url || '').includes('/iam/sso/token');
// 判断是否为重新登录请求:会开启新的会话周期
const isLoginRequest = config => {
const grantType = (config.params || {}).grant_type;
return (
isIamSsoLoginRequest(config) ||
(config.url || '').includes('/oauth/token') && !!grantType && grantType !== REFRESH_GRANT_TYPE
);
};
// 为请求写入最新的访问令牌,供初始请求与刷新后的重放共用
const applyToken = config => {
const meta = config.meta || {};
if (meta.isToken === false) return;
const token = getToken();
if (!token) return;
config.headers[website.tokenHeader] =
config.cryptoToken === true
? 'crypto ' + crypto.encryptAES(token, crypto.cryptoKey)
: 'bearer ' + token;
};
// 会话失效统一出口:整段失效期内只提示一次,清理登录态并跳转登录页
const redirectToLogin = () => {
if (isSessionExpired) return;
isSessionExpired = true;
ElMessage({
message: '登录状态已失效,请重新登录',
type: 'error',
});
removeToken();
removeRefreshToken();
store.dispatch('FedLogOut').then(() => router.push({ path: '/login' }));
};
/**
* 统一弹出错误提示并返回带标记的 Error。
* `_notified` 标记用于让业务层(批量导入等)识别「拦截器已经提示过」,
* 避免同一条错误被弹两次。
* meta.suppressErrorMessage=true 时由业务层自行弹框汇总,拦截器不再 toast。
*/
const notifiedError = (message, config) => {
if (config?.meta?.suppressErrorMessage !== true) {
ElMessage({
message: message,
type: 'error',
});
}
const error = new Error(message);
error._notified = true;
return error;
};
axios.defaults.timeout = 10000;
//返回其他状态码
axios.defaults.validateStatus = function (status) {
return status >= 200 && status <= 500; // 默认的
};
/**
* 判断响应是否为 JSON。
* 用于 blob 请求(导入/导出)的兜底:后端在业务校验失败时返回的是 JSON
* (Content-Type: application/json),而不是文件流,需要解析后才能取到 msg。
*/
const isJsonContentType = res => {
const headers = res.headers || {};
const contentType = String(headers['content-type'] || res.data?.type || '').toLowerCase();
return contentType.includes('application/json') || contentType.includes('text/json');
};
/**
* 是否为可按文本读取的 Blob / Blob-like(含跨 realm)。
* 禁止对普通业务 JSON 对象调用 .text()。
*/
const isBlobLikeData = value =>
value != null &&
typeof value === 'object' &&
typeof value.size === 'number' &&
typeof value.type === 'string' &&
typeof value.text === 'function';
/**
* 判断响应体是否已是解析好的对象(无需再解析)。
* 注意 Blob 也是 object,必须排除,否则会把待解析的文件流误判为已完成。
*/
const isPlainObject = value =>
value !== null &&
typeof value === 'object' &&
!(value instanceof Blob) &&
!isBlobLikeData(value) &&
!(value instanceof ArrayBuffer);
//跨域请求,允许保存cookie
axios.defaults.withCredentials = true;
// NProgress Configuration
NProgress.configure({
showSpinner: false,
});
//http request拦截
axios.interceptors.request.use(
config => {
// start progress bar
NProgress.start();
//地址为已经配置状态则不添加前缀
if (!isURL(config.url) && !config.url.startsWith(baseUrl)) {
config.url = baseUrl + config.url;
}
//安全请求header
config.headers['Blade-Requested-With'] = 'BladeHttpRequest';
//设置语言请求头
config.headers['Accept-Language'] = store.getters.language || 'zh-CN';
//headers判断是否需要
const authorization = config.authorization === false;
if (!authorization) {
config.headers['Authorization'] = `Basic ${Base64.encode(
`${website.clientId}:${website.clientSecret}`
)}`;
}
//headers判断请求是否携带token
const meta = config.meta || {};
// 重新登录会开启新的会话周期,解除失效锁以便后续失效提示能再次触发
if (isLoginRequest(config)) {
isSessionExpired = false;
}
// 写入访问令牌
applyToken(config);
//判断传递数据是否加密
const cryptoData = config.cryptoData === true;
// 开启报文加密
if (cryptoData) {
if (config.params) {
const data = crypto.encryptAES(JSON.stringify(config.params), crypto.aesKey);
config.params = { data };
}
if (config.data) {
config.text = true;
config.data = crypto.encryptAES(JSON.stringify(config.data), crypto.aesKey);
}
}
//headers中配置text请求
if (config.text === true) {
config.headers['Content-Type'] = 'text/plain';
}
//headers中配置serialize为true开启序列化
if (config.method === 'post' && meta.isSerialize === true) {
config.data = serialize(config.data);
}
return config;
},
error => {
return Promise.reject(error);
}
);
//http response拦截
axios.interceptors.response.use(
async res => {
NProgress.done();
//获取配置信息
const config = res.config;
const cryptoData = config.cryptoData === true;
//解析加密报文
if (cryptoData) {
res.data = JSON.parse(crypto.decryptAES(res.data, crypto.aesKey));
}
// blob 请求(文件下载、导入)的响应体是 Blob,直接读 res.data.code / res.data.msg
// 拿不到任何字段:状态码会退回 res.status、提示文案会落到「系统错误」兜底,
// 用户就会以为系统崩了——而后端其实返回了明确的业务提示(如「未读取到数据行…」)。
// 这里按 Content-Type 判断:JSON 才解析回对象;真·文件流(Excel 等)保持原样不动。
// 兼容 Blob(浏览器 responseType='blob')与 string(非浏览器环境)两种形态。
// 仅对真正的 Blob/Blob-like 调 .text(),避免业务 JSON 已解析后出现
// 「e.data.text is not a function」。
if (isJsonContentType(res) && !isPlainObject(res.data)) {
try {
let text = '';
const raw = res.data;
if (isBlobLikeData(raw) || (typeof Blob !== 'undefined' && raw instanceof Blob)) {
text = await raw.text();
} else if (typeof raw === 'string') {
text = raw;
} else if (raw && typeof raw.arrayBuffer === 'function') {
text = new TextDecoder().decode(await raw.arrayBuffer());
} else if (raw instanceof ArrayBuffer) {
text = new TextDecoder().decode(raw);
}
const parsed = typeof text === 'string' && text ? JSON.parse(text) : null;
if (parsed && typeof parsed === 'object') {
res.data = parsed;
}
} catch (ignored) {
// 声明是 JSON 却解析失败:保持原样,交给下游按文件处理
}
}
// 获取状态信息:文件流没有 code/msg,必须退回 HTTP status,禁止读 Blob 上的幽灵字段
const payload = isPlainObject(res.data) ? res.data : {};
const status = Number(payload.error_code || payload.code || res.status);
const statusWhiteList = website.statusWhiteList || [];
const message = payload.msg || payload.error_description || '系统错误';
//如果在白名单里则自行catch逻辑处理
if (statusWhiteList.includes(status)) return Promise.reject(res);
// 令牌失效且尚未重试:尝试刷新令牌后重放原请求
if (status === 401 && !config._retry) {
// 已登出(本地无令牌):残留请求的 401 属预期,后端已拒绝,前端静默忽略即可
if (!getToken()) {
return Promise.reject(new Error(message));
}
config._retry = true;
// 无刷新令牌时不再发起空刷新,直接进入失效流程
if (validatenull(getRefreshToken())) {
redirectToLogin();
return Promise.reject(new Error(message));
}
// 并发的失效请求复用同一次刷新,避免重复刷新与重复提示
if (!isRefreshing) {
isRefreshing = true;
refreshTokenPromise = store
.dispatch('RefreshToken')
.catch(error => {
redirectToLogin();
return Promise.reject(error);
})
.finally(() => {
isRefreshing = false;
});
}
return refreshTokenPromise.then(() => {
applyToken(config);
return axios(config);
});
}
// 刷新后仍然失效:会话已不可恢复,统一跳转登录
if (status === 401 && config._retry) {
redirectToLogin();
return Promise.reject(new Error(message));
}
// 刷新令牌请求:成功响应(200)须放行给刷新动作取用新令牌;仅其自身失败(error_code)时静默丢弃,
// 交由上方 401 逻辑统一收口,避免与失效跳转重复提示
if (isRefreshTokenRequest(config) && status !== 200) {
return Promise.reject(new Error(message));
}
// OAuth2 业务错误码提示
if (status > 2000 && !validatenull(res.data.error_description)) {
return Promise.reject(notifiedError(message, config));
}
// 其余非 200 响应统一提示
if (status !== 200) {
return Promise.reject(notifiedError(message, config));
}
return res;
},
error => {
NProgress.done();
// 网络级失败(HTTP 状态 >500、网关错误、超时、断网等 validateStatus 放行之外的响应)
// 原为静默 reject,接口报错页面无任何感知;统一补错误提示,取消请求与刷新令牌请求除外
if (
!axios.isCancel(error) &&
!isRefreshTokenRequest(error?.config) &&
error?.config?.meta?.suppressErrorMessage !== true
) {
ElMessage({
message: error?.response?.data?.msg || '请求失败,请稍后重试',
type: 'error',
});
}
return Promise.reject(new Error(error));
// 保留原始 axios 错误对象:下游(如导入失败明细解析)需要 error.response
// 读取响应体。若包装成 new Error(error),response 会丢失、message 退化成
// "[object Object]",用户只能看到无意义的通用提示。
}
);
export default axios;