云
2 天以前 77e4c3d0201a83cf41b73d7bd7a79255759575f4
fix(mdm): 修正计量单位类型选项和API文档

- 将计量单位类型从'48千克/100千克'更改为'48袋/100袋'
- 更新API文档中类型字段的注释说明
- 添加下载失败时的重试功能和重试按钮

perf(qr): 优化二维码导出进度面板交互体验

- 添加retryDownload方法用于重新尝试下载
- 在下载失败且可重试时显示重试按钮
- 改善进度面板的交互反馈机制
已修改6个文件
161 ■■■■■ 文件已修改
src/api/mdm/unit/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/bagCode/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/unit/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/modules/generate-form.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/qr-export-progress.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/qrexport/use-qr-export.ts 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/unit/index.ts
@@ -8,7 +8,7 @@
    id?: number; // 单位编号
    code: string; // 单位编码
    name: string; // 单位名称
    type?: string; // 类型(48千克/100千克/吨)
    type?: string; // 类型(48袋/100袋/吨)
    primaryFlag?: boolean; // 是否主单位
    primaryId?: number; // 主单位编号
    changeRate?: number; // 与主单位换算比例
src/api/mes/pro/bagCode/index.ts
@@ -41,17 +41,17 @@
    createTime?: string[];
  }
  /** 生成数据包请求(品种主计量单位类型为 N千克 时按托盘数换算袋数;为 吨 时无托盘、直接输入袋码数) */
  /** 生成数据包请求(品种主计量单位类型为 N袋 时按托盘数换算袋数;为 吨 时无托盘、直接输入袋码数) */
  export interface GenerateParams {
    itemId: number; // 品种(物料)编号
    palletCount?: number; // 托盘数(48千克/100千克 品种必填)
    palletCount?: number; // 托盘数(48袋/100袋 品种必填)
    bagCount?: number; // 袋码数(吨品种必填)
    remark?: string; // 备注
  }
  export interface GenerateResult {
    uuid?: string; // 生成组 UUID
    type?: string; // 品种主计量单位类型(48千克/100千克/吨)
    type?: string; // 品种主计量单位类型(48袋/100袋/吨)
    perPallet?: number; // 每托袋数(吨品种为 0)
    totalCount?: number; // 生成袋码总数
    palletIds?: number[]; // 自动生成的托盘编号列表(吨品种为空)
src/views/basicData/mdm/unit/data.ts
@@ -8,8 +8,8 @@
/** 计量单位类型选项 */
export const UNIT_TYPE_OPTIONS = [
  { label: '48千克', value: '48千克' },
  { label: '100千克', value: '100千克' },
  { label: '48袋', value: '48袋' },
  { label: '100袋', value: '100袋' },
  { label: '吨', value: '吨' },
];
@@ -152,4 +152,4 @@
      slots: { default: 'actions' },
    },
  ];
}
}
src/views/mes/pro/bagCode/modules/generate-form.vue
@@ -18,9 +18,9 @@
  const emit = defineEmits(["success"]);
  // 所选品种主计量单位类型:48千克/100千克 -> 每托 N 袋;吨 -> 无托盘直接输袋码数
  // 所选品种主计量单位类型:48袋/100袋 -> 每托 N 袋;吨 -> 无托盘直接输袋码数
  const unitType = ref("");
  const kgPerPallet = ref<number | null>(null);
  const bagsPerPallet = ref<number | null>(null);
  const isTon = ref(false);
  const unitTypeError = ref(false);
  const palletCountVal = ref<number | null>(null);
@@ -29,7 +29,7 @@
  /** 换算/配置提示文案 */
  const tipText = computed(() => {
    if (unitTypeError.value) {
      return "该品种主计量单位未配置类型(仅支持 48千克/100千克/吨),请先前往「计量单位」维护后重试";
      return "该品种主计量单位未配置类型(仅支持 48袋/100袋/吨),请先前往「计量单位」维护后重试";
    }
    if (!unitType.value) {
      return "";
@@ -39,7 +39,7 @@
        bagCountVal.value ? `,将生成 ${bagCountVal.value} 个袋码` : ""
      }`;
    }
    const per = kgPerPallet.value ?? 0;
    const per = bagsPerPallet.value ?? 0;
    const count = palletCountVal.value ?? 0;
    return `计量单位类型为「${unitType.value}」:每托 ${per} 袋${
      count > 0
@@ -61,7 +61,7 @@
        rules: "selectRequired",
      },
    ];
    if (kgPerPallet.value) {
    if (bagsPerPallet.value) {
      schema.push({
        fieldName: "palletCount",
        label: "托盘数",
@@ -110,7 +110,7 @@
  /** 选择品种后读取主计量单位类型,动态切换托盘数/袋码数输入 */
  async function handleItemChange(item?: MdmItemApi.Item) {
    unitType.value = "";
    kgPerPallet.value = null;
    bagsPerPallet.value = null;
    isTon.value = false;
    unitTypeError.value = false;
    palletCountVal.value = null;
@@ -123,9 +123,9 @@
        if (type === "吨") {
          isTon.value = true;
        } else {
          const matcher = /^(\d{1,5})千克$/.exec(type);
          const matcher = /^(\d{1,5})袋$/.exec(type);
          if (matcher) {
            kgPerPallet.value = Number(matcher[1]);
            bagsPerPallet.value = Number(matcher[1]);
          } else {
            unitTypeError.value = true;
          }
@@ -190,7 +190,7 @@
      modalApi.lock();
      try {
        unitType.value = "";
        kgPerPallet.value = null;
        bagsPerPallet.value = null;
        isTon.value = false;
        unitTypeError.value = false;
        palletCountVal.value = null;
src/views/mes/pro/qrexport/qr-export-progress.vue
@@ -7,7 +7,7 @@
  defineOptions({ name: 'QrExportProgress' });
  const { state, cancel, minimize, expand, close } = useQrExport();
  const { state, cancel, retryDownload, minimize, expand, close } = useQrExport();
  /** 面板是否展示(idle 表示没有任何任务) */
  const visible = computed(() => state.phase !== 'idle');
@@ -143,6 +143,14 @@
        {{ title }}
      </span>
      <div class="flex items-center gap-1">
        <Button
          v-if="state.phase === 'failed' && state.downloadRetryable"
          type="link"
          size="small"
          @click="retryDownload"
        >
          重试下载
        </Button>
        <!-- 进行中提供「隐藏」,任务转入后台继续,不会被中断 -->
        <Button
          v-if="state.phase !== 'done' && state.phase !== 'failed' && state.phase !== 'canceled'"
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,