| | |
| | | <script lang="ts" setup> |
| | | import type { FileType } from 'ant-design-vue/es/upload/interface'; |
| | | |
| | | import { ref } from 'vue'; |
| | | import { h, ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '@vben/common-ui'; |
| | | import { downloadFileFromBlobPart } from '@vben/utils'; |
| | | |
| | | import { Button, message, Upload } from 'ant-design-vue'; |
| | | import { Button, message, Modal as AntModal, Upload } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm, z } from '#/adapter/form'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | /** |
| | | * 导入接口返回结构。 |
| | | * |
| | | * 后端各模块的导入返回值不统一,这里只描述「可展示的导入结果」的公共部分: |
| | | * 成功的条数、失败的集合、被跳过的行。识别不出的返回值按「全部成功」处理。 |
| | | */ |
| | | export interface ImportResultLike { |
| | | /** 成功导入的单号/名称列表 */ |
| | | createPurchaseRequestNos?: string[]; |
| | | /** 失败集合:key 为单号或行号,value 为原因 */ |
| | | failurePurchaseRequestNos?: Record<string, string>; |
| | | /** 被跳过的行:key 为 Excel 行号,value 为原因 */ |
| | | skippedRows?: Record<string, string>; |
| | | /** Excel 中实际有数据的行数 */ |
| | | totalRows?: number; |
| | | /** 成功导入的行数 */ |
| | | successRows?: number; |
| | | /** 其它模块的通用字段:失败列表 */ |
| | | failureList?: Record<string, string> | string[]; |
| | | [key: string]: unknown; |
| | | } |
| | | |
| | | export interface ImportModalProps { |
| | | /** 弹窗标题,如「导入用户」 */ |
| | |
| | | showDefaultActions: false, |
| | | }); |
| | | |
| | | /** |
| | | * 从导入接口的返回值里提取可展示的结果。 |
| | | * |
| | | * 关键点:不能只看接口有没有报错就提示「操作成功」——后端可能跳过了部分行, |
| | | * 这些行不会抛异常,必须把跳过/失败的数量和行号明确告诉用户,否则数据无声丢失。 |
| | | */ |
| | | function summarizeImportResult(raw: unknown) { |
| | | const result = (raw ?? {}) as ImportResultLike; |
| | | |
| | | // 成功条数:优先用后端给的成功行数,其次用成功单号数组长度 |
| | | const successNos = Array.isArray(result.createPurchaseRequestNos) |
| | | ? result.createPurchaseRequestNos |
| | | : []; |
| | | const successRows = |
| | | typeof result.successRows === 'number' ? result.successRows : successNos.length; |
| | | |
| | | const failureEntries = toEntries(result.failurePurchaseRequestNos ?? result.failureList); |
| | | const skippedEntries = toEntries(result.skippedRows); |
| | | |
| | | const totalRows = typeof result.totalRows === 'number' ? result.totalRows : undefined; |
| | | |
| | | return { |
| | | successRows, |
| | | failureEntries, |
| | | skippedEntries, |
| | | totalRows, |
| | | hasIssue: failureEntries.length > 0 || skippedEntries.length > 0, |
| | | }; |
| | | } |
| | | |
| | | /** 把 Record / string[] / undefined 统一成 [label, reason][] */ |
| | | function toEntries(input: unknown): Array<[string, string]> { |
| | | if (!input) { |
| | | return []; |
| | | } |
| | | if (Array.isArray(input)) { |
| | | return input.map((item) => [String(item), '']); |
| | | } |
| | | if (typeof input === 'object') { |
| | | return Object.entries(input as Record<string, string>).map(([k, v]) => [ |
| | | k, |
| | | String(v ?? ''), |
| | | ]); |
| | | } |
| | | return []; |
| | | } |
| | | |
| | | /** 把结果拼成用户能看懂的一段话 */ |
| | | function buildResultMessage(summary: ReturnType<typeof summarizeImportResult>) { |
| | | const parts: string[] = []; |
| | | if (summary.successRows > 0) { |
| | | parts.push(`成功 ${summary.successRows} 条`); |
| | | } |
| | | if (summary.skippedEntries.length > 0) { |
| | | parts.push(`跳过 ${summary.skippedEntries.length} 条`); |
| | | } |
| | | if (summary.failureEntries.length > 0) { |
| | | parts.push(`失败 ${summary.failureEntries.length} 条`); |
| | | } |
| | | if (parts.length === 0) { |
| | | return '导入完成,但没有导入任何数据'; |
| | | } |
| | | const total = summary.totalRows === undefined ? '' : `(表格共 ${summary.totalRows} 行)`; |
| | | return `导入完成:${parts.join(',')}${total}`; |
| | | } |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | async onOpenChange(isOpen: boolean) { |
| | | if (isOpen) { |
| | |
| | | // 提交表单 |
| | | const data = await formApi.getValues(); |
| | | try { |
| | | await config.value.importFn(data.file, data.updateSupport); |
| | | // 关闭并提示 |
| | | // 统一兜底:后端各模块历史实现对 updateSupport 的来源不一致(有的从 query 读、 |
| | | // 有的从表单读),漏传会被 @NotNull 拦下并弹出用户根本看不到对应字段的 |
| | | // "是否支持更新不能为空"。这里强制显式传布尔值,避免任何页面因未初始化踩坑。 |
| | | const updateSupport = data.updateSupport === true; |
| | | const raw = await config.value.importFn(data.file, updateSupport); |
| | | const summary = summarizeImportResult(raw); |
| | | const text = buildResultMessage(summary); |
| | | |
| | | if (summary.hasIssue) { |
| | | // 有跳过或失败的行:保持弹窗打开,把明细展示给用户,避免数据无声丢失 |
| | | message.warning({ |
| | | content: text, |
| | | duration: 8, |
| | | }); |
| | | showDetail(summary); |
| | | emit('success'); |
| | | return; |
| | | } |
| | | |
| | | await modalApi.close(); |
| | | emit('success'); |
| | | message.success($t('ui.actionMessage.operationSuccess')); |
| | | message.success(text); |
| | | } finally { |
| | | modalApi.unlock(); |
| | | } |
| | | }, |
| | | }); |
| | | |
| | | /** 用 Modal.info 展示跳过/失败明细,比 toast 更耐读 */ |
| | | function showDetail(summary: ReturnType<typeof summarizeImportResult>) { |
| | | const sections: Array<{ title: string; rows: Array<[string, string]> }> = []; |
| | | if (summary.skippedEntries.length > 0) { |
| | | sections.push({ title: '被跳过的行(未导入)', rows: summary.skippedEntries }); |
| | | } |
| | | if (summary.failureEntries.length > 0) { |
| | | sections.push({ title: '导入失败', rows: summary.failureEntries }); |
| | | } |
| | | AntModal.info({ |
| | | title: '导入结果明细', |
| | | width: 560, |
| | | content: h( |
| | | 'div', |
| | | { style: 'max-height: 360px; overflow-y: auto;' }, |
| | | sections.map((section) => |
| | | h('div', { style: 'margin-bottom: 12px;' }, [ |
| | | h('div', { style: 'font-weight: 600; margin-bottom: 4px;' }, section.title), |
| | | ...section.rows.map(([label, reason]) => |
| | | h('div', { style: 'font-size: 13px; line-height: 20px;' }, [ |
| | | h('span', { style: 'color: #cf1322;' }, `${label}`), |
| | | h('span', {}, `:${reason || '未说明原因'}`), |
| | | ]), |
| | | ), |
| | | ]), |
| | | ), |
| | | ), |
| | | }); |
| | | } |
| | | |
| | | /** 上传前 */ |
| | | function beforeUpload(file: FileType) { |
| | |
| | | </div> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | </template> |