云
2 天以前 fbc58d6280a811f3c36696c530961f44c6491bb2
refactor(mes): 重构二维码导出下载功能

- 移除原有的 downloadQrExportTask 函数
- 提取下载逻辑到独立的 download-qr-export.ts 文件
- 实现分段下载和断点续传功能
- 添加下载进度显示和取消功能
- 使用文件选择器优化用户体验
- 改进错误处理和重试机制
已添加1个文件
已修改3个文件
301 ■■■■■ 文件已修改
src/api/mes/pro/qrExport/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/components/qr-export-task-modal.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/download-qr-export.ts 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/use-qr-export.ts 131 ●●●●● 补丁 | 查看 | 原始文档 | 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('导出文件已过期,请重新生成');