liu
3 天以前 f2e80b9f8dddd8da58b9840cf69139d028aaf0d4
feat(mes): 二维码导出接入后台任务与可隐藏进度浮层

导出按数量分流:不超过分界线仍走原同步下载,超过则转后台任务。

新增进度浮层:展示真实生成/下载进度,支持「取消」中断任务、
「隐藏」收起为浮标后任务在后台继续,点击浮标可恢复面板。
大批量产物改为流式读入,能取到保存句柄时边下载边写盘,
避免数百 MB 的内容整块驻留内存。

Co-Authored-By: Claude <noreply@anthropic.com>
已添加3个文件
已修改1个文件
696 ■■■■■ 文件已修改
src/api/mes/pro/qrExport/index.ts 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/index.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/qr-export-progress.vue 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/use-qr-export.ts 387 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/qrExport/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
import { requestClient } from '#/api/request';
export namespace MesProQrExportApi {
  /** ä¸šåŠ¡ç±»åž‹ */
  export type BizType =
    | 'BAG_BUSINESS'
    | 'BAG_LABEL'
    | 'BATCH_BUSINESS'
    | 'BATCH_LABEL'
    | 'PALLET_BUSINESS'
    | 'PALLET_LABEL';
  /** å¯¼å‡ºæ–¹å¼ï¼šSYNC åŒæ­¥ç›´æŽ¥ä¸‹è½½ï¼ˆç§’出)/ ASYNC åŽå°ä»»åŠ¡ */
  export type ExportMode = 'ASYNC' | 'SYNC';
  /** å¯¼å‡ºä»»åŠ¡çŠ¶æ€ï¼š10 æŽ’队中 20 ç”Ÿæˆä¸­ 30 å¾…下载 40 å·²å–消 50 å·²å¤±è´¥ 60 å·²ä¸‹è½½ */
  export type TaskStatus = 10 | 20 | 30 | 40 | 50 | 60;
  /** å¯¼å‡ºå‚æ•° */
  export interface ExportParams {
    bizType: BizType;
    batchId?: number;
    uuid?: string;
  }
  /** å¯¼å‡ºæ•°é‡ä¸Žæ–¹å¼ */
  export interface ExportCount {
    mode: ExportMode;
    syncThreshold: number;
    totalCount: number;
  }
  /** å¯¼å‡ºä»»åŠ¡ */
  export interface ExportTask {
    id: number;
    bizType: string;
    taskName: string;
    status: TaskStatus;
    statusName: string;
    totalCount: number;
    finishedCount: number;
    progress: number;
    fileSize?: number;
    errorMsg?: string;
    downloadable: boolean;
    createTime?: string;
    finishTime?: string;
  }
}
/** ç»Ÿè®¡å¯¼å‡ºå¼ æ•°å¹¶ç»™å‡ºå¯¼å‡ºæ–¹å¼ */
export function getQrExportCount(params: MesProQrExportApi.ExportParams) {
  return requestClient.get<MesProQrExportApi.ExportCount>(
    '/mes/pro/qr-export/count',
    { params },
  );
}
/** æäº¤åŽå°å¯¼å‡ºä»»åŠ¡ */
export function submitQrExportTask(data: MesProQrExportApi.ExportParams) {
  return requestClient.post<MesProQrExportApi.ExportTask>(
    '/mes/pro/qr-export/task',
    data,
  );
}
/** æŸ¥è¯¢å¯¼å‡ºä»»åŠ¡è¿›åº¦ */
export function getQrExportTask(id: number) {
  return requestClient.get<MesProQrExportApi.ExportTask>(
    '/mes/pro/qr-export/task/get',
    { params: { id } },
  );
}
/** å–消导出任务 */
export function cancelQrExportTask(id: number) {
  return requestClient.post<boolean>('/mes/pro/qr-export/task/cancel', null, {
    params: { id },
  });
}
/** å¯¼å‡ºä»»åŠ¡äº§ç‰©ä¸‹è½½åœ°å€ï¼ˆä¸å¸¦é‰´æƒå¤´ï¼Œä¾›æµå¼ä¸‹è½½è‡ªè¡Œé™„åŠ  token) */
export function getQrExportDownloadPath(id: number) {
  return `/mes/pro/qr-export/task/download?id=${id}`;
}
src/views/mes/pro/bagCode/index.vue
@@ -30,6 +30,8 @@
  import ReplaceModal from "./modules/replace-form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  import BusinessQrModal from "../components/business-qr.vue";
  import QrExportProgress from "../qrexport/qr-export-progress.vue";
  import { useQrExport } from "../qrexport/use-qr-export";
  defineOptions({ name: "MesProBagCode" });
