From fbc58d6280a811f3c36696c530961f44c6491bb2 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期六, 17 十月 2026 14:06:39 +0800
Subject: [PATCH] refactor(mes): 重构二维码导出下载功能

---
 src/views/mes/pro/components/qr-export-task-modal.vue |   43 ++++++-
 src/api/mes/pro/qrExport/index.ts                     |    3 
 src/views/mes/pro/qrexport/download-qr-export.ts      |  124 ++++++++++++++++++++
 src/views/mes/pro/qrexport/use-qr-export.ts           |  133 +--------------------
 4 files changed, 170 insertions(+), 133 deletions(-)

diff --git a/src/api/mes/pro/qrExport/index.ts b/src/api/mes/pro/qrExport/index.ts
index cc778ea..af2acf7 100644
--- a/src/api/mes/pro/qrExport/index.ts
+++ b/src/api/mes/pro/qrExport/index.ts
@@ -66,6 +66,3 @@
   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/components/qr-export-task-modal.vue b/src/views/mes/pro/components/qr-export-task-modal.vue
index 9330a7d..578f50c 100644
--- a/src/views/mes/pro/components/qr-export-task-modal.vue
+++ b/src/views/mes/pro/components/qr-export-task-modal.vue
@@ -2,19 +2,21 @@
   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';
+  import { downloadQrExportZip, pickQrExportSaveFile } from '../qrexport/download-qr-export';
 
   const storageKey = 'mes-pro-last-qr-export-task-id';
   const visible = ref(false);
   const task = ref<QrExportTask>();
   const downloading = ref(false);
+  const downloadLoaded = ref(0);
+  const downloadTotal = ref(0);
+  let downloadController: AbortController | undefined;
   let timer: ReturnType<typeof setTimeout> | undefined;
 
   function stopPolling() {
@@ -56,15 +58,36 @@
 
   async function download() {
     if (!task.value?.downloadable || downloading.value) return;
+    const currentTask = task.value;
+    // 鏂囦欢閫夋嫨鍣ㄥ繀椤荤敱鐢ㄦ埛鐐瑰嚮鐩存帴瑙﹀彂锛屼笉鑳界瓑缃戠粶璇锋眰涔嬪悗鍐嶈皟鐢ㄣ��
+    const saveHandle = await pickQrExportSaveFile(`${currentTask.taskName}.zip`);
     downloading.value = true;
+    downloadLoaded.value = 0;
+    downloadTotal.value = currentTask.fileSize ?? 0;
+    const controller = new AbortController();
+    downloadController = controller;
     try {
-      const data = await downloadQrExportTask(task.value.id);
-      downloadFileFromBlobPart({ fileName: `${task.value.taskName}.zip`, source: data });
-      localStorage.removeItem(storageKey);
-      await refresh();
+      await downloadQrExportZip(currentTask, {
+        saveHandle,
+        signal: controller.signal,
+        onProgress(loaded, total) {
+          downloadLoaded.value = loaded;
+          downloadTotal.value = total;
+        },
+      });
+      message.success('ZIP 涓嬭浇瀹屾垚');
+    } catch (error: any) {
+      if (!controller.signal.aborted) {
+        message.error(error?.message ?? '涓嬭浇澶辫触锛岃閲嶈瘯');
+      }
     } finally {
       downloading.value = false;
+      if (downloadController === controller) downloadController = undefined;
     }
+  }
+
+  function formatSize(bytes: number) {
+    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
   }
 
   onMounted(async () => {
@@ -81,7 +104,10 @@
       localStorage.removeItem(storageKey);
     }
   });
-  onBeforeUnmount(stopPolling);
+  onBeforeUnmount(() => {
+    stopPolling();
+    downloadController?.abort();
+  });
 
   defineExpose({ show, showLast });
 </script>
@@ -92,6 +118,9 @@
       <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="downloading" class="mt-2 text-sm">
+        姝e湪涓嬭浇 {{ formatSize(downloadLoaded) }} / {{ formatSize(downloadTotal) }}
+      </p>
       <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>
