From d1013ed3371659c2835106fffeb772309607a451 Mon Sep 17 00:00:00 2001
From: tuqin <tuqin@tuqin.cn>
Date: 星期五, 11 九月 2026 09:39:45 +0800
Subject: [PATCH] feat(crm/erp): 新增采购申请、合同管理导入弹窗

---
 src/views/crm/contract/modules/import-form.vue         |  155 ++++++++++++++++++++++
 src/views/erp/purchase/request/index.vue               |   19 ++
 src/api/crm/contract/index.ts                          |   20 ++
 src/views/erp/purchase/request/modules/import-form.vue |  158 ++++++++++++++++++++++
 src/api/erp/purchase/request/index.ts                  |   20 ++
 src/views/crm/contract/index.vue                       |   19 ++
 6 files changed, 391 insertions(+), 0 deletions(-)

diff --git a/src/api/crm/contract/index.ts b/src/api/crm/contract/index.ts
index 7bf66e4..808b869 100644
--- a/src/api/crm/contract/index.ts
+++ b/src/api/crm/contract/index.ts
@@ -160,3 +160,23 @@
 export function generateSaleOrder(id: number) {
   return requestClient.post<number>('/crm/contract/generate-sale-order', null, { params: { id } });
 }
+
+/** 鍚堝悓瀵煎叆缁撴灉 */
+export interface ImportResult {
+  /** 鍒涘缓鎴愬姛鐨勫悎鍚岀紪鍙� */
+  createList?: string[];
+  /** 鏇存柊鎴愬姛鐨勫悎鍚岀紪鍙� */
+  updateList?: string[];
+  /** 瀵煎叆澶辫触闆嗗悎锛宬ey 涓哄悎鍚岀紪鍙锋垨琛屽彿锛寁alue 涓哄け璐ュ師鍥� */
+  failureList?: Record<string, string>;
+}
+
+/** 涓嬭浇鍚堝悓瀵煎叆妯℃澘 */
+export function importContractTemplate() {
+  return requestClient.download('/crm/contract/get-import-template');
+}
+
+/** 瀵煎叆鍚堝悓 */
+export function importContract(file: File) {
+  return requestClient.upload<ImportResult>('/crm/contract/import', { file });
+}
diff --git a/src/api/erp/purchase/request/index.ts b/src/api/erp/purchase/request/index.ts
index 3ba18a9..3553382 100644
--- a/src/api/erp/purchase/request/index.ts
+++ b/src/api/erp/purchase/request/index.ts
@@ -46,6 +46,16 @@
   }
 }
 
+/** 閲囪喘鐢宠瀵煎叆缁撴灉 */
+export interface ImportResult {
+  /** 鍒涘缓鎴愬姛鐨勯噰璐敵璇峰崟鍙� */
+  createList?: string[];
+  /** 鏇存柊鎴愬姛鐨勯噰璐敵璇峰崟鍙� */
+  updateList?: string[];
+  /** 瀵煎叆澶辫触闆嗗悎锛宬ey 涓洪噰璐敵璇峰崟鍙锋垨琛屽彿锛寁alue 涓哄け璐ュ師鍥� */
+  failureList?: Record<string, string>;
+}
+
 /** 鍒涘缓閲囪喘鐢宠 */
 export function createPurchaseRequest(data: ErpPurchaseRequestApi.PurchaseRequest) {
   return requestClient.post('/erp/purchase-request/create', data);
@@ -134,3 +144,13 @@
     }
   }
 }
+
+/** 涓嬭浇閲囪喘鐢宠瀵煎叆妯℃澘 */
+export function importPurchaseRequestTemplate() {
+  return requestClient.download('/erp/purchase-request/get-import-template');
+}
+
+/** 瀵煎叆閲囪喘鐢宠 */
+export function importPurchaseRequest(file: File) {
+  return requestClient.upload<ImportResult>('/erp/purchase-request/import', { file });
+}
diff --git a/src/views/crm/contract/index.vue b/src/views/crm/contract/index.vue
index cd385ed..b4f4f4b 100644
--- a/src/views/crm/contract/index.vue
+++ b/src/views/crm/contract/index.vue
@@ -23,12 +23,18 @@
 
 import { useGridColumns, useGridFormSchema } from './data';
 import Form from './modules/form.vue';