@@ -91,6 +93,8 @@
    gridApi.query();
  }
  const { start: startQrExport } = useQrExport();
  /** ç”Ÿæˆæ•°æ®åŒ… */
  function handleGenerate() {
    generateFormModalApi.open();
@@ -131,8 +135,24 @@
      message.warning("请先按生成组或批次筛选后再导出二维码");
      return;
    }
    const data = await (kind === 'business' ? exportBagBusinessQr : exportBagQr)(params);
    downloadFileFromBlobPart({ fileName: kind === 'business' ? '袋码业务二维码.zip' : '袋码追溯二维码.zip', source: data });
    // æ•°é‡å°‘则同步秒出;超过分界线自动转后台任务,由进度浮层展示真实进度并支持取消/隐藏
    const bizType = kind === "business" ? "BAG_BUSINESS" : "BAG_LABEL";
    const fileName = kind === "business" ? "袋码业务二维码" : "袋码追溯二维码";
    await startQrExport(
      { bizType, ...params },
      {
        fileName,
        syncDownload: async () => {
          const data = await (kind === "business"
            ? exportBagBusinessQr
            : exportBagQr)(params);
          downloadFileFromBlobPart({
            fileName: `${fileName}.zip`,
            source: data,
          });
        },
      },
    );
  }
  /** å¯¼å‡ºè¢‹ç  Excel(交付印刷),导出后袋码状态变为已印刷,需刷新列表 */