diff --git a/src/views/mes/pro/qrexport/download-qr-export.ts b/src/views/mes/pro/qrexport/download-qr-export.ts
new file mode 100644
index 0000000..38fb5be
--- /dev/null
+++ b/src/views/mes/pro/qrexport/download-qr-export.ts
@@ -0,0 +1,124 @@
+import type { QrExportTask } from '#/api/mes/pro/qrExport';
+
+import { useAppConfig } from '@vben/hooks';
+import { useAccessStore } from '@vben/stores';
+import { downloadFileFromBlobPart } from '@vben/utils';
+
+import { getQrExportDownloadPath } from '#/api/mes/pro/qrExport';
+
+const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
+const CHUNK_SIZE = 2 * 1024 * 1024;
+
+/** 鍦ㄧ敤鎴风偣鍑绘椂閫夋嫨钀界洏浣嶇疆锛涙祻瑙堝櫒涓嶆敮鎸佹椂杩斿洖 null锛屾敼鐢� Blob 涓嬭浇銆� */
+export async function pickQrExportSaveFile(fileName: string): Promise<any> {
+  const picker = (window as any).showSaveFilePicker;
+  if (typeof picker !== 'function') return null;
+  try {
+    return await picker({
+      suggestedName: fileName,
+      types: [{ description: 'ZIP 鍘嬬缉鍖�', accept: { 'application/zip': ['.zip'] } }],
+    });
+  } catch {
+    return null;
+  }
+}
+
+async function requestChunk(
+  url: string,
+  token: string | undefined,
+  start: number,
+  end: number,
+  total: number,
+  signal: AbortSignal,
+): Promise<Uint8Array> {
+  for (let attempt = 0; attempt < 3; attempt++) {
+    try {
+      const response = await fetch(url, {
+        headers: {
+          ...(token ? { Authorization: `Bearer ${token}` } : {}),
+          Range: `bytes=${start}-${end}`,
+        },
+        signal,
+      });
+      if (response.status !== 206) {
+        throw new Error(`鍒嗘涓嬭浇澶辫触锛圚TTP ${response.status}锛塦);
+      }
+      if (response.headers.get('content-range') !== `bytes ${start}-${end}/${total}`) {
+        throw new Error('涓嬭浇鍝嶅簲鐨勫瓧鑺傝寖鍥翠笉姝g‘');
+      }
+      const bytes = new Uint8Array(await response.arrayBuffer());
+      if (bytes.byteLength !== end - start + 1) {
+        throw new Error('涓嬭浇鍐呭涓嶅畬鏁�');
+      }
+      return bytes;
+    } catch (error) {
+      if (signal.aborted || attempt === 2) throw error;
+      await new Promise((resolve) => setTimeout(resolve, 750 * (attempt + 1)));
+    }
+  }
+  throw new Error('鍒嗘涓嬭浇澶辫触');
+}
+
+async function resolveFileSize(
+  url: string,
+  token: string | undefined,
+  signal: AbortSignal,
+): Promise<number> {
+  const response = await fetch(url, {
+    headers: {
+      ...(token ? { Authorization: `Bearer ${token}` } : {}),
+      Range: 'bytes=0-0',
+    },
+    signal,
+  });
+  const match = /^bytes 0-0\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
+  const total = Number(match?.[1]);
+  if (response.status !== 206 || !Number.isSafeInteger(total) || total <= 0) {
+    throw new Error('鏃犳硶鑾峰彇瀵煎嚭鏂囦欢澶у皬锛岃鍒锋柊浠诲姟鍚庨噸璇�');
+  }
+  await response.arrayBuffer();
+  return total;
+}
+
+/** 澶ф枃浠跺垎娈典笅杞斤紱鍗曟澶辫触鑷姩閲嶈瘯锛屾垚鍔熷悗鎵嶅皢瀹屾暣 ZIP 浜ょ粰娴忚鍣ㄣ�� */
+export async function downloadQrExportZip(
+  task: QrExportTask,
+  options: {
+    onProgress?: (loaded: number, total: number) => void;
+    saveHandle?: any;
+    signal: AbortSignal;
+  },
+): Promise<void> {
+  const url = `${apiURL}${getQrExportDownloadPath(task.id)}`;
+  const token = useAccessStore().accessToken ?? undefined;
+  const total = Number.isSafeInteger(task.fileSize) && (task.fileSize ?? 0) > 0
+    ? task.fileSize!
+    : await resolveFileSize(url, token, options.signal);
+  const chunks: Uint8Array[] = [];
+  let writable: any = null;
+  let completed = false;
+  let loaded = 0;
+  try {
+    if (options.saveHandle) writable = await options.saveHandle.createWritable();
+    options.onProgress?.(0, total);
+    for (let start = 0; start < total; start += CHUNK_SIZE) {
+      const end = Math.min(start + CHUNK_SIZE, total) - 1;
+      const bytes = await requestChunk(url, token, start, end, total, options.signal);
+      if (writable) await writable.write(bytes);
+      else chunks.push(bytes);
+      loaded += bytes.byteLength;
+      options.onProgress?.(loaded, total);
+    }
+    if (writable) await writable.close();
+    else {
+      const blob = new Blob(chunks as BlobPart[], { type: 'application/zip' });
+      downloadFileFromBlobPart({
+        fileName: `${task.taskName ?? '浜岀淮鐮佸鍑�'}.zip`,
+        source: blob,
+      });
+    }
+    completed = true;
+  } finally {
+    if (writable && !completed) await writable.abort().catch(() => {});
+  }
+}
diff --git a/src/views/mes/pro/qrexport/use-qr-export.ts b/src/views/mes/pro/qrexport/use-qr-export.ts
index 61275b2..cfb3b71 100644
--- a/src/views/mes/pro/qrexport/use-qr-export.ts
+++ b/src/views/mes/pro/qrexport/use-qr-export.ts
@@ -2,19 +2,15 @@
 
 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';
+import { downloadQrExportZip, pickQrExportSaveFile } from './download-qr-export';
 
 /**
  * 瀵煎嚭闃舵
@@ -55,8 +51,6 @@
   total: number;
 }
 
-const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
-
 /**
  * 瀵煎嚭鐘舵�佹彁鍒版ā鍧楃骇鍗曚緥锛氳繘搴﹂潰鏉胯鏀惰捣銆佹垨椤甸潰缁勪欢琚攢姣侊紙鐢ㄦ埛鍒囪蛋鍐嶅垏鍥烇級鏃讹紝
  * 杞涓庝笅杞介兘蹇呴』缁х画锛屼笉鑳藉洜涓虹粍浠跺嵏杞借�屼腑鏂��
@@ -74,24 +68,6 @@
  * 娴忚鍣ㄤ笉鏀寔鎴栫敤鎴锋斁寮冮�夋嫨鏃堕��鍥炲唴瀛樹笅杞姐��
  */
 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 {
@@ -170,7 +146,7 @@
       return;
     }
     // 澶ф壒閲忥細瓒佺敤鎴锋墜鍔夸粛鏈夋晥鍏堝彇淇濆瓨鍙ユ焺锛屼骇鐗╁彲鐩存帴娴佸紡钀界洏
-    saveHandle = await tryPickSaveFile(`${state.taskName}.zip`);
+    saveHandle = await pickQrExportSaveFile(`${state.taskName}.zip`);
     await startAsync(params, count);
   } catch (error: any) {
     failWith(error);
@@ -256,45 +232,6 @@
   }
 }
 
-/** 姣忔鍙姹� 2 MB锛岄伩鍏嶄唬鐞嗗鍗曚釜澶ф枃浠跺搷搴旇秴鏃躲�� */
-const DOWNLOAD_CHUNK_SIZE = 2 * 1024 * 1024;
-
-async function fetchDownloadChunk(
-  url: string,
-  token: string | undefined,
-  start: number,
-  end: number,
-  total: number,
-  signal: AbortSignal,
-): Promise<Uint8Array> {
-  for (let attempt = 0; attempt < 3; attempt++) {
-    try {
-      const response = await fetch(url, {
-        headers: {
-          ...(token ? { Authorization: `Bearer ${token}` } : {}),
-          Range: `bytes=${start}-${end}`,
-        },
-        signal,
-      });
-      if (response.status !== 206) {
-        throw new Error(`鍒嗘涓嬭浇澶辫触锛圚TTP ${response.status}锛塦);
-      }
-      if (response.headers.get('content-range') !== `bytes ${start}-${end}/${total}`) {
-        throw new Error('涓嬭浇鍝嶅簲鐨勫瓧鑺傝寖鍥翠笉姝g‘');
-      }
-      const bytes = new Uint8Array(await response.arrayBuffer());
-      if (bytes.byteLength !== end - start + 1) {
-        throw new Error('涓嬭浇鍐呭涓嶅畬鏁�');
-      }
-      return bytes;
-    } catch (error) {
-      if (signal.aborted || attempt === 2) throw error;
-      await new Promise((resolve) => setTimeout(resolve, 750 * (attempt + 1)));
-    }
-  }
-  throw new Error('鍒嗘涓嬭浇澶辫触');
-}
-
 /** 涓嬭浇浜х墿锛氫紭鍏堝啓鍏ョ鐩橈紝姣忔鐙珛璇锋眰骞跺彲閲嶈瘯銆� */
 async function downloadProduct(task: MesProQrExportApi.ExportTask) {
   state.phase = 'downloading';
@@ -302,71 +239,24 @@
   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 ?? undefined;
-
   const controller = new AbortController();
   let canceled = false;
-  let writable: any = null;
   downloadAbort = () => {
     canceled = true;
     controller.abort();
   };
 
   try {
-    const useDisk = Boolean(saveHandle);
-    if (useDisk) {
-      writable = await saveHandle.createWritable();
-    }
-    const chunks: Uint8Array[] = [];
-    let loaded = 0;
-    if (Number.isSafeInteger(task.fileSize) && (task.fileSize ?? 0) > 0) {
-      const total = task.fileSize!;
-      for (let start = 0; start < total; start += DOWNLOAD_CHUNK_SIZE) {
-        const end = Math.min(start + DOWNLOAD_CHUNK_SIZE, total) - 1;
-        const value = await fetchDownloadChunk(url, token, start, end, total, controller.signal);
-        if (useDisk) {
-          await writable.write(value);
-        } else {
-          chunks.push(value);
-        }
-        loaded += value.length;
+    await downloadQrExportZip(task, {
+      saveHandle,
+      signal: controller.signal,
+      onProgress(loaded, total) {
         state.downloadLoaded = loaded;
-      }
-    } else {
-      // 鍏煎鏈褰曟枃浠跺ぇ灏忕殑鏃т换鍔°��
-      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();
-      for (;;) {
-        const { done, value } = await reader.read();
-        if (done) break;
-        if (!value) continue;
-        if (useDisk) {
-          await writable.write(value);
-        } else {
-          chunks.push(value);
-        }
-        loaded += value.length;
-        state.downloadLoaded = loaded;
-      }
-    }
+        state.downloadTotal = total;
+      },
+    });
     if (canceled) {
       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} 寮燻;
@@ -375,9 +265,6 @@
     state.downloadRetryable = true;
     failWith(error);
   } finally {
-    if (writable && state.phase !== 'done') {
-      await writable.abort().catch(() => {});
-    }
     downloadAbort = null;
     saveHandle = null;
   }
@@ -388,7 +275,7 @@
   if (!state.downloadRetryable || !state.taskId || state.phase !== 'failed') return;
   try {
     // 淇濆瓨鏂囦欢閫夋嫨鍣ㄩ渶瑕佺敤鎴风偣鍑昏Е鍙戯紱鍏堝彇寰楀彞鏌勶紝鍐嶆煡璇换鍔$姸鎬併��
-    saveHandle = await tryPickSaveFile(`${state.taskName}.zip`);
+    saveHandle = await pickQrExportSaveFile(`${state.taskName}.zip`);
     const task = await getQrExportTask(state.taskId);
     if (!task.downloadable) {
       throw new Error('瀵煎嚭鏂囦欢宸茶繃鏈燂紝璇烽噸鏂扮敓鎴�');

--
Gitblit v1.9.3