From f2e80b9f8dddd8da58b9840cf69139d028aaf0d4 Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期五, 16 十月 2026 17:45:36 +0800
Subject: [PATCH] feat(mes): 二维码导出接入后台任务与可隐藏进度浮层

---
 src/api/mes/pro/qrExport/index.ts                 |   85 +++++++
 src/views/mes/pro/bagCode/index.vue               |   35 ++
 src/views/mes/pro/qrexport/qr-export-progress.vue |  189 +++++++++++++++
 src/views/mes/pro/qrexport/use-qr-export.ts       |  387 ++++++++++++++++++++++++++++++++
 4 files changed, 691 insertions(+), 5 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..b7e83d2
--- /dev/null
+++ b/src/api/mes/pro/qrExport/index.ts
@@ -0,0 +1,85 @@
+import { requestClient } from '#/api/request';
+
+export namespace MesProQrExportApi {
+  /** 涓氬姟绫诲瀷 */
+  export type BizType =
+    | 'BAG_BUSINESS'
+    | 'BAG_LABEL'
+    | 'BATCH_BUSINESS'
+    | 'BATCH_LABEL'
+    | 'PALLET_BUSINESS'
+    | 'PALLET_LABEL';
+
+  /** 瀵煎嚭鏂瑰紡锛歋YNC 鍚屾鐩存帴涓嬭浇锛堢鍑猴級/ ASYNC 鍚庡彴浠诲姟 */
+  export type ExportMode = 'ASYNC' | 'SYNC';
+
+  /** 瀵煎嚭浠诲姟鐘舵�侊細10 鎺掗槦涓� 20 鐢熸垚涓� 30 寰呬笅杞� 40 宸插彇娑� 50 宸插け璐� 60 宸蹭笅杞� */
+  export type TaskStatus = 10 | 20 | 30 | 40 | 50 | 60;
+
+  /** 瀵煎嚭鍙傛暟 */
+  export interface ExportParams {
+    bizType: BizType;
+    batchId?: number;
+    uuid?: string;
+  }
+
+  /** 瀵煎嚭鏁伴噺涓庢柟寮� */
+  export interface ExportCount {
+    mode: ExportMode;
+    syncThreshold: number;
+    totalCount: number;
+  }
+
+  /** 瀵煎嚭浠诲姟 */
+  export interface ExportTask {
+    id: number;
+    bizType: string;
+    taskName: string;
+    status: TaskStatus;
+    statusName: string;
+    totalCount: number;
+    finishedCount: number;
+    progress: number;
+    fileSize?: number;
+    errorMsg?: string;
+    downloadable: boolean;
+    createTime?: string;
+    finishTime?: string;
+  }
+}
+
+/** 缁熻瀵煎嚭寮犳暟骞剁粰鍑哄鍑烘柟寮� */
+export function getQrExportCount(params: MesProQrExportApi.ExportParams) {
+  return requestClient.get<MesProQrExportApi.ExportCount>(
+    '/mes/pro/qr-export/count',
+    { params },
+  );
+}
+
+/** 鎻愪氦鍚庡彴瀵煎嚭浠诲姟 */
+export function submitQrExportTask(data: MesProQrExportApi.ExportParams) {
+  return requestClient.post<MesProQrExportApi.ExportTask>(
+    '/mes/pro/qr-export/task',
+    data,
+  );
+}
+
+/** 鏌ヨ瀵煎嚭浠诲姟杩涘害 */
+export function getQrExportTask(id: number) {
+  return requestClient.get<MesProQrExportApi.ExportTask>(
+    '/mes/pro/qr-export/task/get',
+    { params: { id } },
+  );
+}
+
+/** 鍙栨秷瀵煎嚭浠诲姟 */
+export function cancelQrExportTask(id: number) {
+  return requestClient.post<boolean>('/mes/pro/qr-export/task/cancel', null, {
+    params: { id },
+  });
+}
+
+/** 瀵煎嚭浠诲姟浜х墿涓嬭浇鍦板潃锛堜笉甯﹂壌鏉冨ご锛屼緵娴佸紡涓嬭浇鑷闄勫姞 token锛� */
+export function getQrExportDownloadPath(id: number) {
+  return `/mes/pro/qr-export/task/download?id=${id}`;
+}
diff --git a/src/views/mes/pro/bagCode/index.vue b/src/views/mes/pro/bagCode/index.vue
index cf22f99..6bd6b3a 100644
--- a/src/views/mes/pro/bagCode/index.vue
+++ b/src/views/mes/pro/bagCode/index.vue
@@ -30,6 +30,8 @@
   import ReplaceModal from "./modules/replace-form.vue";
   import QrTraceModal from "../components/qr-trace-modal.vue";
   import BusinessQrModal from "../components/business-qr.vue";