+import ImportForm from './modules/import-form.vue';
 
 const { push } = useRouter();
 const sceneType = ref('1');
 
 const [FormModal, formModalApi] = useVbenModal({
   connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [ImportModal, importModalApi] = useVbenModal({
+  connectedComponent: ImportForm,
   destroyOnClose: true,
 });
 
@@ -51,6 +57,11 @@
     ...formValues,
   });
   downloadFileFromBlobPart({ fileName: '鍚堝悓.xls', source: data });
+}
+
+/** 瀵煎叆鍚堝悓 */
+function handleImport() {
+  importModalApi.open();
 }
 
 /** 鍒涘缓鍚堝悓 */
@@ -205,6 +216,7 @@
 <template>
   <Page auto-content-height>
     <FormModal @success="handleRefresh" />
+    <ImportModal @success="handleRefresh" />
     <Grid>
       <template #toolbar-actions>
         <Tabs class="w-full" @change="handleChangeSceneType">
@@ -230,6 +242,13 @@
               auth: ['crm:contract:export'],
               onClick: handleExport,
             },
+            {
+              label: '瀵煎叆',
+              type: 'primary',
+              icon: ACTION_ICON.UPLOAD,
+              auth: ['crm:contract:import'],
+              onClick: handleImport,
+            },
           ]"
         />
       </template>
