From 6540316952969ed21230c0f92c3bc57e88f58142 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期五, 16 十月 2026 20:15:15 +0800
Subject: [PATCH] feat(mes): 新增二维码后台导出功能

---
 src/views/mes/pro/components/qr-export-task-modal.vue |  103 +++++++++++++++++++++++++
 src/api/mes/pro/qrExport/index.ts                     |   54 +++++++++++++
 src/views/mes/pro/bagCode/index.vue                   |   15 +++
 src/views/mes/pro/batch/index.vue                     |   21 +++++
 src/views/mes/pro/pallet/index.vue                    |   14 +++
 5 files changed, 207 insertions(+), 0 deletions(-)

diff --git a/src/api/mes/pro/qrExport/index.ts b/src/api/mes/pro/qrExport/index.ts
new file mode 100644
index 0000000..d18e67d
--- /dev/null
+++ b/src/api/mes/pro/qrExport/index.ts
@@ -0,0 +1,54 @@
+import { requestClient } from '#/api/request';
+
+export type QrExportBizType =
+  | 'BAG_LABEL'
+  | 'BAG_BUSINESS'
+  | 'PALLET_LABEL'
+  | 'PALLET_BUSINESS'
+  | 'BATCH_LABEL'
+  | 'BATCH_BUSINESS';
+
+export interface QrExportParams {
+  bizType: QrExportBizType;
+  uuid?: string;
+  batchId?: number;
+}
+
+export interface QrExportCount {
+  totalCount: number;
+  syncThreshold: number;
+  mode: 'SYNC' | 'ASYNC';
+}
+
+export interface QrExportTask {
+  id: number;
+  bizType: QrExportBizType;
+  taskName: string;
+  status: number;
+  statusName: string;
+  totalCount: number;
+  finishedCount: number;
+  progress: number;
+  errorMsg?: string;
+  downloadable: boolean;
+}
+
+export function countQrExport(params: QrExportParams) {
+  return requestClient.get<QrExportCount>('/mes/pro/qr-export/count', { params });
+}
+
+export function submitQrExport(params: QrExportParams) {
+  return requestClient.post<QrExportTask>('/mes/pro/qr-export/task', params);
+}
+
+export function getQrExportTask(id: number) {
+  return requestClient.get<QrExportTask>('/mes/pro/qr-export/task/get', { params: { id } });
+}
+
+export function cancelQrExportTask(id: number) {
+  return requestClient.post<boolean>(`/mes/pro/qr-export/task/cancel?id=${id}`);
+}
+
+export function downloadQrExportTask(id: number) {
+  return requestClient.download('/mes/pro/qr-export/task/download', { params: { id } });
+}
diff --git a/src/views/mes/pro/bagCode/index.vue b/src/views/mes/pro/bagCode/index.vue
index cf22f99..9f0df83 100644
--- a/src/views/mes/pro/bagCode/index.vue
+++ b/src/views/mes/pro/bagCode/index.vue
@@ -1,4 +1,5 @@
 <script lang="ts" setup>
+  import { ref } from 'vue';
   import type { VxeTableGridOptions } from "#/adapter/vxe-table";
   import type { MesProBagCodeApi } from "#/api/mes/pro/bagCode";
 
@@ -20,6 +21,7 @@
     unbindBagCodePallet,
   } from "#/api/mes/pro/bagCode";
   import { IconifyIcon } from "@vben/icons";
+  import { countQrExport, submitQrExport } from '#/api/mes/pro/qrExport';
   import { $t } from "#/locales";
 
   import { useGridColumns, useGridFormSchema } from "./data";
@@ -30,8 +32,11 @@
   import ReplaceModal from "./modules/replace-form.vue";
   import QrTraceModal from "../components/qr-trace-modal.vue";
   import BusinessQrModal from "../components/business-qr.vue";
+  import QrExportTaskModal from '../components/qr-export-task-modal.vue';
 
   defineOptions({ name: "MesProBagCode" });
