| src/api/mes/pro/qrExport/index.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/components/qr-export-task-modal.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/qrexport/download-qr-export.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/qrexport/use-qr-export.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
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 } }); } 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"> æ£å¨ä¸è½½ {{ 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> 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(`åæ®µä¸è½½å¤±è´¥ï¼HTTP ${response.status}ï¼`); } if (response.headers.get('content-range') !== `bytes ${start}-${end}/${total}`) { throw new Error('ä¸è½½ååºçåèèå´ä¸æ£ç¡®'); } 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(() => {}); } } 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(`åæ®µä¸è½½å¤±è´¥ï¼HTTP ${response.status}ï¼`); } if (response.headers.get('content-range') !== `bytes ${start}-${end}/${total}`) { throw new Error('ä¸è½½ååºçåèèå´ä¸æ£ç¡®'); } 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 = 'æ£å¨ä¸è½½â¦'; 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; state.downloadLoaded = loaded; } } else { // å ¼å®¹æªè®°å½æä»¶å¤§å°çæ§ä»»å¡ã const response = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {}, await downloadQrExportZip(task, { saveHandle, signal: controller.signal, }); if (!response.ok || !response.body) { throw new Error(`ä¸è½½å¤±è´¥ï¼HTTP ${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; onProgress(loaded, total) { 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('å¯¼åºæä»¶å·²è¿æï¼è¯·éæ°çæ');