Files
tms-erp-web/src/utils/import-excel.js
T
weicw-dshsa fc7df95c1a 🐛 修复导入错误提示重复弹出并丢失业务文案
- axios 拦截器:blob 请求(导入/导出)在业务校验失败时后端返回的是
  JSON(Content-Type: application/json),而 axios 在 responseType='blob'
  下把响应体解析成 Blob,直接读 res.data.msg 取不到任何字段,
  status 退回 res.status、文案落到「系统错误」兜底,用户误以为系统崩溃。
  现按 Content-Type 判断:JSON 才解析回对象,真·文件流保持原样不动。
- 统一提示出口 notifiedError():弹窗后给 Error 打 _notified 标记,
  业务层据此避免同一条错误被弹两次。
- import-excel:拦截器已提示过的错误不再重复 $message.error;
  >500/网络异常等仍由 resolveErrorMessage 从 Blob 中取 msg 兜底。
2026-09-26 12:21:20 +08:00

169 lines
6.4 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.
import { importBlob } from '@/api/common';
import { downloadXls } from '@/utils/util';
import { ElLoading } from 'element-plus';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
const excelPattern = /\.(xls|xlsx)$/;
const isExcelResponse = res => {
const headers = res.headers || {};
const contentType = String(headers['content-type'] || res.data?.type || '').toLowerCase();
const contentDisposition = String(headers['content-disposition'] || '').toLowerCase();
if (
contentType.includes('application/vnd.ms-excel') ||
contentType.includes('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
) {
return true;
}
// 网关或对象存储可能将 Excel 流标记为二进制类型,此时通过下载文件名识别。
return (
contentType.includes('application/octet-stream') &&
/\.(xls|xlsx)(?:["';]|$)/.test(contentDisposition)
);
};
const parseBlobJson = async blob => {
const text = await blob.text();
return text ? JSON.parse(text) : {};
};
/**
* 解析导入失败时的错误提示文案。
*
* 背景:导入接口统一用 `responseType: 'blob'` 请求,因此**非 2xx 响应体也是 Blob**。
* axios 的错误分支不做解析,直接 reject,`error.message` 只有
* 「Request failed with status code 400」这类通用文案(或全局兜底的「系统错误」),
* 用户会误以为系统崩了——而后端其实已返回了明确的中文业务提示(如「导入数据不能为空」)。
*
* 这里补上这一步:从错误响应的 Blob 里取出后端 `msg`,取不到再退回通用文案。
*/
const resolveErrorMessage = async error => {
const blob = error?.response?.data ?? error?.data;
if (blob instanceof Blob) {
try {
const result = await parseBlobJson(blob);
if (result?.msg) return result.msg;
} catch (ignored) {
// 响应体不是 JSON(如网关返回的 HTML 错误页),退回通用文案
}
}
const fallback = error?.response?.data?.msg || error?.message;
// axios 的英文通用文案对用户没有意义,统一换成中文提示
if (!fallback || /Request failed with status code/i.test(fallback)) {
return '导入失败,请检查导入文件后重试';
}
return fallback;
};
/**
* 加工后端返回的导入失败明细 Excel。
* 若传入 failDetailDecorator(函数),则动态加载 exceljs 读取 blob 流,
* 交由 decorator 完成改写原因列、调整列宽等加工,再导出新的 blob;
* 否则原样返回 blob(保持旧行为,不影响未使用 decorator 的模块)。
* @param {Blob} blob 后端返回的失败明细 Excel 流
* @param {String} businessName 业务名称(如「货物类型」)
* @param {Function} [failDetailDecorator] async (workbook, { businessName }) => void
* @returns {Promise<Blob>}
*/
const decorateFailExcel = async (blob, businessName, failDetailDecorator) => {
if (typeof failDetailDecorator !== 'function') return blob;
const ExcelJS = await import('exceljs');
const buf = await blob.arrayBuffer();
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buf);
await failDetailDecorator(workbook, { businessName });
const out = await workbook.xlsx.writeBuffer();
return new Blob([out], { type: 'application/vnd.ms-excel' });
};
/**
* 打开导入弹窗
* @param {Object} vm 组件实例
* @param {String} businessName 业务名称,用于失败明细文件命名
* @param {Function} [onSuccess] 导入成功回调
* @param {Object} [options] 扩展项
* @param {Function} [options.failDetailDecorator] 失败明细加工函数,用于对后端返回的失败 Excel 标红/改写原因
* @param {Number} [options.timeout=60000] 导入请求超时时间(毫秒)
*/
export const openImportDialog = (vm, businessName, onSuccess, options = {}) => {
const { failDetailDecorator, timeout = 60000 } = options;
const excelOption = vm.excelOption || vm.excelOptionConfig;
const column = vm.findColumn(excelOption?.column || [], 'excelFile');
if (!column) {
vm.$message.error('导入配置异常,请稍后重试');
return;
}
column.httpRequest = (option, uploadColumn) =>
handleImportExcel(vm, option, uploadColumn, businessName, onSuccess, {
failDetailDecorator,
timeout,
});
vm.excelBox = true;
};
export const handleImportExcel = async (vm, option, column, businessName, onSuccess, options = {}) => {
const { failDetailDecorator, timeout = 60000 } = options;
const file = option.file;
const fileName = file && file.name ? file.name.toLowerCase() : '';
if (!excelPattern.test(fileName)) {
vm.$message.error('请上传 .xls,.xlsx 标准格式文件');
if (typeof option.onError === 'function') {
option.onError(new Error('请上传 .xls,.xlsx 标准格式文件'));
}
return;
}
NProgress.start();
const loading = ElLoading.service({
lock: true,
text: '导入中',
background: 'rgba(255, 255, 255, 0.7)',
});
try {
const res = await importBlob(column.action, file, { timeout });
if (isExcelResponse(res)) {
const decorated = await decorateFailExcel(res.data, businessName, failDetailDecorator);
downloadXls(
decorated,
`${businessName}导入失败明细${vm.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
);
vm.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const result = await parseBlobJson(res.data);
if (result.code !== 200) {
vm.$message.error(result.msg || '导入失败');
if (typeof option.onError === 'function') {
option.onError(new Error(result.msg || '导入失败'));
}
return;
}
vm.$message.success('导入完成');
}
vm.excelBox = false;
vm.excelForm = {};
if (typeof onSuccess === 'function') {
onSuccess();
} else if (typeof vm.onLoad === 'function') {
vm.onLoad(vm.page, vm.query);
} else if (typeof vm.initTree === 'function') {
vm.initTree();
}
if (typeof option.onSuccess === 'function') {
option.onSuccess(res);
}
} catch (error) {
// 拦截器已提示过的错误(如后端 400 返回的业务提示)不再重复弹一次,
// 只保留一次清晰的中文提示。
if (!error?._notified) {
vm.$message.error(await resolveErrorMessage(error));
}
if (typeof option.onError === 'function') {
option.onError(error);
}
} finally {
loading.close();
NProgress.done();
}
};