+
+  const qrExportTaskModal = ref<InstanceType<typeof QrExportTaskModal>>();
 
   const [GenerateFormModal, generateFormModalApi] = useVbenModal({
     connectedComponent: GenerateModal,
@@ -129,6 +134,14 @@
     }
     if (!params.uuid && !params.batchId) {
       message.warning("璇峰厛鎸夌敓鎴愮粍鎴栨壒娆$瓫閫夊悗鍐嶅鍑轰簩缁寸爜");
+      return;
+    }
+    const bizType = kind === 'business' ? 'BAG_BUSINESS' : 'BAG_LABEL';
+    const count = await countQrExport({ bizType, ...params });
+    if (count.mode === 'ASYNC') {
+      const task = await submitQrExport({ bizType, ...params });
+      qrExportTaskModal.value?.show(task);
+      message.success(`宸叉彁浜� ${count.totalCount} 寮犱簩缁寸爜鐨勫悗鍙板鍑轰换鍔);
       return;
     }
     const data = await (kind === 'business' ? exportBagBusinessQr : exportBagQr)(params);
@@ -347,6 +360,7 @@
     <ReplaceFormModal @success="handleReplaceSuccess" />
     <QrTraceQueryModal />
     <BusinessQrViewModal />
+    <QrExportTaskModal ref="qrExportTaskModal" />
     <Grid table-title="琚嬬爜鍒楄〃锛堜竴琚嬩竴鐮侊級">
       <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">琚嬬爜鍒楄〃锛堜竴琚嬩竴鐮侊級</span></template>
       <template #toolbar-tools>
@@ -360,6 +374,7 @@
           { label: 'Excel 鏁版嵁鍖�', auth: ['mes:pro-bag-code:export'], onClick: handleExport }]">
             <template #more><Button>瀵煎嚭 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
           </TableAction>
+          <Button @click="qrExportTaskModal?.showLast()">鍚庡彴瀵煎嚭浠诲姟</Button>
           <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '鐢熸垚鍘熷琚嬬爜', auth: ['mes:pro-bag-code:create'], onClick: handleRawGenerate },
           { label: '琛ョ敓鎴愪笟鍔$爜', auth: ['mes:pro-bag-code:create'], onClick: handleGenerateBusinessCodes },
           { label: '鐮存崯鏇挎崲', auth: ['mes:pro-bag-code:replace'], onClick: handleReplace },
diff --git a/src/views/mes/pro/batch/index.vue b/src/views/mes/pro/batch/index.vue
index 49e137d..7200a27 100644
--- a/src/views/mes/pro/batch/index.vue
+++ b/src/views/mes/pro/batch/index.vue
@@ -1,4 +1,5 @@
 <script lang="ts" setup>
+  import { ref } from 'vue';
   import type { VxeTableGridOptions } from "#/adapter/vxe-table";
   import type { MesProBatchApi } from "#/api/mes/pro/batch";
 
@@ -19,6 +20,7 @@
     updateBatchStatus,
   } from "#/api/mes/pro/batch";
   import { IconifyIcon } from "@vben/icons";
+  import { countQrExport, submitQrExport } from '#/api/mes/pro/qrExport';
   import { $t } from "#/locales";
 
   import { useGridColumns, useGridFormSchema } from "./data";
@@ -26,6 +28,9 @@
   import PreGenerateModal from "./modules/pre-generate.vue";
   import QrTraceModal from "../components/qr-trace-modal.vue";
   import BusinessQrModal from "../components/business-qr.vue";
+  import QrExportTaskModal from '../components/qr-export-task-modal.vue';
+
+  const qrExportTaskModal = ref<InstanceType<typeof QrExportTaskModal>>();
 
   const [FormModal, formModalApi] = useVbenModal({
     connectedComponent: Form,
@@ -54,6 +59,13 @@
   }
   async function handleExportBusiness(row: MesProBatchApi.Batch) {
     if (row.id == null) return;
+    const params = { bizType: 'BATCH_BUSINESS' as const, batchId: row.id };
+    const count = await countQrExport(params);
+    if (count.mode === 'ASYNC') {
+      qrExportTaskModal.value?.show(await submitQrExport(params));
+      message.success(`宸叉彁浜� ${count.totalCount} 寮犱簩缁寸爜鐨勫悗鍙板鍑轰换鍔);
+      return;
+    }
     const data = await exportBatchBusinessQr(row.id);
     downloadFileFromBlobPart({ fileName: '鎵规涓庢墭鐩樹笟鍔′簩缁寸爜.zip', source: data });
   }
