xiaoyi
2026-10-26 3ee139949840b4ba3caa320312a564e3cfbbfdd1
fix 上传
已修改2个文件
176 ■■■■■ 文件已修改
src/api/erp/purchase/request/index.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/import-modal/import-modal.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/request/index.ts
@@ -132,10 +132,27 @@
  return requestClient.download('/purchase/request/get-import-template');
}
/** 导入采购申请 */
export function importPurchaseRequest(file: File, updateSupport: boolean) {
  return requestClient.upload('/purchase/request/import', {
    file,
    updateSupport,
  });
/** 导入结果(与后端 ErpPurchaseRequestImportRespVO 对应) */
export interface PurchaseRequestImportResult {
  /** 成功创建的采购申请单号 */
  createPurchaseRequestNos?: string[];
  /** 失败集合:key 为单号或行号,value 为失败原因 */
  failurePurchaseRequestNos?: Record<string, string>;
  /** 被跳过的行:key 为 Excel 行号,value 为跳过原因 */
  skippedRows?: Record<string, string>;
  /** Excel 中实际有数据的行数 */
  totalRows?: number;
  /** 成功导入的行数 */
  successRows?: number;
}
/** 导入采购申请 */
export function importPurchaseRequest(file: File, updateSupport?: boolean) {
  return requestClient.upload<PurchaseRequestImportResult>(
    '/purchase/request/import',
    {
      file,
      updateSupport,
    },
  );
}
src/components/import-modal/import-modal.vue
@@ -1,15 +1,37 @@
<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 {
  /** 弹窗标题,如「导入用户」 */
@@ -60,6 +82,72 @@
  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) {
@@ -76,16 +164,63 @@
    // 提交表单
    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) {
@@ -121,4 +256,4 @@
      </div>
    </template>
  </Modal>
</template>
</template>