+  import QrExportProgress from "../qrexport/qr-export-progress.vue";
+  import { useQrExport } from "../qrexport/use-qr-export";
 
   defineOptions({ name: "MesProBagCode" });
 
@@ -91,6 +93,8 @@
     gridApi.query();
   }
 
+  const { start: startQrExport } = useQrExport();
+
   /** 鐢熸垚鏁版嵁鍖� */
   function handleGenerate() {
     generateFormModalApi.open();
@@ -131,8 +135,24 @@
       message.warning("璇峰厛鎸夌敓鎴愮粍鎴栨壒娆$瓫閫夊悗鍐嶅鍑轰簩缁寸爜");
       return;
     }
-    const data = await (kind === 'business' ? exportBagBusinessQr : exportBagQr)(params);
-    downloadFileFromBlobPart({ fileName: kind === 'business' ? '琚嬬爜涓氬姟浜岀淮鐮�.zip' : '琚嬬爜杩芥函浜岀淮鐮�.zip', source: data });
+    // 鏁伴噺灏戝垯鍚屾绉掑嚭锛涜秴杩囧垎鐣岀嚎鑷姩杞悗鍙颁换鍔★紝鐢辫繘搴︽诞灞傚睍绀虹湡瀹炶繘搴﹀苟鏀寔鍙栨秷/闅愯棌
+    const bizType = kind === "business" ? "BAG_BUSINESS" : "BAG_LABEL";
+    const fileName = kind === "business" ? "琚嬬爜涓氬姟浜岀淮鐮�" : "琚嬬爜杩芥函浜岀淮鐮�";
+    await startQrExport(
+      { bizType, ...params },
+      {
+        fileName,
+        syncDownload: async () => {
+          const data = await (kind === "business"
+            ? exportBagBusinessQr
+            : exportBagQr)(params);
+          downloadFileFromBlobPart({
+            fileName: `${fileName}.zip`,
+            source: data,
+          });
+        },
+      },
+    );
   }
 
   /** 瀵煎嚭琚嬬爜 Excel锛堜氦浠樺嵃鍒凤級锛屽鍑哄悗琚嬬爜鐘舵�佸彉涓哄凡鍗板埛锛岄渶鍒锋柊鍒楄〃 */