@@ -79,6 +91,13 @@
 
   /** 鎵撳嵃鏁村浜岀淮鐮侊紙鎵规鍥� + 鍏ㄩ儴鎵樼洏鍥� ZIP锛� */
   async function handlePrintQrSet(row: MesProBatchApi.Batch) {
+    const params = { bizType: 'BATCH_LABEL' as const, batchId: row.id! };
+    const count = await countQrExport(params);
+    if (count.mode === 'ASYNC') {
+      qrExportTaskModal.value?.show(await submitQrExport(params));
+      message.success(`宸叉彁浜� ${count.totalCount} 寮犱簩缁寸爜鐨勫悗鍙板鍑轰换鍔);
+      return;
+    }
     const data = await exportBatchQr(row.id!);
     downloadFileFromBlobPart({
       fileName: `鎵规浜岀淮鐮�-${getDisplayCode(row)}.zip`,
@@ -182,6 +201,7 @@
     <PreGenerateModalApi @success="handlePreGenerateSuccess" />
     <QrTraceQueryModal />
     <BusinessQrViewModal />
+    <QrExportTaskModal ref="qrExportTaskModal" />
     <Grid table-title="鐢熶骇鎵规鍒楄〃">
       <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">鐢熶骇鎵规鍒楄〃</span></template>
       <template #toolbar-tools>
@@ -192,6 +212,7 @@
           <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: 'Excel 鏁版嵁', auth: ['mes:pro-batch:export'], onClick: handleExport }]">
             <template #more><Button>瀵煎嚭 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
           </TableAction>
+          <Button @click="qrExportTaskModal?.showLast()">鍚庡彴瀵煎嚭浠诲姟</Button>
         </div>
       </template>
       <template #code="{ row }">
diff --git a/src/views/mes/pro/components/qr-export-task-modal.vue b/src/views/mes/pro/components/qr-export-task-modal.vue
new file mode 100644
index 0000000..9330a7d
--- /dev/null
+++ b/src/views/mes/pro/components/qr-export-task-modal.vue
@@ -0,0 +1,103 @@
+<script lang="ts" setup>
+  import type { QrExportTask } from '#/api/mes/pro/qrExport';
+
+  import { onBeforeUnmount, onMounted, ref } from 'vue';
+  import { downloadFileFromBlobPart } from '@vben/utils';
+  import { Button, message, Modal, Progress } from 'ant-design-vue';
+
+  import {
+    cancelQrExportTask,
+    downloadQrExportTask,
+    getQrExportTask,
+  } from '#/api/mes/pro/qrExport';
+
+  const storageKey = 'mes-pro-last-qr-export-task-id';
+  const visible = ref(false);
+  const task = ref<QrExportTask>();
+  const downloading = ref(false);
+  let timer: ReturnType<typeof setTimeout> | undefined;
+
+  function stopPolling() {
+    if (timer) clearTimeout(timer);
+    timer = undefined;
+  }
+
+  async function refresh() {
+    if (!task.value) return;
+    stopPolling();
+    try {
+      task.value = await getQrExportTask(task.value.id);
+      if (task.value.status === 10 || task.value.status === 20) {
+        timer = setTimeout(refresh, 2000);
+      }
+    } catch {
+      message.error('鑾峰彇瀵煎嚭杩涘害澶辫触锛岃鐐瑰嚮鍒锋柊閲嶈瘯');
+    }
+  }
+
+  function show(value: QrExportTask) {
+    task.value = value;
+    localStorage.setItem(storageKey, String(value.id));
+    visible.value = true;
+    void refresh();
+  }
+
+  function showLast() {
+    if (task.value) visible.value = true;
+    else message.info('鏆傛棤鍚庡彴瀵煎嚭浠诲姟');
+  }
+
+  async function cancel() {
+    if (!task.value) return;
+    await cancelQrExportTask(task.value.id);
+    message.success('宸茬敵璇峰彇娑堝鍑轰换鍔�');
+    await refresh();
+  }
+
+  async function download() {
+    if (!task.value?.downloadable || downloading.value) return;
+    downloading.value = true;
+    try {
+      const data = await downloadQrExportTask(task.value.id);
+      downloadFileFromBlobPart({ fileName: `${task.value.taskName}.zip`, source: data });
+      localStorage.removeItem(storageKey);
+      await refresh();
+    } finally {
+      downloading.value = false;
+    }
+  }
+
+  onMounted(async () => {
+    const id = Number(localStorage.getItem(storageKey));
+    if (!Number.isSafeInteger(id) || id <= 0) return;
+    try {
+      const previous = await getQrExportTask(id);
+      task.value = previous;
+      if (previous.status === 10 || previous.status === 20 || previous.downloadable) {
+        visible.value = true;
+      }
+      if (previous.status === 10 || previous.status === 20) timer = setTimeout(refresh, 2000);
+    } catch {
+      localStorage.removeItem(storageKey);
+    }
+  });
+  onBeforeUnmount(stopPolling);
+
+  defineExpose({ show, showLast });
+</script>
+
+<template>
+  <Modal v-model:open="visible" title="浜岀淮鐮佸悗鍙板鍑�" :footer="null" :mask-closable="false">
+    <template v-if="task">
+      <p>浠诲姟 #{{ task.id }} 路 {{ task.taskName }}</p>
+      <p>宸茬敓鎴� {{ task.finishedCount }} / {{ task.totalCount }} 寮狅紝{{ task.statusName }}</p>
+      <Progress :percent="task.progress" :status="task.status === 50 ? 'exception' : task.downloadable ? 'success' : 'active'" />
+      <p v-if="task.errorMsg" class="text-red-600">{{ task.errorMsg }}</p>
+      <div class="mt-4 flex justify-end gap-2">
+        <Button @click="refresh">鍒锋柊</Button>
+        <Button v-if="task.status === 10 || task.status === 20" danger @click="cancel">鍙栨秷浠诲姟</Button>
+        <Button v-if="task.downloadable" type="primary" :loading="downloading" @click="download">涓嬭浇 ZIP</Button>
+      </div>
+    </template>
+  </Modal>
+</template>
diff --git a/src/views/mes/pro/pallet/index.vue b/src/views/mes/pro/pallet/index.vue
index 82adce6..3d375d2 100644
--- a/src/views/mes/pro/pallet/index.vue
+++ b/src/views/mes/pro/pallet/index.vue
@@ -1,4 +1,5 @@
 <script lang="ts" setup>
+  import { ref } from 'vue';
   import type { VxeTableGridOptions } from "#/adapter/vxe-table";
   import type { MesProPalletApi } from "#/api/mes/pro/pallet";
 
@@ -18,6 +19,7 @@
     getPalletPage,
   } from "#/api/mes/pro/pallet";
   import { IconifyIcon } from "@vben/icons";
+  import { countQrExport, submitQrExport } from '#/api/mes/pro/qrExport';
   import { $t } from "#/locales";
 
   import { useGridColumns, useGridFormSchema } from "./data";
@@ -25,6 +27,9 @@
   import GenerateModal from "./modules/generate-form.vue";
   import QrTraceModal from "../components/qr-trace-modal.vue";
   import BusinessQrModal from "../components/business-qr.vue";
+  import QrExportTaskModal from '../components/qr-export-task-modal.vue';
+
+  const qrExportTaskModal = ref<InstanceType<typeof QrExportTaskModal>>();
 
   defineOptions({ name: "MesProPallet" });
 
@@ -137,6 +142,13 @@
       message.warning("璇峰厛鎸夋壒娆$瓫閫夊悗鍐嶅鍑轰簩缁寸爜");
       return;
     }
+    const params = { bizType: 'PALLET_LABEL' as const, batchId: values.batchId };
+    const count = await countQrExport(params);
+    if (count.mode === 'ASYNC') {
+      qrExportTaskModal.value?.show(await submitQrExport(params));
+      message.success(`宸叉彁浜� ${count.totalCount} 寮犱簩缁寸爜鐨勫悗鍙板鍑轰换鍔);
+      return;
+    }
     const data = await exportPalletQr(values.batchId);
     downloadFileFromBlobPart({ fileName: "鎵樼洏鐮佷簩缁寸爜.zip", source: data });
   }
@@ -204,6 +216,7 @@
     <BindBatchFormModal @success="handleRefresh" />
     <QrTraceQueryModal />
     <BusinessQrViewModal />
+    <QrExportTaskModal ref="qrExportTaskModal" />
     <Grid table-title="鎵樼洏鐮佸垪琛�">
       <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">鎵樼洏鐮佸垪琛�</span></template>
       <template #toolbar-tools>
@@ -216,6 +229,7 @@
           { label: 'Excel 鏁版嵁', auth: ['mes:pro-pallet:export'], onClick: handleExport }]">
             <template #more><Button>瀵煎嚭 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
           </TableAction>
+          <Button @click="qrExportTaskModal?.showLast()">鍚庡彴瀵煎嚭浠诲姟</Button>
           <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '琛ョ敓鎴愪笟鍔$爜锛堥�変腑璁板綍锛�', auth: ['mes:pro-pallet:create'], onClick: handleGenerateBusiness }]">
             <template #more><Button>鏇村鎿嶄綔 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
           </TableAction>

--
Gitblit v1.9.3