@@ -238,11 +258,14 @@
  function handleBindPallet() {
    const records = getSelectedRecords();
    if (!records) return;
    if (records.some((record) => !record.batchId)) {
      message.warning("请先录入生产批次,再绑定托盘");
    // å·²å½•入批次的袋码只能绑到本批次的托盘;未录入批次的袋码可绑任意托盘(选到正式托盘时后端会先补录批次)。
    // ä¸¤è€…混选时批次归属不明确,要求分开操作。
    const boundRecords = records.filter((record) => record.batchId);
    if (boundRecords.length > 0 && boundRecords.length < records.length) {
      message.warning("所选袋码中既有已录入批次、也有未录入批次的记录,请分开绑定");
      return;
    }
    const batchIds = new Set(records.map((record) => record.batchId));
    const batchIds = new Set(boundRecords.map((record) => record.batchId));
    if (batchIds.size > 1) {
      message.warning("请选择同一批次下的袋码进行绑定");
      return;
@@ -264,6 +287,7 @@
      .setData({
        batchId: first.batchId,
        batchCode: first.batchCode,
        itemId: first.itemId,
        ids: records.map((record) => record.id!),
      })
      .open();
@@ -347,6 +371,7 @@
    <ReplaceFormModal @success="handleReplaceSuccess" />
    <QrTraceQueryModal />
    <BusinessQrViewModal />
    <QrExportProgress />
    <Grid table-title="袋码列表(一袋一码)">
      <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">袋码列表(一袋一码)</span></template>
      <template #toolbar-tools>
src/views/mes/pro/qrexport/qr-export-progress.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,189 @@
<script lang="ts" setup>
  import { computed } from 'vue';
  import { Button, Progress } from 'ant-design-vue';
  import { useQrExport } from './use-qr-export';
  defineOptions({ name: 'QrExportProgress' });
  const { state, cancel, minimize, expand, close } = useQrExport();
  /** é¢æ¿æ˜¯å¦å±•示(idle è¡¨ç¤ºæ²¡æœ‰ä»»ä½•任务) */
  const visible = computed(() => state.phase !== 'idle');
  /** è¿›è¡Œä¸­çš„阶段才允许取消 */
  const cancelable = computed(() =>
    ['counting', 'downloading', 'generating'].includes(state.phase),
  );
  const percent = computed(() => {
    switch (state.phase) {
      case 'counting': {
        return 0;
      }
      case 'done': {
        return 100;
      }
      case 'downloading': {
        if (state.downloadTotal > 0) {
          return Math.min(
            100,
            Math.round((state.downloadLoaded / state.downloadTotal) * 100),
          );
        }
        return 0;
      }
      case 'generating': {
        return state.progress;
      }
      default: {
        return state.progress;
      }
    }
  });
  const progressStatus = computed(() => {
    if (state.phase === 'failed') return 'exception';
    if (state.phase === 'done') return 'success';
    return 'active';
  });
  /** ç”Ÿæˆé˜¶æ®µä¸Žä¸‹è½½é˜¶æ®µéƒ½ä¸å¯é¢„知时长,用不确定态进度条 */
  const indeterminate = computed(
    () => state.phase === 'counting' || state.phase === 'sync',
  );
  const title = computed(() => {
    switch (state.phase) {
      case 'canceled': {
        return '导出已取消';
      }
      case 'counting': {
        return '正在统计数量';
      }
      case 'done': {
        return '导出完成';
      }
      case 'downloading': {
        return '正在下载';
      }
      case 'failed': {
        return '导出失败';
      }
      case 'generating': {
        return '正在生成二维码';
      }
      case 'sync': {
        return '正在导出';
      }
      default: {
        return '二维码导出';
      }
    }
  });
  const detail = computed(() => {
    if (state.phase === 'failed') {
      return state.errorMsg || '导出失败';
    }
    if (state.phase === 'downloading') {
      if (state.downloadTotal > 0) {
        return `${formatSize(state.downloadLoaded)} / ${formatSize(state.downloadTotal)}`;
      }
      return `已下载 ${formatSize(state.downloadLoaded)}`;
    }
    return state.message;
  });
  /** æ”¶èµ·åŽæµ®æ ‡ä¸Šä»è¦èƒ½çœ‹åˆ°è¿›åº¦ï¼Œé¿å…ç”¨æˆ·ä»¥ä¸ºä»»åŠ¡å·²ç»åœäº† */
  const pillText = computed(() =>
    state.phase === 'generating' ? `${percent.value}%` : title.value,
  );
  function formatSize(bytes: number) {
    if (!bytes) return '0 B';
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
    if (bytes < 1024 * 1024 * 1024) {
      return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
    }
    return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
  }
</script>
<template>
  <!-- æ”¶èµ·æ€ï¼šå°æµ®æ ‡ï¼Œç‚¹å‡»å¯é‡æ–°å±•å¼€ -->
  <div
    v-if="visible && state.minimized"
    class="fixed right-6 bottom-6 z-[2000] flex cursor-pointer items-center gap-2 rounded-full bg-white px-4 py-2 shadow-lg ring-1 ring-gray-200 hover:shadow-xl dark:bg-gray-800 dark:ring-gray-700"
    @click="expand"
  >
    <span
      class="inline-block h-2 w-2 rounded-full"
      :class="{
        'bg-blue-500': state.phase === 'generating' || state.phase === 'downloading',
        'bg-green-500': state.phase === 'done',
        'bg-red-500': state.phase === 'failed',
        'bg-gray-400': state.phase === 'canceled',
      }"
    ></span>
    <span class="text-sm text-gray-700 dark:text-gray-200">
      äºŒç»´ç å¯¼å‡º {{ pillText }}
    </span>
  </div>
  <!-- å±•开态:进度面板 -->
  <div
    v-else-if="visible"
    class="fixed right-6 bottom-6 z-[2000] w-80 rounded-lg bg-white p-4 shadow-lg ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
  >
    <div class="mb-2 flex items-center justify-between">
      <span class="text-sm font-medium text-gray-800 dark:text-gray-100">
        {{ title }}
      </span>
      <div class="flex items-center gap-1">
        <!-- è¿›è¡Œä¸­æä¾›ã€Œéšè—ã€ï¼Œä»»åŠ¡è½¬å…¥åŽå°ç»§ç»­ï¼Œä¸ä¼šè¢«ä¸­æ–­ -->
        <Button
          v-if="state.phase !== 'done' && state.phase !== 'failed' && state.phase !== 'canceled'"
          type="link"
          size="small"
          @click="minimize"
        >
          éšè—
        </Button>
        <Button
          v-if="cancelable"
          type="link"
          size="small"
          danger
          @click="cancel"
        >
          å–消
        </Button>
        <Button
          v-if="!cancelable"
          type="link"
          size="small"
          @click="close"
        >
          å…³é—­
        </Button>
      </div>
    </div>
    <Progress
      :percent="percent"
      :status="progressStatus"
      :show-info="!indeterminate"
      size="small"
    />
    <div class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
      {{ state.taskName }}
    </div>
    <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
      {{ detail }}
    </div>
  </div>
</template>
src/views/mes/pro/qrexport/use-qr-export.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,387 @@
import type { MesProQrExportApi } from '#/api/mes/pro/qrExport';
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';
/**
 * å¯¼å‡ºé˜¶æ®µ
 * <p>
 * counting ç»Ÿè®¡å¼ æ•° / sync å°æ‰¹é‡åŒæ­¥ä¸‹è½½ä¸­ / generating åŽå°ç”Ÿæˆä¸­ /
 * downloading ä¸‹è½½äº§ç‰©ä¸­ / done å®Œæˆ / canceled å·²å–消 / failed å¤±è´¥
 */
export type QrExportPhase =
  | 'canceled'
  | 'counting'
  | 'done'
  | 'downloading'
  | 'failed'
  | 'generating'
  | 'idle'
  | 'sync';
export interface QrExportState {
  bizType?: MesProQrExportApi.BizType;
  /** å·²ä¸‹è½½å­—节 */
  downloadLoaded: number;
  /** äº§ç‰©æ€»å­—节(取自 Content-Length,取不到时为 0 è¡¨ç¤ºæœªçŸ¥ï¼‰ */
  downloadTotal: number;
  errorMsg: string;
  /** å·²ç”Ÿæˆå¼ æ•° */
  finished: number;
  message: string;
  /** æ”¶èµ·ä¸ºæµ®æ ‡ï¼ˆä»»åŠ¡ç»§ç»­åœ¨åŽå°è·‘ï¼‰ */
  minimized: boolean;
  phase: QrExportPhase;
  /** è¿›åº¦ç™¾åˆ†æ¯”(0~100) */
  progress: number;
  taskId?: number;
  taskName: string;
  /** æ ‡ç­¾æ€»å¼ æ•° */
  total: number;
}
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
/**
 * å¯¼å‡ºçŠ¶æ€æåˆ°æ¨¡å—çº§å•ä¾‹ï¼šè¿›åº¦é¢æ¿è¢«æ”¶èµ·ã€æˆ–é¡µé¢ç»„ä»¶è¢«é”€æ¯ï¼ˆç”¨æˆ·åˆ‡èµ°å†åˆ‡å›žï¼‰æ—¶ï¼Œ
 * è½®è¯¢ä¸Žä¸‹è½½éƒ½å¿…须继续,不能因为组件卸载而中断。
 */
const state = reactive<QrExportState>(createInitialState());
let pollTimer: null | ReturnType<typeof setInterval> = null;
let downloadAbort: null | (() => void) = null;
/**
 * ç›®æ ‡æ–‡ä»¶å¥æŸ„
 * <p>
 * å¤§æ‰¹é‡å¯¼å‡ºçš„产物可达数百 MB,若整包读进内存再落盘,标签页极易被撑爆。因此优先在点击导出
 * ï¼ˆç”¨æˆ·æ‰‹åŠ¿ä»åœ¨æœ‰æ•ˆæœŸå†…ï¼‰æ—¶å–åˆ°ä¿å­˜å¥æŸ„ï¼Œäº§ç‰©è¾¹ä¸‹è½½è¾¹å†™ç›˜ï¼Œå†…å­˜åªå ä¸€ä¸ªåˆ†ç‰‡ã€‚
 * æµè§ˆå™¨ä¸æ”¯æŒæˆ–用户放弃选择时退回内存下载。
 */
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 {
    downloadLoaded: 0,
    downloadTotal: 0,
    errorMsg: '',
    finished: 0,
    message: '',
    minimized: false,
    phase: 'idle',
    progress: 0,
    taskName: '',
    total: 0,
  };
}
function resetState() {
  Object.assign(state, createInitialState());
}
function stopPolling() {
  if (pollTimer !== null) {
    clearInterval(pollTimer);
    pollTimer = null;
  }
}
/** æ˜¯å¦å¤„于进行中(面板需要展示) */
function isActive() {
  return [
    'counting',
    'generating',
    'downloading',
    'sync',
  ].includes(state.phase);
}
/** ç”±å¤–部页面注入的「小批量同步下载」实现,避免本模块依赖具体业务页 */
export interface StartOptions {
  fileName?: string;
  /** å°æ‰¹é‡ï¼ˆä¸è¶…过分界线)时如何同步下载,由调用页提供 */
  syncDownload?: () => Promise<void>;
}
/**
 * å‘起导出
 * <p>
 * å…ˆé—®åŽç«¯è¦å¼ æ•°ï¼šä¸è¶…过分界线直接同步下载(秒出、不排队);超过则转后台任务并展示进度。
 */
async function start(
  params: MesProQrExportApi.ExportParams,
  options: StartOptions = {},
) {
  if (isActive()) {
    message.warning('已有导出任务正在进行,请等待完成或取消后再试');
    return;
  }
  resetState();
  state.bizType = params.bizType;
  state.taskName = options.fileName ?? '二维码';
  state.phase = 'counting';
  state.message = '正在统计待导出数量…';
  try {
    const count = await getQrExportCount(params);
    state.total = count.totalCount;
    if (count.totalCount === 0) {
      state.phase = 'failed';
      state.errorMsg = '没有可导出的二维码';
      state.message = '没有可导出的二维码';
      return;
    }
    if (count.mode === 'SYNC') {
      await startSync(params, count, options);
      return;
    }
    // å¤§æ‰¹é‡ï¼šè¶ç”¨æˆ·æ‰‹åŠ¿ä»æœ‰æ•ˆå…ˆå–ä¿å­˜å¥æŸ„ï¼Œäº§ç‰©å¯ç›´æŽ¥æµå¼è½ç›˜
    saveHandle = await tryPickSaveFile(`${state.taskName}.zip`);
    await startAsync(params, count);
  } catch (error: any) {
    failWith(error);
  }
}
/** å°æ‰¹é‡ï¼šæ²¿ç”¨åŽŸæœ‰åŒæ­¥ä¸‹è½½æŽ¥å£ */
async function startSync(
  _params: MesProQrExportApi.ExportParams,
  count: MesProQrExportApi.ExportCount,
  options: StartOptions,
) {
  state.phase = 'sync';
  state.message = `共 ${count.totalCount} å¼ ï¼Œæ­£åœ¨ç”Ÿæˆâ€¦`;
  if (!options.syncDownload) {
    state.phase = 'failed';
    state.errorMsg = '缺少同步下载实现';
    return;
  }
  await options.syncDownload();
  state.phase = 'done';
  state.progress = 100;
  state.finished = count.totalCount;
  state.message = `已导出 ${count.totalCount} å¼ `;
}
/** å¤§æ‰¹é‡ï¼šæäº¤åŽå°ä»»åŠ¡å¹¶è½®è¯¢è¿›åº¦ */
async function startAsync(
  params: MesProQrExportApi.ExportParams,
  count: MesProQrExportApi.ExportCount,
) {
  state.phase = 'generating';
  state.message = `共 ${count.totalCount} å¼ ï¼Œæ­£åœ¨åŽå°ç”Ÿæˆâ€¦`;
  const task = await submitQrExportTask(params);
  state.taskId = task.id;
  state.taskName = task.taskName ?? state.taskName;
  state.total = task.totalCount ?? count.totalCount;
  state.finished = task.finishedCount ?? 0;
  state.progress = task.progress ?? 0;
  pollTask();
}
function pollTask() {
  stopPolling();
  pollTimer = setInterval(() => {
    void refreshTask();
  }, 1000);
}
async function refreshTask() {
  if (!state.taskId) return;
  try {
    const task = await getQrExportTask(state.taskId);
    state.total = task.totalCount ?? 0;
    state.finished = task.finishedCount ?? 0;
    state.progress = task.progress ?? 0;
    state.taskName = task.taskName ?? state.taskName;
    if (task.status === 30) {
      // å¾…下载
      stopPolling();
      state.progress = 100;
      await downloadProduct(task);
      return;
    }
    if (task.status === 40) {
      stopPolling();
      state.phase = 'canceled';
      state.message = '导出已取消';
      return;
    }
    if (task.status === 50) {
      stopPolling();
      state.phase = 'failed';
      state.errorMsg = task.errorMsg ?? '导出失败';
      state.message = '导出失败';
      return;
    }
    state.phase = 'generating';
    state.message = `正在生成(${state.finished} / ${state.total})…`;
  } catch (error: any) {
    stopPolling();
    failWith(error);
  }
}
/** ä¸‹è½½äº§ç‰©ï¼šä¼˜å…ˆæµå¼å†™å…¥ç£ç›˜ï¼Œé¿å…æ•°ç™¾ MB çš„内容整块驻留内存 */
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;
  const controller = new AbortController();
  let canceled = false;
  let writable: any = null;
  downloadAbort = () => {
    canceled = true;
    controller.abort();
  };
  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 (useDisk) {
          await writable.write(value);
        } else {
          chunks.push(value);
        }
        loaded += value.length;
        state.downloadLoaded = loaded;
      }
    }
    if (canceled) {
      if (useDisk) await writable.abort();
      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} å¼ `;
  } catch (error: any) {
    if (canceled) return;
    failWith(error);
  } finally {
    downloadAbort = null;
    saveHandle = null;
  }
}
/** å–消:生成中取消任务,下载中中断下载 */
async function cancel() {
  if (state.phase === 'downloading') {
    downloadAbort?.();
    state.phase = 'canceled';
    state.message = '已取消下载(服务端文件会被自动清理)';
    return;
  }
  if (state.taskId && ['counting', 'generating'].includes(state.phase)) {
    try {
      await cancelQrExportTask(state.taskId);
    } catch {
      // å–消失败不阻断界面收尾:服务端任务的超时回收机制会兜底
    }
    stopPolling();
    state.phase = 'canceled';
    state.message = '已取消导出';
  }
}
/** æ”¶èµ·ä¸ºæµ®æ ‡ï¼Œä»»åŠ¡ç»§ç»­åœ¨åŽå°è·‘ */
function minimize() {
  state.minimized = true;
}
/** ä»Žæµ®æ ‡å±•开面板 */
function expand() {
  state.minimized = false;
}
/** å…³é—­é¢æ¿å¹¶æ¸…空状态(进行中需先取消) */
function close() {
  stopPolling();
  downloadAbort?.();
  downloadAbort = null;
  saveHandle = null;
  resetState();
}
function failWith(error: any) {
  state.phase = 'failed';
  state.errorMsg = error?.message ?? error?.msg ?? '导出失败';
  state.message = '导出失败';
}
export function useQrExport() {
  return {
    state,
    isActive,
    start,
    cancel,
    minimize,
    expand,
    close,
  };
}