@@ -238,11 +258,14 @@
   function handleBindPallet() {
     const records = getSelectedRecords();
     if (!records) return;
-    if (records.some((record) => !record.batchId)) {
-      message.warning("璇峰厛褰曞叆鐢熶骇鎵规锛屽啀缁戝畾鎵樼洏");
+    // 宸插綍鍏ユ壒娆$殑琚嬬爜鍙兘缁戝埌鏈壒娆$殑鎵樼洏锛涙湭褰曞叆鎵规鐨勮鐮佸彲缁戜换鎰忔墭鐩橈紙閫夊埌姝e紡鎵樼洏鏃跺悗绔細鍏堣ˉ褰曟壒娆★級銆�
+    // 涓よ�呮贩閫夋椂鎵规褰掑睘涓嶆槑纭紝瑕佹眰鍒嗗紑鎿嶄綔銆�
+    const boundRecords = records.filter((record) => record.batchId);
+    if (boundRecords.length > 0 && boundRecords.length < records.length) {
+      message.warning("鎵�閫夎鐮佷腑鏃㈡湁宸插綍鍏ユ壒娆°�佷篃鏈夋湭褰曞叆鎵规鐨勮褰曪紝璇峰垎寮�缁戝畾");
       return;
     }
-    const batchIds = new Set(records.map((record) => record.batchId));
+    const batchIds = new Set(boundRecords.map((record) => record.batchId));
     if (batchIds.size > 1) {
       message.warning("璇烽�夋嫨鍚屼竴鎵规涓嬬殑琚嬬爜杩涜缁戝畾");
       return;
@@ -264,6 +287,7 @@
       .setData({
         batchId: first.batchId,
         batchCode: first.batchCode,
+        itemId: first.itemId,
         ids: records.map((record) => record.id!),
       })
       .open();
@@ -347,6 +371,7 @@
     <ReplaceFormModal @success="handleReplaceSuccess" />
     <QrTraceQueryModal />
     <BusinessQrViewModal />
+    <QrExportProgress />
     <Grid table-title="琚嬬爜鍒楄〃锛堜竴琚嬩竴鐮侊級">
       <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">琚嬬爜鍒楄〃锛堜竴琚嬩竴鐮侊級</span></template>
       <template #toolbar-tools>
diff --git a/src/views/mes/pro/qrexport/qr-export-progress.vue b/src/views/mes/pro/qrexport/qr-export-progress.vue
new file mode 100644
index 0000000..3d9281d
--- /dev/null
+++ b/src/views/mes/pro/qrexport/qr-export-progress.vue
@@ -0,0 +1,189 @@
+<script lang="ts" setup>
+  import { computed } from 'vue';
+
+  import { Button, Progress } from 'ant-design-vue';
+
+  import { useQrExport } from './use-qr-export';
+
+  defineOptions({ name: 'QrExportProgress' });
+
+  const { state, cancel, minimize, expand, close } = useQrExport();
+
+  /** 闈㈡澘鏄惁灞曠ず锛坕dle 琛ㄧず娌℃湁浠讳綍浠诲姟锛� */
+  const visible = computed(() => state.phase !== 'idle');
+
+  /** 杩涜涓殑闃舵鎵嶅厑璁稿彇娑� */
+  const cancelable = computed(() =>
+    ['counting', 'downloading', 'generating'].includes(state.phase),
+  );
+
+  const percent = computed(() => {
+    switch (state.phase) {
+      case 'counting': {
+        return 0;
+      }
+      case 'done': {
+        return 100;
+      }
+      case 'downloading': {
+        if (state.downloadTotal > 0) {
+          return Math.min(
+            100,
+            Math.round((state.downloadLoaded / state.downloadTotal) * 100),
+          );
+        }
+        return 0;
+      }
+      case 'generating': {
+        return state.progress;
+      }
+      default: {
+        return state.progress;
+      }
+    }
+  });
+
+  const progressStatus = computed(() => {
+    if (state.phase === 'failed') return 'exception';
+    if (state.phase === 'done') return 'success';
+    return 'active';
+  });
+
+  /** 鐢熸垚闃舵涓庝笅杞介樁娈甸兘涓嶅彲棰勭煡鏃堕暱锛岀敤涓嶇‘瀹氭�佽繘搴︽潯 */
+  const indeterminate = computed(
+    () => state.phase === 'counting' || state.phase === 'sync',
+  );
+
+  const title = computed(() => {
+    switch (state.phase) {
+      case 'canceled': {
+        return '瀵煎嚭宸插彇娑�';
+      }
+      case 'counting': {
+        return '姝e湪缁熻鏁伴噺';
+      }
+      case 'done': {
+        return '瀵煎嚭瀹屾垚';
+      }
+      case 'downloading': {
+        return '姝e湪涓嬭浇';
+      }
+      case 'failed': {
+        return '瀵煎嚭澶辫触';
+      }
+      case 'generating': {
+        return '姝e湪鐢熸垚浜岀淮鐮�';
+      }
+      case 'sync': {
+        return '姝e湪瀵煎嚭';
+      }
+      default: {
+        return '浜岀淮鐮佸鍑�';
+      }
+    }
+  });
+
+  const detail = computed(() => {
+    if (state.phase === 'failed') {
+      return state.errorMsg || '瀵煎嚭澶辫触';
+    }
+    if (state.phase === 'downloading') {
+      if (state.downloadTotal > 0) {
+        return `${formatSize(state.downloadLoaded)} / ${formatSize(state.downloadTotal)}`;
+      }
+      return `宸蹭笅杞� ${formatSize(state.downloadLoaded)}`;
+    }
+    return state.message;
+  });
+
+  /** 鏀惰捣鍚庢诞鏍囦笂浠嶈鑳界湅鍒拌繘搴︼紝閬垮厤鐢ㄦ埛浠ヤ负浠诲姟宸茬粡鍋滀簡 */
+  const pillText = computed(() =>
+    state.phase === 'generating' ? `${percent.value}%` : title.value,
+  );
+
+  function formatSize(bytes: number) {
+    if (!bytes) return '0 B';
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
+    if (bytes < 1024 * 1024 * 1024) {
+      return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+    }
+    return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
+  }
+</script>
+
+<template>
+  <!-- 鏀惰捣鎬侊細灏忔诞鏍囷紝鐐瑰嚮鍙噸鏂板睍寮� -->
+  <div
+    v-if="visible && state.minimized"
+    class="fixed right-6 bottom-6 z-[2000] flex cursor-pointer items-center gap-2 rounded-full bg-white px-4 py-2 shadow-lg ring-1 ring-gray-200 hover:shadow-xl dark:bg-gray-800 dark:ring-gray-700"
+    @click="expand"
+  >
+    <span
+      class="inline-block h-2 w-2 rounded-full"
+      :class="{
+        'bg-blue-500': state.phase === 'generating' || state.phase === 'downloading',
+        'bg-green-500': state.phase === 'done',
+        'bg-red-500': state.phase === 'failed',
+        'bg-gray-400': state.phase === 'canceled',
+      }"
+    ></span>
+    <span class="text-sm text-gray-700 dark:text-gray-200">
+      浜岀淮鐮佸鍑� {{ pillText }}
+    </span>
+  </div>
+
+  <!-- 灞曞紑鎬侊細杩涘害闈㈡澘 -->
+  <div
+    v-else-if="visible"
+    class="fixed right-6 bottom-6 z-[2000] w-80 rounded-lg bg-white p-4 shadow-lg ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
+  >
+    <div class="mb-2 flex items-center justify-between">
+      <span class="text-sm font-medium text-gray-800 dark:text-gray-100">
+        {{ title }}
+      </span>
+      <div class="flex items-center gap-1">
+        <!-- 杩涜涓彁渚涖�岄殣钘忋�嶏紝浠诲姟杞叆鍚庡彴缁х画锛屼笉浼氳涓柇 -->
+        <Button
+          v-if="state.phase !== 'done' && state.phase !== 'failed' && state.phase !== 'canceled'"
+          type="link"
+          size="small"
+          @click="minimize"
+        >
+          闅愯棌
+        </Button>
+        <Button
+          v-if="cancelable"
+          type="link"
+          size="small"
+          danger
+          @click="cancel"
+        >
+          鍙栨秷
+        </Button>
+        <Button
+          v-if="!cancelable"
+          type="link"
+          size="small"
+          @click="close"
+        >
+          鍏抽棴
+        </Button>
+      </div>
+    </div>
+
+    <Progress
+      :percent="percent"
+      :status="progressStatus"
+      :show-info="!indeterminate"
+      size="small"
+    />
+
+    <div class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
+      {{ state.taskName }}
+    </div>
+    <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+      {{ detail }}
+    </div>
+  </div>
+</template>
diff --git a/src/views/mes/pro/qrexport/use-qr-export.ts b/src/views/mes/pro/qrexport/use-qr-export.ts
new file mode 100644
index 0000000..fe78856
--- /dev/null
+++ b/src/views/mes/pro/qrexport/use-qr-export.ts
@@ -0,0 +1,387 @@
+import type { MesProQrExportApi } from '#/api/mes/pro/qrExport';
+
+import { reactive } from 'vue';
+
+import { useAppConfig } from '@vben/hooks';
+import { useAccessStore } from '@vben/stores';
+import { downloadFileFromBlobPart } from '@vben/utils';
+
+import { message } from 'ant-design-vue';
+
+import {
+  cancelQrExportTask,
+  getQrExportCount,
+  getQrExportDownloadPath,
+  getQrExportTask,
+  submitQrExportTask,
+} from '#/api/mes/pro/qrExport';
+
+/**
+ * 瀵煎嚭闃舵
+ * <p>
+ * counting 缁熻寮犳暟 / sync 灏忔壒閲忓悓姝ヤ笅杞戒腑 / generating 鍚庡彴鐢熸垚涓� /
+ * downloading 涓嬭浇浜х墿涓� / done 瀹屾垚 / canceled 宸插彇娑� / failed 澶辫触
+ */
+export type QrExportPhase =
+  | 'canceled'
+  | 'counting'
+  | 'done'
+  | 'downloading'
+  | 'failed'
+  | 'generating'
+  | 'idle'
+  | 'sync';
+
+export interface QrExportState {
+  bizType?: MesProQrExportApi.BizType;
+  /** 宸蹭笅杞藉瓧鑺� */
+  downloadLoaded: number;
+  /** 浜х墿鎬诲瓧鑺傦紙鍙栬嚜 Content-Length锛屽彇涓嶅埌鏃朵负 0 琛ㄧず鏈煡锛� */
+  downloadTotal: number;
+  errorMsg: string;
+  /** 宸茬敓鎴愬紶鏁� */
+  finished: number;
+  message: string;
+  /** 鏀惰捣涓烘诞鏍囷紙浠诲姟缁х画鍦ㄥ悗鍙拌窇锛� */
+  minimized: boolean;
+  phase: QrExportPhase;
+  /** 杩涘害鐧惧垎姣旓紙0~100锛� */
+  progress: number;
+  taskId?: number;
+  taskName: string;
+  /** 鏍囩鎬诲紶鏁� */
+  total: number;
+}
+
+const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
+
+/**
+ * 瀵煎嚭鐘舵�佹彁鍒版ā鍧楃骇鍗曚緥锛氳繘搴﹂潰鏉胯鏀惰捣銆佹垨椤甸潰缁勪欢琚攢姣侊紙鐢ㄦ埛鍒囪蛋鍐嶅垏鍥烇級鏃讹紝
+ * 杞涓庝笅杞介兘蹇呴』缁х画锛屼笉鑳藉洜涓虹粍浠跺嵏杞借�屼腑鏂��
+ */
+const state = reactive<QrExportState>(createInitialState());
+
+let pollTimer: null | ReturnType<typeof setInterval> = null;
+let downloadAbort: null | (() => void) = null;
+
+/**
+ * 鐩爣鏂囦欢鍙ユ焺
+ * <p>
+ * 澶ф壒閲忓鍑虹殑浜х墿鍙揪鏁扮櫨 MB锛岃嫢鏁村寘璇昏繘鍐呭瓨鍐嶈惤鐩橈紝鏍囩椤垫瀬鏄撹鎾戠垎銆傚洜姝や紭鍏堝湪鐐瑰嚮瀵煎嚭
+ * 锛堢敤鎴锋墜鍔夸粛鍦ㄦ湁鏁堟湡鍐咃級鏃跺彇鍒颁繚瀛樺彞鏌勶紝浜х墿杈逛笅杞借竟鍐欑洏锛屽唴瀛樺彧鍗犱竴涓垎鐗囥��
+ * 娴忚鍣ㄤ笉鏀寔鎴栫敤鎴锋斁寮冮�夋嫨鏃堕��鍥炲唴瀛樹笅杞姐��
+ */
+let saveHandle: any = null;
+
+/** 灏濊瘯鍙栧緱淇濆瓨鍙ユ焺锛涗笉鏀寔銆佽鍙栨秷鏃惰繑鍥� null锛堜笉瑙嗕负閿欒锛� */
+async function tryPickSaveFile(suggestedName: string): Promise<any> {
+  const picker = (window as any).showSaveFilePicker;
+  if (typeof picker !== 'function') {
+    return null;
+  }
+  try {
+    return await picker({
+      suggestedName,
+      types: [
+        { description: 'ZIP 鍘嬬缉鍖�', accept: { 'application/zip': ['.zip'] } },
+      ],
+    });
+  } catch {
+    return null;
+  }
+}
+
+function createInitialState(): QrExportState {
+  return {
+    downloadLoaded: 0,
+    downloadTotal: 0,
+    errorMsg: '',
+    finished: 0,
+    message: '',
+    minimized: false,
+    phase: 'idle',
+    progress: 0,
+    taskName: '',
+    total: 0,
+  };
+}
+
+function resetState() {
+  Object.assign(state, createInitialState());
+}
+
+function stopPolling() {
+  if (pollTimer !== null) {
+    clearInterval(pollTimer);
+    pollTimer = null;
+  }
+}
+
+/** 鏄惁澶勪簬杩涜涓紙闈㈡澘闇�瑕佸睍绀猴級 */
+function isActive() {
+  return [
+    'counting',
+    'generating',
+    'downloading',
+    'sync',
+  ].includes(state.phase);
+}
+
+/** 鐢卞閮ㄩ〉闈㈡敞鍏ョ殑銆屽皬鎵归噺鍚屾涓嬭浇銆嶅疄鐜帮紝閬垮厤鏈ā鍧椾緷璧栧叿浣撲笟鍔¢〉 */
+export interface StartOptions {
+  fileName?: string;
+  /** 灏忔壒閲忥紙涓嶈秴杩囧垎鐣岀嚎锛夋椂濡備綍鍚屾涓嬭浇锛岀敱璋冪敤椤垫彁渚� */
+  syncDownload?: () => Promise<void>;
+}
+
+/**
+ * 鍙戣捣瀵煎嚭
+ * <p>
+ * 鍏堥棶鍚庣瑕佸紶鏁帮細涓嶈秴杩囧垎鐣岀嚎鐩存帴鍚屾涓嬭浇锛堢鍑恒�佷笉鎺掗槦锛夛紱瓒呰繃鍒欒浆鍚庡彴浠诲姟骞跺睍绀鸿繘搴︺��
+ */
+async function start(
+  params: MesProQrExportApi.ExportParams,
+  options: StartOptions = {},
+) {
+  if (isActive()) {
+    message.warning('宸叉湁瀵煎嚭浠诲姟姝e湪杩涜锛岃绛夊緟瀹屾垚鎴栧彇娑堝悗鍐嶈瘯');
+    return;
+  }
+  resetState();
+  state.bizType = params.bizType;
+  state.taskName = options.fileName ?? '浜岀淮鐮�';
+  state.phase = 'counting';
+  state.message = '姝e湪缁熻寰呭鍑烘暟閲忊��';
+
+  try {
+    const count = await getQrExportCount(params);
+    state.total = count.totalCount;
+    if (count.totalCount === 0) {
+      state.phase = 'failed';
+      state.errorMsg = '娌℃湁鍙鍑虹殑浜岀淮鐮�';
+      state.message = '娌℃湁鍙鍑虹殑浜岀淮鐮�';
+      return;
+    }
+    if (count.mode === 'SYNC') {
+      await startSync(params, count, options);
+      return;
+    }
+    // 澶ф壒閲忥細瓒佺敤鎴锋墜鍔夸粛鏈夋晥鍏堝彇淇濆瓨鍙ユ焺锛屼骇鐗╁彲鐩存帴娴佸紡钀界洏
+    saveHandle = await tryPickSaveFile(`${state.taskName}.zip`);
+    await startAsync(params, count);
+  } catch (error: any) {
+    failWith(error);
+  }
+}
+
+/** 灏忔壒閲忥細娌跨敤鍘熸湁鍚屾涓嬭浇鎺ュ彛 */
+async function startSync(
+  _params: MesProQrExportApi.ExportParams,
+  count: MesProQrExportApi.ExportCount,
+  options: StartOptions,
+) {
+  state.phase = 'sync';
+  state.message = `鍏� ${count.totalCount} 寮狅紝姝e湪鐢熸垚鈥;
+  if (!options.syncDownload) {
+    state.phase = 'failed';
+    state.errorMsg = '缂哄皯鍚屾涓嬭浇瀹炵幇';
+    return;
+  }
+  await options.syncDownload();
+  state.phase = 'done';
+  state.progress = 100;
+  state.finished = count.totalCount;
+  state.message = `宸插鍑� ${count.totalCount} 寮燻;
+}
+
+/** 澶ф壒閲忥細鎻愪氦鍚庡彴浠诲姟骞惰疆璇㈣繘搴� */
+async function startAsync(
+  params: MesProQrExportApi.ExportParams,
+  count: MesProQrExportApi.ExportCount,
+) {
+  state.phase = 'generating';
+  state.message = `鍏� ${count.totalCount} 寮狅紝姝e湪鍚庡彴鐢熸垚鈥;
+  const task = await submitQrExportTask(params);
+  state.taskId = task.id;
+  state.taskName = task.taskName ?? state.taskName;
+  state.total = task.totalCount ?? count.totalCount;
+  state.finished = task.finishedCount ?? 0;
+  state.progress = task.progress ?? 0;
+  pollTask();
+}
+
+function pollTask() {
+  stopPolling();
+  pollTimer = setInterval(() => {
+    void refreshTask();
+  }, 1000);
+}
+
+async function refreshTask() {
+  if (!state.taskId) return;
+  try {
+    const task = await getQrExportTask(state.taskId);
+    state.total = task.totalCount ?? 0;
+    state.finished = task.finishedCount ?? 0;
+    state.progress = task.progress ?? 0;
+    state.taskName = task.taskName ?? state.taskName;
+    if (task.status === 30) {
+      // 寰呬笅杞�
+      stopPolling();
+      state.progress = 100;
+      await downloadProduct(task);
+      return;
+    }
+    if (task.status === 40) {
+      stopPolling();
+      state.phase = 'canceled';
+      state.message = '瀵煎嚭宸插彇娑�';
+      return;
+    }
+    if (task.status === 50) {
+      stopPolling();
+      state.phase = 'failed';
+      state.errorMsg = task.errorMsg ?? '瀵煎嚭澶辫触';
+      state.message = '瀵煎嚭澶辫触';
+      return;
+    }
+    state.phase = 'generating';
+    state.message = `姝e湪鐢熸垚锛�${state.finished} / ${state.total}锛夆�;
+  } catch (error: any) {
+    stopPolling();
+    failWith(error);
+  }
+}
+
+/** 涓嬭浇浜х墿锛氫紭鍏堟祦寮忓啓鍏ョ鐩橈紝閬垮厤鏁扮櫨 MB 鐨勫唴瀹规暣鍧楅┗鐣欏唴瀛� */
+async function downloadProduct(task: MesProQrExportApi.ExportTask) {
+  state.phase = 'downloading';
+  state.message = '姝e湪涓嬭浇鈥�';
+  state.downloadLoaded = 0;
+  state.downloadTotal = task.fileSize ?? 0;
+  const fileName = `${task.taskName ?? '浜岀淮鐮佸鍑�'}.zip`;
+  const url = `${apiURL}${getQrExportDownloadPath(task.id)}`;
+  const token = useAccessStore().accessToken;
+
+  const controller = new AbortController();
+  let canceled = false;
+  let writable: any = null;
+  downloadAbort = () => {
+    canceled = true;
+    controller.abort();
+  };
+
+  try {
+    const response = await fetch(url, {
+      headers: token ? { Authorization: `Bearer ${token}` } : {},
+      signal: controller.signal,
+    });
+    if (!response.ok || !response.body) {
+      throw new Error(`涓嬭浇澶辫触锛圚TTP ${response.status}锛塦);
+    }
+    const contentLength = Number(response.headers.get('content-length') ?? 0);
+    if (contentLength > 0) {
+      state.downloadTotal = contentLength;
+    }
+    const reader = response.body.getReader();
+    downloadAbort = () => {
+      canceled = true;
+      void reader.cancel();
+    };
+    const useDisk = Boolean(saveHandle);
+    if (useDisk) {
+      writable = await saveHandle.createWritable();
+    }
+    // 杈规敹杈瑰啓锛氳繘搴︽潵鑷湡瀹炲瓧鑺傛暟锛涜兘鍐欑洏灏卞彧鍗犱竴涓垎鐗囩殑鍐呭瓨
+    const chunks: Uint8Array[] = [];
+    let loaded = 0;
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      if (value) {
+        if (useDisk) {
+          await writable.write(value);
+        } else {
+          chunks.push(value);
+        }
+        loaded += value.length;
+        state.downloadLoaded = loaded;
+      }
+    }
+    if (canceled) {
+      if (useDisk) await writable.abort();
+      return;
+    }
+    if (useDisk) {
+      await writable.close();
+    } else {
+      const blob = new Blob(chunks as BlobPart[], { type: 'application/zip' });
+      downloadFileFromBlobPart({ fileName, source: blob });
+    }
+    state.phase = 'done';
+    state.message = `宸插鍑� ${state.total} 寮燻;
+  } catch (error: any) {
+    if (canceled) return;
+    failWith(error);
+  } finally {
+    downloadAbort = null;
+    saveHandle = null;
+  }
+}
+
+/** 鍙栨秷锛氱敓鎴愪腑鍙栨秷浠诲姟锛屼笅杞戒腑涓柇涓嬭浇 */
+async function cancel() {
+  if (state.phase === 'downloading') {
+    downloadAbort?.();
+    state.phase = 'canceled';
+    state.message = '宸插彇娑堜笅杞斤紙鏈嶅姟绔枃浠朵細琚嚜鍔ㄦ竻鐞嗭級';
+    return;
+  }
+  if (state.taskId && ['counting', 'generating'].includes(state.phase)) {
+    try {
+      await cancelQrExportTask(state.taskId);
+    } catch {
+      // 鍙栨秷澶辫触涓嶉樆鏂晫闈㈡敹灏撅細鏈嶅姟绔换鍔$殑瓒呮椂鍥炴敹鏈哄埗浼氬厹搴�
+    }
+    stopPolling();
+    state.phase = 'canceled';
+    state.message = '宸插彇娑堝鍑�';
+  }
+}
+
+/** 鏀惰捣涓烘诞鏍囷紝浠诲姟缁х画鍦ㄥ悗鍙拌窇 */
+function minimize() {
+  state.minimized = true;
+}
+
+/** 浠庢诞鏍囧睍寮�闈㈡澘 */
+function expand() {
+  state.minimized = false;
+}
+
+/** 鍏抽棴闈㈡澘骞舵竻绌虹姸鎬侊紙杩涜涓渶鍏堝彇娑堬級 */
+function close() {
+  stopPolling();
+  downloadAbort?.();
+  downloadAbort = null;
+  saveHandle = null;
+  resetState();
+}
+
+function failWith(error: any) {
+  state.phase = 'failed';
+  state.errorMsg = error?.message ?? error?.msg ?? '瀵煎嚭澶辫触';
+  state.message = '瀵煎嚭澶辫触';
+}
+
+export function useQrExport() {
+  return {
+    state,
+    isActive,
+    start,
+    cancel,
+    minimize,
+    expand,
+    close,
+  };
+}

--
Gitblit v1.9.3