| | |
| | | |
| | | export interface QrExportState { |
| | | bizType?: MesProQrExportApi.BizType; |
| | | /** 下载失败后,服务端文件仍可用时允许直接重试 */ |
| | | downloadRetryable: boolean; |
| | | /** 已下载字节 */ |
| | | downloadLoaded: number; |
| | | /** 产物总字节(取自 Content-Length,取不到时为 0 表示未知) */ |
| | |
| | | function createInitialState(): QrExportState { |
| | | return { |
| | | downloadLoaded: 0, |
| | | downloadRetryable: false, |
| | | downloadTotal: 0, |
| | | errorMsg: '', |
| | | finished: 0, |
| | |
| | | } |
| | | } |
| | | |
| | | /** 下载产物:优先流式写入磁盘,避免数百 MB 的内容整块驻留内存 */ |
| | | /** 每次只请求 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.downloadRetryable = false; |
| | | 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; |
| | | const token = useAccessStore().accessToken ?? undefined; |
| | | |
| | | const controller = new AbortController(); |
| | | let canceled = false; |
| | |
| | | }; |
| | | |
| | | try { |
| | | 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(); |
| | | 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 (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}` } : {}, |
| | | 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 { |
| | |
| | | } |
| | | } |
| | | if (canceled) { |
| | | if (useDisk) await writable.abort(); |
| | | return; |
| | | } |
| | | if (useDisk) { |
| | |
| | | state.message = `已导出 ${state.total} 张`; |
| | | } catch (error: any) { |
| | | if (canceled) return; |
| | | state.downloadRetryable = true; |
| | | failWith(error); |
| | | } finally { |
| | | if (writable && state.phase !== 'done') { |
| | | await writable.abort().catch(() => {}); |
| | | } |
| | | downloadAbort = null; |
| | | saveHandle = null; |
| | | } |
| | | } |
| | | |
| | | /** 重新下载已生成的 ZIP,无需再次生成二维码。 */ |
| | | async function retryDownload() { |
| | | if (!state.downloadRetryable || !state.taskId || state.phase !== 'failed') return; |
| | | try { |
| | | // 保存文件选择器需要用户点击触发;先取得句柄,再查询任务状态。 |
| | | saveHandle = await tryPickSaveFile(`${state.taskName}.zip`); |
| | | const task = await getQrExportTask(state.taskId); |
| | | if (!task.downloadable) { |
| | | throw new Error('导出文件已过期,请重新生成'); |
| | | } |
| | | await downloadProduct(task); |
| | | } catch (error: any) { |
| | | failWith(error); |
| | | } |
| | | } |
| | | |
| | |
| | | isActive, |
| | | start, |
| | | cancel, |
| | | retryDownload, |
| | | minimize, |
| | | expand, |
| | | close, |