Files
tms-erp-web/src/utils/import-excel.js
T

266 lines
10 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)$/;
/** 是否为浏览器 Blob(含跨 realm 的 Blob-like) */
export const isBlobLike = value =>
value != null &&
typeof value === 'object' &&
typeof value.size === 'number' &&
typeof value.type === 'string' &&
(typeof value.text === 'function' || typeof value.arrayBuffer === 'function');
/** axios 拦截器已把 JSON Blob 解析成的业务对象 */
export const isBusinessJson = value =>
value != null &&
typeof value === 'object' &&
!isBlobLike(value) &&
!(value instanceof ArrayBuffer) &&
('code' in value || 'success' in value || 'msg' in value);
export const isExcelResponse = res => {
// 已是业务 JSON(导入成功/失败提示)时,绝不能当失败明细 Excel 处理
if (isBusinessJson(res?.data)) {
return false;
}
// 只有文件流才可能是失败明细;普通对象没有 .text/.arrayBuffer
if (!isBlobLike(res?.data)) {
return false;
}
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)
);
};
/**
* 解析导入接口响应体为 JSON 对象。
* <p>
* axios 拦截器在 Content-Type 为 JSON 时,会把 `responseType: 'blob'` 的 Blob
* 提前解析成普通对象;此处需同时兼容 Blob / 字符串 / 已解析对象,避免再调
* `blob.text()` 时出现「blob.text is not a function」。
*/
export const parseBlobJson = async data => {
if (data == null) {
return {};
}
if (isBusinessJson(data)) {
return data;
}
if (typeof data === 'object' && !isBlobLike(data) && !(data instanceof ArrayBuffer)) {
return data;
}
if (typeof data === 'string') {
return data ? JSON.parse(data) : {};
}
let text = '';
if (typeof data.text === 'function') {
text = await data.text();
} else if (typeof data.arrayBuffer === 'function') {
text = new TextDecoder().decode(await data.arrayBuffer());
} else if (data instanceof ArrayBuffer) {
text = new TextDecoder().decode(data);
}
return text ? JSON.parse(text) : {};
};
/**
* 解析导入失败时的错误提示文案。
*
* 背景:导入接口统一用 `responseType: 'blob'` 请求,因此**非 2xx 响应体也是 Blob**。
* axios 的错误分支不做解析,直接 reject,`error.message` 只有
* 「Request failed with status code 400」这类通用文案(或全局兜底的「系统错误」),
* 用户会误以为系统崩了——而后端其实已返回了明确的中文业务提示(如「导入数据不能为空」)。
*
* 这里补上这一步:从错误响应的 Blob 里取出后端 `msg`,取不到再退回通用文案。
*/
export const resolveImportErrorMessage = async error => {
const payload = error?.response?.data ?? error?.data;
if (isBusinessJson(payload) && payload.msg) {
return payload.msg;
}
if (isBlobLike(payload) || typeof payload === 'string') {
try {
const result = await parseBlobJson(payload);
if (result?.msg) return result.msg;
} catch (ignored) {
// 响应体不是 JSON(如网关返回的 HTML 错误页),退回通用文案
}
}
const fallback = payload?.msg || error?.message;
// axios 的英文通用文案对用户没有意义,统一换成中文提示
if (!fallback || /Request failed with status code|\.text is not a function/i.test(fallback)) {
return '导入失败,请检查导入文件后重试';
}
return fallback;
};
/**
* 统一消费导入接口响应:成功 JSON / 失败 Excel 流。
* 兼容 axios 已把 JSON Blob 解析成对象的情况,禁止再对普通对象调 .text()。
*/
export const consumeImportResponse = async (res, options = {}) => {
const { businessName = '导入', dayjsFormat } = options;
if (isExcelResponse(res)) {
return {
kind: 'excel',
blob: res?.data,
fileName: `${businessName}导入失败明细${dayjsFormat || ''}.xlsx`,
};
}
const result = isBusinessJson(res?.data) ? res.data : await parseBlobJson(res?.data);
if (Number(result?.code) !== 200) {
throw new Error(result?.msg || '导入失败');
}
return { kind: 'success', result };
};
/**
* 加工后端返回的导入失败明细 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;
if (!isBlobLike(blob) && !(blob instanceof ArrayBuffer)) {
return blob;
}
const ExcelJS = await import('exceljs');
const buf = typeof blob.arrayBuffer === 'function' ? await blob.arrayBuffer() : blob;
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' });
};
/** 通知上传组件成功:只传业务响应体,避免其误调 axios 响应上的 .text() */
const notifyUploadSuccess = (option, res) => {
if (typeof option?.onSuccess !== 'function') {
return;
}
try {
const body = isBusinessJson(res?.data) ? res.data : { code: 200, success: true };
option.onSuccess(body);
} catch (callbackError) {
console.warn('upload onSuccess callback failed', callbackError);
}
};
/** 通知上传组件失败 */
const notifyUploadError = (option, error) => {
if (typeof option?.onError !== 'function') {
return;
}
try {
option.onError(error instanceof Error ? error : new Error(String(error?.message || error || '导入失败')));
} catch (callbackError) {
console.warn('upload onError callback failed', callbackError);
}
};
/**
* 打开导入弹窗
* @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 标准格式文件');
notifyUploadError(option, 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 {
// 成功体常被 axios 拦截器提前解析成对象;优先走业务 JSON,避免再调 Blob.text
const result = isBusinessJson(res?.data) ? res.data : await parseBlobJson(res?.data);
if (Number(result.code) !== 200) {
vm.$message.error(result.msg || '导入失败');
notifyUploadError(option, 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();
}
// 上传组件 onSuccess 约定接收「响应体」,不是完整 axios 响应。
// 若传入 axios 响应对象,部分环境下会按 Fetch Response 调用 .text(),
// 从而在导入已成功后仍弹出「e.text is not a function」。
notifyUploadSuccess(option, res);
} catch (error) {
// 拦截器已提示过的错误(如后端 400 返回的业务提示)不再重复弹一次,
// 只保留一次清晰的中文提示。
if (!error?._notified) {
vm.$message.error(await resolveImportErrorMessage(error));
}
notifyUploadError(option, error);
} finally {
loading.close();
NProgress.done();
}
};