云
2 天以前 77e4c3d0201a83cf41b73d7bd7a79255759575f4
src/views/mes/pro/qrexport/use-qr-export.ts
@@ -34,6 +34,8 @@
export interface QrExportState {
  bizType?: MesProQrExportApi.BizType;
  /** 下载失败后,服务端文件仍可用时允许直接重试 */
  downloadRetryable: boolean;
  /** 已下载字节 */
  downloadLoaded: number;
  /** 产物总字节(取自 Content-Length,取不到时为 0 表示未知) */
@@ -94,6 +96,7 @@
function createInitialState(): QrExportState {
  return {
    downloadLoaded: 0,
    downloadRetryable: false,
    downloadTotal: 0,
    errorMsg: '',
    finished: 0,
@@ -253,15 +256,55 @@
  }
}
/** 下载产物:优先流式写入磁盘,避免数百 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;
@@ -272,33 +315,41 @@
  };
  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 {
@@ -309,7 +360,6 @@
      }
    }
    if (canceled) {
      if (useDisk) await writable.abort();
      return;
    }
    if (useDisk) {
@@ -322,10 +372,30 @@
    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);
  }
}
@@ -380,6 +450,7 @@
    isActive,
    start,
    cancel,
    retryDownload,
    minimize,
    expand,
    close,