2a5eca8393
Co-authored-by: Cursor <cursoragent@cursor.com>
266 lines
10 KiB
JavaScript
266 lines
10 KiB
JavaScript
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();
|
||
}
|
||
};
|