diff --git a/src/views/crm/contract/modules/import-form.vue b/src/views/crm/contract/modules/import-form.vue
new file mode 100644
index 0000000..0cd990d
--- /dev/null
+++ b/src/views/crm/contract/modules/import-form.vue
@@ -0,0 +1,155 @@
+<script lang="ts" setup>
+import type { FileType } from 'ant-design-vue/es/upload/interface';
+import type { ImportResult } from '#/api/crm/contract';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+import { downloadFileFromBlobPart } from '#/packages/utils/src';
+
+import { Button, message, Progress, Upload } from 'ant-design-vue';
+
+import { importContract, importContractTemplate } from '#/api/crm/contract';
+
+const emit = defineEmits(['success']);
+
+const fileList = ref<FileType[]>([]);
+const importResult = ref<ImportResult>();
+const importing = ref(false);
+const progressPercent = ref(0);
+let progressTimer: ReturnType<typeof setInterval> | undefined;
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    let shouldClose = false;
+    if (fileList.value.length === 0) {
+      message.warning('璇烽�夋嫨瑕佸鍏ョ殑鏂囦欢');
+      return;
+    }
+    if (importing.value) return;
+    modalApi.lock();
+    importing.value = true;
+    startMockProgress();
+    try {
+      importResult.value = await importContract(fileList.value[0] as File);
+      // 瀹屾垚锛氳繘搴︽潯璧版弧
+      stopMockProgress();
+      progressPercent.value = 100;
+      await delay(300);
+      emit('success');
+      const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
+      if (failedCount > 0) {
+        message.warning(
+          `瀵煎叆瀹屾垚锛氭柊澧� ${createCount.value} 鏉★紝澶辫触 ${failedCount} 鏉★紝璇锋煡鐪嬩笅鏂瑰け璐ユ槑缁哷,
+        );
+      } else {
+        message.success('瀵煎叆瀹屾垚');
+        shouldClose = true;
+      }
+    } finally {
+      stopMockProgress();
+      importing.value = false;
+      modalApi.unlock();
+      if (shouldClose) {
+        await modalApi.close();
+      }
+    }
+  },
+});
+
+/** 妯℃嫙杩涘害鏉★細璇锋眰鏈熼棿浠� 0 璧板埌 90% */
+function startMockProgress() {
+  progressPercent.value = 0;
+  progressTimer = setInterval(() => {
+    if (progressPercent.value < 90) {
+      progressPercent.value = Math.min(
+        90,
+        progressPercent.value + Math.random() * 12,
+      );
+    }
+  }, 300);
+}
+
+function stopMockProgress() {
+  if (progressTimer) {
+    clearInterval(progressTimer);
+    progressTimer = undefined;
+  }
+}
+
+function delay(ms: number) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function beforeUpload(file: FileType) {
+  fileList.value = [file];
+  importResult.value = undefined;
+  return false;
+}
+
+function handleRemove() {
+  fileList.value = [];
+  importResult.value = undefined;
+}
+
+async function handleDownloadTemplate() {
+  const data = await importContractTemplate();
+  downloadFileFromBlobPart({ fileName: '鍚堝悓瀵煎叆妯℃澘.xls', source: data });
+}
+
+const createCount = computed(() => importResult.value?.createList?.length ?? 0);
+const updateCount = computed(() => importResult.value?.updateList?.length ?? 0);
+const failureCount = computed(
+  () => Object.keys(importResult.value?.failureList ?? {}).length,
+);
+</script>
+
+<template>
+  <Modal title="瀵煎叆鍚堝悓" class="w-[720px]">
+    <div class="mx-4">
+      <Upload
+        :max-count="1"
+        accept=".xls,.xlsx"
+        :file-list="fileList"
+        :before-upload="beforeUpload"
+        @remove="handleRemove"
+      >
+        <Button type="primary">閫夋嫨 Excel 鏂囦欢</Button>
+      </Upload>
+      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
+        <p>浠呭厑璁稿鍏� xls銆亁lsx 鏍煎紡鏂囦欢锛涘繀濉垪锛氬悎鍚屽悕绉般�佸鎴峰悕绉般�佷骇鍝佺紪鐮併�佹暟閲忋�傗�滃鎴峰悕绉扳�濋渶涓� CRM 瀹㈡埛涓�鑷达紱鈥滀骇鍝佺紪鐮佲�濇寜鐗╂枡缂栫爜濉啓锛堚�滀骇鍝佸悕绉扳�濆垪浠呬緵瀵圭収锛夛紱鈥滃悎鍚屽崟浠�(鍏�)鈥濈暀绌哄垯鍙栫墿鏂欓攢鍞环銆�</p>
+        <p>涓�寮犲悎鍚屽彲鍚鏉℃槑缁嗭細鐩稿悓鈥滃悎鍚岀紪鍙封�濈殑澶氳浼氬悎骞朵负涓�寮犲悎鍚岋紙涓昏〃瀛楁鍙栬缁勯琛岋級锛涒�滃悎鍚岀紪鍙封�濈暀绌烘椂姣忎竴琛屽悇鐢熸垚涓�寮犳柊鍚堝悓銆傚悎鍚岀紪鍙峰凡瀛樺湪鏃朵細瀵煎叆澶辫触銆�</p>
+        <p>鍚堝悓閲戦鎸夆�滄槑缁嗗崟浠访楁暟閲� 鈭� 浼樻儬鐜団�濊嚜鍔ㄨ绠楋紱瀹氶噾閲戦鍙~锛涜礋璐d汉 = 褰撳墠鐧诲綍鐢ㄦ埛锛涚姸鎬佷负鈥滄湭鎻愪氦鈥濓紝鍙湪鍒楄〃涓户缁紪杈戞垨鎻愪氦瀹℃壒銆�</p>
+      </div>
+      <!-- 瀵煎叆杩涘害鏉� -->
+      <div v-if="importing" class="mt-4">
+        <div class="mb-2 text-sm text-gray-600">姝e湪瀵煎叆鍚堝悓鏁版嵁锛岃绋嶅��...</div>
+        <Progress :percent="progressPercent" status="active" />
+      </div>
+      <!-- 瀵煎叆缁撴灉 -->
+      <div
+        v-if="importResult"
+        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
+      >
+        <div class="mb-2 font-medium">瀵煎叆缁撴灉</div>
+        <div class="mb-2 flex gap-4">
+          <span class="text-green-600">鏂板锛歿{ createCount }} 鏉�</span>
+          <span class="text-blue-600">瑕嗙洊鏇存柊锛歿{ updateCount }} 鏉�</span>
+          <span class="text-red-600">澶辫触锛歿{ failureCount }} 鏉�</span>
+        </div>
+        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
+          <div
+            v-for="(reason, name) in importResult.failureList"
+            :key="name"
+            class="text-sm leading-6 text-red-500"
+          >
+            {{ name }}锛歿{ reason }}
+          </div>
+        </div>
+      </div>
+    </div>
+    <template #prepend-footer>
+      <Button @click="handleDownloadTemplate">涓嬭浇瀵煎叆妯℃澘</Button>
+    </template>
+  </Modal>
+</template>
diff --git a/src/views/erp/purchase/request/index.vue b/src/views/erp/purchase/request/index.vue
index b78590d..9957565 100644
--- a/src/views/erp/purchase/request/index.vue
+++ b/src/views/erp/purchase/request/index.vue
@@ -23,6 +23,7 @@
 
 import { useGridColumns, useGridFormSchema } from './data';
 import Form from './modules/form.vue';
+import ImportForm from './modules/import-form.vue';
 import OrderForm from './modules/order-form.vue';
 import ProcessSelectModal from './modules/process-select-modal.vue';
 
@@ -33,6 +34,11 @@
 
 const [FormModal, formModalApi] = useVbenModal({
   connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [ImportModal, importModalApi] = useVbenModal({
+  connectedComponent: ImportForm,
   destroyOnClose: true,
 });
 
@@ -55,6 +61,11 @@
 async function handleExport() {
   const data = await exportPurchaseRequest(await gridApi.formApi.getValues());
   downloadFileFromBlobPart({ fileName: '閲囪喘鐢宠.xls', source: data });
+}
+
+/** 瀵煎叆閲囪喘鐢宠 */
+function handleImport() {
+  importModalApi.open();
 }
 
 /** 鏂板閲囪喘鐢宠 */
@@ -180,6 +191,7 @@
 <template>
   <Page auto-content-height>
     <FormModal @success="handleRefresh" />
+    <ImportModal @success="handleRefresh" />
     <OrderFormModal @success="handleRefresh" />
     <ProcessModal @success="handleRefresh" />
     <Grid table-title="閲囪喘鐢宠鍒楄〃">
@@ -201,6 +213,13 @@
               onClick: handleExport,
             },
             {
+              label: '瀵煎叆',
+              type: 'primary',
+              icon: ACTION_ICON.UPLOAD,
+              auth: ['erp:purchase-request:import'],
+              onClick: handleImport,
+            },
+            {
               label: '鎵归噺鍒犻櫎',
               type: 'primary',
               danger: true,
diff --git a/src/views/erp/purchase/request/modules/import-form.vue b/src/views/erp/purchase/request/modules/import-form.vue
new file mode 100644
index 0000000..4d3a9be
--- /dev/null
+++ b/src/views/erp/purchase/request/modules/import-form.vue
@@ -0,0 +1,158 @@
+<script lang="ts" setup>
+import type { FileType } from 'ant-design-vue/es/upload/interface';
+import type { ImportResult } from '#/api/erp/purchase/request';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+import { downloadFileFromBlobPart } from '#/packages/utils/src';
+
+import { Button, message, Progress, Upload } from 'ant-design-vue';
+
+import {
+  importPurchaseRequest,
+  importPurchaseRequestTemplate,
+} from '#/api/erp/purchase/request';
+
+const emit = defineEmits(['success']);
+
+const fileList = ref<FileType[]>([]);
+const importResult = ref<ImportResult>();
+const importing = ref(false);
+const progressPercent = ref(0);
+let progressTimer: ReturnType<typeof setInterval> | undefined;
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    let shouldClose = false;
+    if (fileList.value.length === 0) {
+      message.warning('璇烽�夋嫨瑕佸鍏ョ殑鏂囦欢');
+      return;
+    }
+    if (importing.value) return;
+    modalApi.lock();
+    importing.value = true;
+    startMockProgress();
+    try {
+      importResult.value = await importPurchaseRequest(fileList.value[0] as File);
+      // 瀹屾垚锛氳繘搴︽潯璧版弧
+      stopMockProgress();
+      progressPercent.value = 100;
+      await delay(300);
+      emit('success');
+      const failedCount = Object.keys(importResult.value?.failureList ?? {}).length;
+      if (failedCount > 0) {
+        message.warning(
+          `瀵煎叆瀹屾垚锛氭柊澧� ${createCount.value} 鏉★紝澶辫触 ${failedCount} 鏉★紝璇锋煡鐪嬩笅鏂瑰け璐ユ槑缁哷,
+        );
+      } else {
+        message.success('瀵煎叆瀹屾垚');
+        shouldClose = true;
+      }
+    } finally {
+      stopMockProgress();
+      importing.value = false;
+      modalApi.unlock();
+      if (shouldClose) {
+        await modalApi.close();
+      }
+    }
+  },
+});
+
+/** 妯℃嫙杩涘害鏉★細璇锋眰鏈熼棿浠� 0 璧板埌 90% */
+function startMockProgress() {
+  progressPercent.value = 0;
+  progressTimer = setInterval(() => {
+    if (progressPercent.value < 90) {
+      progressPercent.value = Math.min(
+        90,
+        progressPercent.value + Math.random() * 12,
+      );
+    }
+  }, 300);
+}
+
+function stopMockProgress() {
+  if (progressTimer) {
+    clearInterval(progressTimer);
+    progressTimer = undefined;
+  }
+}
+
+function delay(ms: number) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function beforeUpload(file: FileType) {
+  fileList.value = [file];
+  importResult.value = undefined;
+  return false;
+}
+
+function handleRemove() {
+  fileList.value = [];
+  importResult.value = undefined;
+}
+
+async function handleDownloadTemplate() {
+  const data = await importPurchaseRequestTemplate();
+  downloadFileFromBlobPart({ fileName: '閲囪喘鐢宠瀵煎叆妯℃澘.xls', source: data });
+}
+
+const createCount = computed(() => importResult.value?.createList?.length ?? 0);
+const updateCount = computed(() => importResult.value?.updateList?.length ?? 0);
+const failureCount = computed(
+  () => Object.keys(importResult.value?.failureList ?? {}).length,
+);
+</script>
+
+<template>
+  <Modal title="瀵煎叆閲囪喘鐢宠" class="w-[720px]">
+    <div class="mx-4">
+      <Upload
+        :max-count="1"
+        accept=".xls,.xlsx"
+        :file-list="fileList"
+        :before-upload="beforeUpload"
+        @remove="handleRemove"
+      >
+        <Button type="primary">閫夋嫨 Excel 鏂囦欢</Button>
+      </Upload>
+      <div class="mt-2 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
+        <p>浠呭厑璁稿鍏� xls銆亁lsx 鏍煎紡鏂囦欢锛涘繀濉垪锛氫骇鍝佺紪鐮併�佹暟閲忋�傗�滀骇鍝佺紪鐮佲�濇寜鐗╂枡缂栫爜濉啓锛堚�滀骇鍝佸悕绉扳�濆垪浠呬緵瀵圭収锛夛紝渚涘簲鍟嗗彲绌猴紙濉簡闇�涓庣郴缁熶緵搴斿晢涓�鑷达級锛屸�滈渶姹傛棩鏈熲�濇牸寮忓 2026-01-01锛屸�滄槸鍚︽潵鏂欐楠屸�濆~鈥滄槸/鍚︹�濄��</p>
+        <p>涓�寮犻噰璐敵璇峰彲鍚鏉℃槑缁嗭細鐩稿悓鈥滈噰璐敵璇峰崟鍙封�濈殑澶氳浼氬悎骞朵负涓�寮犵敵璇凤紙涓昏〃瀛楁鍙栬缁勯琛岋級锛涒�滈噰璐敵璇峰崟鍙封�濈暀绌烘椂姣忎竴琛屽悇鐢熸垚涓�寮犳柊鐢宠銆傞噰璐敵璇峰崟鍙峰凡瀛樺湪鏃朵細瀵煎叆澶辫触銆�</p>
+        <p>瀵煎叆鐨勭敵璇凤紝鍏躲�岀敵璇蜂汉 / 鐢宠閮ㄩ棬銆嶆寜褰撳墠鐧诲綍鐢ㄦ埛鑷姩甯﹀嚭锛涚姸鎬佷负鈥滄湭鎻愪氦鈥濓紝鍙湪鍒楄〃涓户缁紪杈戞垨鎻愪氦瀹℃壒銆�</p>
+      </div>
+      <!-- 瀵煎叆杩涘害鏉� -->
+      <div v-if="importing" class="mt-4">
+        <div class="mb-2 text-sm text-gray-600">姝e湪瀵煎叆閲囪喘鐢宠鏁版嵁锛岃绋嶅��...</div>
+        <Progress :percent="progressPercent" status="active" />
+      </div>
+      <!-- 瀵煎叆缁撴灉 -->
+      <div
+        v-if="importResult"
+        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
+      >
+        <div class="mb-2 font-medium">瀵煎叆缁撴灉</div>
+        <div class="mb-2 flex gap-4">
+          <span class="text-green-600">鏂板锛歿{ createCount }} 鏉�</span>
+          <span class="text-blue-600">瑕嗙洊鏇存柊锛歿{ updateCount }} 鏉�</span>
+          <span class="text-red-600">澶辫触锛歿{ failureCount }} 鏉�</span>
+        </div>
+        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
+          <div
+            v-for="(reason, name) in importResult.failureList"
+            :key="name"
+            class="text-sm leading-6 text-red-500"
+          >
+            {{ name }}锛歿{ reason }}
+          </div>
+        </div>
+      </div>
+    </div>
+    <template #prepend-footer>
+      <Button @click="handleDownloadTemplate">涓嬭浇瀵煎叆妯℃澘</Button>
+    </template>
+  </Modal>
+</template>

--
Gitblit v1.9.3