云
3 天以前 fbc58d6280a811f3c36696c530961f44c6491bb2
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;
    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} 张`;
@@ -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('导出文件已过期,请重新生成');