| | |
| | | |
| | | 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'; |
| | | |
| | | /** |
| | | * 导出阶段 |
| | |
| | | total: number; |
| | | } |
| | | |
| | | const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); |
| | | |
| | | /** |
| | | * 导出状态提到模块级单例:进度面板被收起、或页面组件被销毁(用户切走再切回)时, |
| | | * 轮询与下载都必须继续,不能因为组件卸载而中断。 |
| | |
| | | * 浏览器不支持或用户放弃选择时退回内存下载。 |
| | | */ |
| | | 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 { |
| | |
| | | return; |
| | | } |
| | | // 大批量:趁用户手势仍有效先取保存句柄,产物可直接流式落盘 |
| | | saveHandle = await tryPickSaveFile(`${state.taskName}.zip`); |
| | | saveHandle = await pickQrExportSaveFile(`${state.taskName}.zip`); |
| | | await startAsync(params, count); |
| | | } catch (error: any) { |
| | | failWith(error); |
| | |
| | | } |
| | | } |
| | | |
| | | /** 每次只请求 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'; |
| | |
| | | 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; |
| | | 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(`下载失败(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; |
| | | 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} 张`; |
| | |
| | | state.downloadRetryable = true; |
| | | failWith(error); |
| | | } finally { |
| | | if (writable && state.phase !== 'done') { |
| | | await writable.abort().catch(() => {}); |
| | | } |
| | | downloadAbort = null; |
| | | saveHandle = null; |
| | | } |
| | |
| | | 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('导出文件已过期,请重新生成'); |