| src/api/mdm/unit/index.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/api/mes/pro/bagCode/index.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/basicData/mdm/unit/data.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/bagCode/modules/generate-form.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/qrexport/qr-export-progress.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/mes/pro/qrexport/use-qr-export.ts | ●●●●● 补丁 | 查看 | 原始文档 | 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: '吨' }, ]; 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,6 +315,27 @@ }; 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}` } : {}, signal: controller.signal, @@ -280,25 +344,12 @@ throw new Error(`下载失败(HTTP ${response.status})`); } const contentLength = Number(response.headers.get('content-length') ?? 0); if (contentLength > 0) { state.downloadTotal = contentLength; } 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 (!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,