diff --git a/src/api/interface-trackOrder/trackOrder.ts b/src/api/interface-trackOrder/trackOrder.ts index 260877a..bdc38d4 100644 --- a/src/api/interface-trackOrder/trackOrder.ts +++ b/src/api/interface-trackOrder/trackOrder.ts @@ -68,14 +68,39 @@ export async function trackOrderGetOrderOperationsByIdGet( ); } -/** 获取订单类型字典 GET /Track/Order/GetOrderTypeDict */ +/** + * 获取订单类型字典。 + * 轨迹后端无 /Track/Order/GetOrderTypeDict(404),统一走 Dictionary/GetList。 + * 返回形态兼容旧调用方:`{ data: Record }`。 + */ export async function trackOrderGetOrderTypeDictGet(options?: { [key: string]: any; }) { - return request>(`trackApi/Track/Order/GetOrderTypeDict`, { - method: 'GET', - ...(options || {}), - }); + const list = await request>( + `trackApi/Dictionary/GetList`, + { + method: 'GET', + params: { + type: 'TrackOrderType', + }, + ...(options || {}), + }, + ); + + if (Array.isArray(list)) { + const dict: Record = {}; + for (const item of list) { + if (item?.key == null) continue; + dict[String(item.key)] = String(item.value ?? ''); + } + return { data: dict }; + } + + if (list && typeof list === 'object' && (list as any).data != null) { + return list as { data: Record }; + } + + return { data: (list as Record) || {} }; } /** 获取分页的轨迹订单 POST /Track/Order/GetTrackOrderList */ diff --git a/src/components/HandsontablePro/index.vue b/src/components/HandsontablePro/index.vue index 9730fcf..e9af2b3 100644 --- a/src/components/HandsontablePro/index.vue +++ b/src/components/HandsontablePro/index.vue @@ -219,6 +219,8 @@ /** 编辑写回源数据时跳过 data watch,避免打断输入 */ let ignoreDataWatch = false; + /** loadData / 程序化选区期间跳过 selection 回写,避免清空业务页已选 */ + let selectionLock = 0; /** 表头初始化/销毁期间不向业务页 emit search,避免首屏重复拉接口 */ let headerSearchEmitReady = false; let headerSearchReadyTimer: ReturnType | null = null; @@ -620,6 +622,54 @@ return props.data[rowIndex]; } + function publishSelection(rows: any[], indices: Set) { + selectedRows.value = rows; + selectedRowIndicesSet.value = indices; + emit('update:selectedRows', rows); + emit('selection-change', rows); + applySelectedRowStyles(); + } + + /** 复选框列为批量操作真相源:按 checked 汇总选中行 */ + function syncSelectionFromCheckboxes() { + if (!props.showCheckboxColumn) return; + const visualCount = + displayRowIndices.value.length > 0 ? displayRowIndices.value.length : props.data.length; + const indices = new Set(); + const rows: any[] = []; + for (let visual = 0; visual < visualCount; visual++) { + const row = rowAt(visual); + if (row && (row as any).checked) { + indices.add(visual); + rows.push(row); + } + } + publishSelection(rows, indices); + } + + function shouldKeepSelectionOnOutsideClick(event: MouseEvent): boolean { + const target = event?.target as HTMLElement | null; + if (!target) return false; + // 点工具栏/分页/下拉/弹层时保留选中,避免「选中后点立即支付已清空」 + return !!target.closest( + [ + '.table-toolbar', + '.actions-right', + '.toolbar-right', + '.toolbar-left', + '.arco-btn', + '.arco-dropdown', + '.arco-dropdown-option', + '.arco-trigger-popup', + '.arco-pagination', + '.arco-modal', + '.arco-drawer', + '.arco-select-dropdown', + '.column-config-icon-btn', + ].join(','), + ); + } + function buildRowDataArray(row: any, visualIndex: number): any[] { const rowData: any[] = []; // 固定:操作 → 序号 → 复选框 → 数据 @@ -1343,6 +1393,7 @@ if (!changes || source === 'loadData') return; const { checkboxColIndex, dataColStart } = getLayoutIndices(); ignoreDataWatch = true; + let checkboxChanged = false; try { changes.forEach(([row, col, oldValue, newValue]: any[]) => { const rowData = rowAt(row); @@ -1350,6 +1401,7 @@ if (col === checkboxColIndex) { (rowData as any).checked = newValue || false; + checkboxChanged = true; emit('checkbox-change', rowData, newValue || false, row); return; } @@ -1375,6 +1427,9 @@ oldValue, }); }); + if (checkboxChanged) { + syncSelectionFromCheckboxes(); + } } finally { // 可编辑模式:值已写入源对象且 HOT 自身已更新,禁止 loadData, // 否则会打断连续编辑 / 关掉下拉。仅在 nextTick 后恢复 data watch。 @@ -1398,6 +1453,11 @@ colHeaders: headerTexts, rowHeaders: false, selectionMode: 'multiple', + // 点工具栏/分页时保留选中,避免批量按钮拿到空选区 + outsideClickDeselects(event: MouseEvent) { + if (props.showCheckboxColumn) return false; + return !shouldKeepSelectionOnOutsideClick(event); + }, width: '100%', height: tableHeight, licenseKey: 'non-commercial-and-evaluation', @@ -1780,31 +1840,30 @@ return cellProperties; }, afterSelectionEnd: function(r: number, c: number, r2: number, c2: number) { - if (!hotInstance) return; + if (!hotInstance || selectionLock) return; - const selected = hotInstance.getSelected() || []; - const selectedRowIndices = new Set(); + // 开启复选框时,批量选中以 checked 为准,避免单元格选区覆盖 + if (props.showCheckboxColumn) { + applySelectedRowStyles(); + } else { + const selected = hotInstance.getSelected() || []; + const selectedRowIndices = new Set(); - selected.forEach(([rowStart, colStart, rowEnd, colEnd]: number[]) => { - const startRow = Math.min(rowStart, rowEnd); - const endRow = Math.max(rowStart, rowEnd); - for (let i = startRow; i <= endRow; i++) { - if (i >= 0 && i < props.data.length) { - selectedRowIndices.add(i); + selected.forEach(([rowStart, _colStart, rowEnd, _colEnd]: number[]) => { + const startRow = Math.min(rowStart, rowEnd); + const endRow = Math.max(rowStart, rowEnd); + for (let i = startRow; i <= endRow; i++) { + if (i >= 0 && i < props.data.length) { + selectedRowIndices.add(i); + } } - } - }); + }); - selectedRowIndicesSet.value = selectedRowIndices; - selectedRows.value = Array.from(selectedRowIndices) - .map(index => rowAt(index)) - .filter(Boolean); - - emit('update:selectedRows', selectedRows.value); - emit('selection-change', selectedRows.value); - - // 应用选中行样式 - applySelectedRowStyles(); + publishSelection( + Array.from(selectedRowIndices).map((index) => rowAt(index)).filter(Boolean), + selectedRowIndices, + ); + } // 可编辑模式:单击单元格即进入编辑(表单录入体验) if ( @@ -1865,7 +1924,7 @@ afterOnCellMouseDown: function(event: any, coords: any, td: any) { const { actionColIndex, serialColIndex, checkboxColIndex, dataColStart } = getLayoutIndices(); - // 复选框列 + // 复选框列(自定义 DOM,不走内置 editor) if (coords.col === checkboxColIndex) { const target = event.target; if (target.type === 'checkbox') { @@ -1873,8 +1932,16 @@ if (rowIndex >= 0 && rowIndex < props.data.length) { const row = rowAt(rowIndex); const checked = target.checked; - (row as any).checked = checked; - emit('checkbox-change', row, checked, rowIndex); + ignoreDataWatch = true; + try { + (row as any).checked = checked; + syncSelectionFromCheckboxes(); + emit('checkbox-change', row, checked, rowIndex); + } finally { + nextTick(() => { + ignoreDataWatch = false; + }); + } } } } @@ -2290,8 +2357,14 @@ } if (!props.data.length) { displayRowIndices.value = []; - hotInstance.loadData([]); - hotInstance.render(); + selectionLock += 1; + try { + hotInstance.loadData([]); + hotInstance.render(); + publishSelection([], new Set()); + } finally { + selectionLock -= 1; + } return; } @@ -2310,9 +2383,19 @@ } const data = syncDisplayRows(); - - hotInstance.loadData(data); - hotInstance.render(); + selectionLock += 1; + try { + hotInstance.loadData(data); + hotInstance.render(); + // loadData 会清掉 HOT 单元格选区;复选框模式按 checked 回写业务选中 + if (props.showCheckboxColumn) { + syncSelectionFromCheckboxes(); + } else { + applySelectedRowStyles(); + } + } finally { + selectionLock -= 1; + } // 刷新尺寸以确保 Handsontable 内置滚动视口(.wtHolder)尺寸正确 setTimeout(() => { @@ -2642,10 +2725,26 @@ refresh: updateData, getSelectedRows: () => selectedRows.value, clearSelection: () => { - if (hotInstance) { - hotInstance.deselectCell(); - selectedRows.value = []; - selectedRowIndicesSet.value = new Set(); + selectionLock += 1; + try { + if (hotInstance) { + hotInstance.deselectCell(); + } + if (props.showCheckboxColumn) { + ignoreDataWatch = true; + try { + props.data.forEach((row) => { + if (row) (row as any).checked = false; + }); + } finally { + nextTick(() => { + ignoreDataWatch = false; + }); + } + } + publishSelection([], new Set()); + } finally { + selectionLock -= 1; } }, }); diff --git a/src/utils/printPreview.ts b/src/utils/printPreview.ts index 46a1fa1..c1af762 100644 --- a/src/utils/printPreview.ts +++ b/src/utils/printPreview.ts @@ -149,17 +149,81 @@ async function buildPrintStreamKeyCandidates(rawUrl: string): Promise return keys } -function stripUtf8Bom(bytes: Uint8Array): Uint8Array { +/** 去掉 BOM / 前导空白,避免面单生成器偶发换行导致 magic 校验失败 */ +function stripLeadingNoise(bytes: Uint8Array): Uint8Array { + let start = 0 if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { - return bytes.slice(3) + start = 3 } - return bytes + while (start < bytes.length) { + const b = bytes[start] + if (b === 0x09 || b === 0x0a || b === 0x0d || b === 0x20) { + start += 1 + continue + } + break + } + return start === 0 ? bytes : bytes.slice(start) } function bytesStartWithPdfMagic(bytes: Uint8Array): boolean { - const head = bytes.slice(0, 5) - if (head.length < 5) return false - return String.fromCharCode(...head) === PDF_MAGIC + // 标准 PDF 头为 "%PDF-1.x";此前误用 5 字节与 4 字符 "%PDF" 比较,合法面单也会被拒 + if (bytes.length < PDF_MAGIC.length) return false + const head = String.fromCharCode(...bytes.slice(0, PDF_MAGIC.length)) + return head === PDF_MAGIC +} + +function sniffMimeFromBytes(bytes: Uint8Array): string | null { + if (bytesStartWithPdfMagic(bytes)) return 'application/pdf' + if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { + return 'image/png' + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + if ( + bytes.length >= 6 && + bytes[0] === 0x47 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x38 + ) { + return 'image/gif' + } + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return 'image/webp' + } + const sample = new TextDecoder('utf-8', { fatal: false }).decode(bytes.slice(0, 64)).trimStart() + if (/^]/i.test(sample)) { + return 'text/html; charset=utf-8' + } + return null +} + +/** Base64 文本面单(常见于下游回传):JVBERi… → %PDF… */ +function tryDecodeBase64Pdf(bytes: Uint8Array): Uint8Array | null { + const sample = new TextDecoder('utf-8', { fatal: false }).decode(bytes.slice(0, 32)).trim() + if (!sample.startsWith('JVBERi')) return null + try { + const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes).trim() + const pure = text.replace(/^data:application\/pdf;base64,/i, '').replace(/\s+/g, '') + const bin = atob(pure) + const out = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i) + return bytesStartWithPdfMagic(out) ? out : null + } catch { + return null + } } async function throwIfBytesLookLikeApiError(bytes: Uint8Array) { @@ -182,20 +246,47 @@ async function throwIfBytesLookLikeApiError(bytes: Uint8Array) { } } -/** 校验并规范化 PDF 字节(拒绝 JSON/HTML 错误体) */ -export async function normalizePdfBytes(blob: Blob, rawUrl: string): Promise { - if (!isPrintPreviewPdf(rawUrl, blob)) { - return new Uint8Array(await blob.arrayBuffer()) +type NormalizedPreviewBytes = { bytes: Uint8Array; mime: string } + +/** 规范化预览字节:按内容嗅探 MIME;扩展名/响应头标成 PDF 时再严格校验 */ +async function normalizePreviewBytes(blob: Blob, rawUrl: string): Promise { + let bytes = stripLeadingNoise(new Uint8Array(await blob.arrayBuffer())) + const decoded = tryDecodeBase64Pdf(bytes) + if (decoded) bytes = decoded + + const sniffed = sniffMimeFromBytes(bytes) + if (sniffed) { + return { bytes, mime: sniffed } } - let bytes = stripUtf8Bom(new Uint8Array(await blob.arrayBuffer())) - if (!bytesStartWithPdfMagic(bytes)) { - await throwIfBytesLookLikeApiError(bytes) + await throwIfBytesLookLikeApiError(bytes) + + if (isPrintPreviewPdf(rawUrl, blob)) { throw new Error('面单文件不是有效的 PDF') } + + const fallback = + (blob.type && !/octet-stream|force-download/i.test(blob.type) && blob.type) || + guessMime(rawUrl) || + 'application/octet-stream' + return { bytes, mime: fallback } +} + +/** 校验并规范化 PDF 字节(拒绝 JSON/HTML 错误体;图片/HTML 面单原样返回) */ +export async function normalizePdfBytes(blob: Blob, rawUrl: string): Promise { + const { bytes, mime } = await normalizePreviewBytes(blob, rawUrl) + if (mime.includes('pdf')) { + if (!bytesStartWithPdfMagic(bytes)) { + throw new Error('面单文件不是有效的 PDF') + } + } return bytes } +function blobFromNormalized(normalized: NormalizedPreviewBytes): Blob { + return new Blob([normalized.bytes], { type: normalized.mime }) +} + async function fetchBlobViaSameOriginPath(path: string): Promise { const res = await axiosInstance.get(path, { responseType: 'blob', @@ -232,14 +323,8 @@ async function fetchBlobViaCosProxy(rawUrl: string): Promise { throw new Error(`COS 代理下载失败 HTTP ${res.status}`) } const mime = String(res.headers.get('content-type') || guessMime(rawUrl)) - const bytes = stripUtf8Bom(new Uint8Array(await res.arrayBuffer())) - await throwIfBytesLookLikeApiError(bytes) - if (/\.pdf(\?|#|$)/i.test(rawUrl) && !bytesStartWithPdfMagic(bytes)) { - throw new Error('面单文件不是有效的 PDF') - } - return new Blob([bytes], { - type: /\.pdf(\?|#|$)/i.test(rawUrl) ? 'application/pdf' : mime, - }) + const rawBlob = new Blob([await res.arrayBuffer()], { type: mime }) + return blobFromNormalized(await normalizePreviewBytes(rawBlob, rawUrl)) } finally { window.clearTimeout(timer) } @@ -252,10 +337,7 @@ async function fetchViaFileStorageStream(rawUrl: string, errors: string[]): Prom const blob = await streamFileBlob(key) const mime = guessMime(key) const typed = blob.type ? blob : new Blob([blob], { type: mime }) - const bytes = await normalizePdfBytes(typed, rawUrl) - return new Blob([bytes], { - type: isPrintPreviewPdf(rawUrl, typed) ? 'application/pdf' : mime, - }) + return blobFromNormalized(await normalizePreviewBytes(typed, rawUrl)) } catch (e: unknown) { const msg = extractOmsRequestError(e) if (msg) errors.push(msg) @@ -274,11 +356,8 @@ async function fetchPreviewBlob(rawUrl: string): Promise { // 开发:测试 Admin 可能尚未部署 /file-storage,先走同源 COS 代理 if (import.meta.env.DEV && isCos) { try { - const blob = await fetchBlobViaCosProxy(raw) - const bytes = await normalizePdfBytes(blob, raw) - return new Blob([bytes], { - type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw), - }) + // fetchBlobViaCosProxy 内已按内容规范化 MIME + return await fetchBlobViaCosProxy(raw) } catch (e: unknown) { errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败') } @@ -291,11 +370,7 @@ async function fetchPreviewBlob(rawUrl: string): Promise { // 同源 /__cos__ 代理(生产 nginx;开发上面已试过则跳过) if (isCos && !import.meta.env.DEV) { try { - const blob = await fetchBlobViaCosProxy(raw) - const bytes = await normalizePdfBytes(blob, raw) - return new Blob([bytes], { - type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw), - }) + return await fetchBlobViaCosProxy(raw) } catch (e: unknown) { errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败') } @@ -306,10 +381,7 @@ async function fetchPreviewBlob(rawUrl: string): Promise { if (sameOriginPath.startsWith('/')) { try { const blob = await fetchBlobViaSameOriginPath(sameOriginPath) - const bytes = await normalizePdfBytes(blob, raw) - return new Blob([bytes], { - type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw), - }) + return blobFromNormalized(await normalizePreviewBytes(blob, raw)) } catch (e: unknown) { const msg = extractOmsRequestError(e) if (msg) errors.push(msg) @@ -351,6 +423,8 @@ export async function resolvePrintPreviewSrc(rawUrl: string): Promise { export function isPrintPreviewPdf(rawUrl: string, blob?: Blob | null): boolean { const mime = String(blob?.type || '').toLowerCase() if (mime.includes('pdf')) return true + // 已按内容嗅探为图片/HTML 时,即使 URL 带 .pdf 也不再当 PDF + if (mime.startsWith('image/') || mime.includes('html')) return false return /\.pdf(\?|#|$)/i.test(String(rawUrl || '')) } diff --git a/src/views/channel/salesProducts/index.vue b/src/views/channel/salesProducts/index.vue index 0547ff8..54418e8 100644 --- a/src/views/channel/salesProducts/index.vue +++ b/src/views/channel/salesProducts/index.vue @@ -1152,6 +1152,16 @@ const id = Number(key.slice(5)); const product = filteredProductList.value.find((p: any) => p.id === id); if (!product) return; + // 已选中且卖价已加载:忽略重复 select(树/受控 selectedKeys 偶发连发) + if ( + selectedProduct.value === id + && activeTab.value === 'price' + && priceTabLoadedProductId === id + && !configFormDirty.value + && !rulesFormDirty.value + ) { + return; + } const ok = await confirmDiscardUnsavedChanges(); if (!ok) return; await handleSelectProduct(product); @@ -1180,65 +1190,80 @@ let priceTabLoadSeq = 0; /** 当前 priceTableData 所属产品;切走再回来时直接复用,不再空表重拉 */ let priceTabLoadedProductId: number | null = null; - /** 已做过发布态对比的列表签名,避免每次切 tab 都解析整份卖价快照 */ - let pricePublishCheckedSig = ''; + /** 同产品进行中的列表请求复用,避免树 select 连发打两次 price/list */ + let priceTabInflight: { productId: number; promise: Promise } | null = null; const priceListLoading = ref(false); - async function loadPriceTabData(productId: number) { + async function loadPriceTabData(productId: number, opts?: { force?: boolean }) { + if (priceTabInflight?.productId === productId) { + await priceTabInflight.promise; + return; + } + // 已加载过该产品(含空列表)且非强制刷新:跳过,避免重复 price/list + if (!opts?.force && priceTabLoadedProductId === productId) { + return; + } + const seq = ++priceTabLoadSeq; const showSpinner = priceTabLoadedProductId !== productId || priceTableData.value.length === 0; if (showSpinner) priceListLoading.value = true; - try { - // 列表不拉 details,也不等发布记录。生效版本在列表渲染后单独补。 - const prices = await getSalesProductPriceList(productId, { includeDetails: false }); - // 快速切换产品时丢弃过期响应 - if (seq !== priceTabLoadSeq || selectedProduct.value !== productId) return; - // 映射前确保字典就绪,计费因素列显示中文名而非 code - if (!salesPriceDictFetched.value) { - await ensureSalesPriceDictLoaded(); + + const run = (async () => { + try { + // 列表不拉 details,也不等发布记录。生效版本在列表渲染后单独补。 + const prices = await getSalesProductPriceList(productId, { includeDetails: false }); + // 快速切换产品时丢弃过期响应 if (seq !== priceTabLoadSeq || selectedProduct.value !== productId) return; - } - // factors 仍可能较多:markRaw 禁止 Vue 深度代理 - const rows = (prices || []).map((it: any) => { - const factors = Array.isArray(it.factors) ? it.factors : []; - const audit = pickResolvedAudit(it); - return { - id: it.id, - serialNumber: it.serialNumber, - status: isEnableStatusEnabled(it.status ?? it.Status), - priceName: it.priceName, - costItem: it.costItem, - costUnit: it.costUnit, - currency: it.currency, - chargingCondition: it.chargingCondition || '', - billingFactors: factors - .map((f: any) => pricingFactorColumnLabel(String(f?.factor ?? f?.name ?? ''))) - .filter(Boolean) - .join('+'), - factors: markRaw(factors), - details: null as any[] | null, - modifyTime: audit.updateTime, - modifier: audit.updateBy, - }; - }); - priceTableData.value = rows; - priceTabLoadedProductId = productId; - void refreshSalesProductPriceEffectiveInfo(productId); - // 发布同步态必须每次刷新:不能跟价格行 modifyTime 签名绑死,否则刚发布后签名不变会跳过刷新、徽章一直「待发布」 - void refreshPricePublishSyncStatus(productId).then(() => { - if (seq === priceTabLoadSeq && selectedProduct.value === productId) { - pricePublishCheckedSig = `${productId}|${rows - .map((r) => `${r.id}:${r.modifyTime}:${r.status ? 1 : 0}`) - .join(',')}`; + // 映射前确保字典就绪,计费因素列显示中文名而非 code + if (!salesPriceDictFetched.value) { + await ensureSalesPriceDictLoaded(); + if (seq !== priceTabLoadSeq || selectedProduct.value !== productId) return; } - }); - } catch (e) { - if (seq !== priceTabLoadSeq) return; - console.error(e); - effectiveVersion.value = '--'; - effectiveTime.value = '--'; + // factors 仍可能较多:markRaw 禁止 Vue 深度代理 + const rows = (prices || []).map((it: any) => { + const factors = Array.isArray(it.factors) ? it.factors : []; + const audit = pickResolvedAudit(it); + return { + id: it.id, + serialNumber: it.serialNumber, + status: isEnableStatusEnabled(it.status ?? it.Status), + priceName: it.priceName, + costItem: it.costItem, + costUnit: it.costUnit, + currency: it.currency, + chargingCondition: it.chargingCondition || '', + billingFactors: factors + .map((f: any) => pricingFactorColumnLabel(String(f?.factor ?? f?.name ?? ''))) + .filter(Boolean) + .join('+'), + factors: markRaw(factors), + details: null as any[] | null, + modifyTime: audit.updateTime, + modifier: audit.updateBy, + }; + }); + priceTableData.value = rows; + priceTabLoadedProductId = productId; + void refreshSalesProductPriceEffectiveInfo(productId); + // 徽章只听后端 sync-status,不在前端算差异 + void refreshPricePublishSyncStatus(productId); + } catch (e) { + if (seq !== priceTabLoadSeq) return; + console.error(e); + effectiveVersion.value = '--'; + effectiveTime.value = '--'; + } finally { + if (seq === priceTabLoadSeq) priceListLoading.value = false; + } + })(); + + priceTabInflight = { productId, promise: run }; + try { + await run; } finally { - if (seq === priceTabLoadSeq) priceListLoading.value = false; + if (priceTabInflight?.productId === productId && priceTabInflight.promise === run) { + priceTabInflight = null; + } } } @@ -1404,9 +1429,10 @@ return detail?.content ?? detail?.Content ?? null; } + /** 仅展示后端 sync-status.pending,前端不做快照 diff */ async function refreshPricePublishSyncStatus(productId?: number) { const pid = productId ?? selectedProduct.value; - if (pid == null || !priceTableData.value.length) { + if (pid == null) { pricePublishChecking.value = false; pricePublishPending.value = null; return; @@ -1419,7 +1445,8 @@ operatorType: 'price', }); if (seq !== priceTabLoadSeq || selectedProduct.value !== pid) return; - pricePublishPending.value = !!(status?.pending ?? status?.Pending); + const pending = status?.pending ?? status?.Pending; + pricePublishPending.value = typeof pending === 'boolean' ? pending : null; } catch { if (seq === priceTabLoadSeq) pricePublishPending.value = null; } finally { @@ -1561,6 +1588,20 @@ const handleSelectProduct = async (product: any) => { const productChanged = selectedProduct.value !== product.id; + // 同产品重复选中且当前 tab 数据已在:不再打 detail / price/list + if ( + !productChanged + && product?.id + && ( + (activeTab.value === 'price' && priceTabLoadedProductId === product.id) + || (activeTab.value === 'rules' && rulesTabLoadedProductId === product.id) + || (activeTab.value === 'records' && recordsTabLoadedProductId === product.id) + || activeTab.value === 'config' + || activeTab.value === 'customerGuide' + ) + ) { + return; + } selectedProduct.value = product.id; configPublishPending.value = false; rulesPublishPending.value = false; @@ -1569,12 +1610,12 @@ if (productChanged && activeTab.value !== 'price') { priceTableData.value = []; priceTabLoadedProductId = null; - pricePublishCheckedSig = ''; pricePublishPending.value = null; pricePublishChecking.value = false; effectiveVersion.value = '--'; effectiveTime.value = '--'; } else if (activeTab.value === 'price') { + // 切到卖价 tab / 换产品:先清空徽章,等 sync-status 回来再显示 pricePublishPending.value = null; pricePublishChecking.value = false; } @@ -2272,9 +2313,9 @@ publishModalMethods.closeModal(); await loadRecordsTabData(pid, true); if (operatorType === 'price') { - pricePublishCheckedSig = ''; - pricePublishPending.value = false; - await loadPriceTabData(pid); + // 不乐观改徽章;等后端 sync-status 重算 + pricePublishPending.value = null; + await loadPriceTabData(pid, { force: true }); await refreshPricePublishSyncStatus(pid); } } catch (e: any) { @@ -3576,7 +3617,7 @@ }; } else { // 兼容后端不返回 id:用唯一组合定位刚新增记录,切成编辑态避免下次重复 create - await loadPriceTabData(pid); + await loadPriceTabData(pid, { force: true }); const latest = [...priceTableData.value] .reverse() .find((row: any) => row.serialNumber === payload.serialNumber && row.costItem === payload.costItem); @@ -3606,7 +3647,7 @@ try { const msg = await submitPriceConfig(pid); Message.success(msg); - await loadPriceTabData(pid); + await loadPriceTabData(pid, { force: true }); priceModalVisible.value = false; } catch (e: any) { Message.error(e?.message || '保存失败'); @@ -4169,7 +4210,7 @@ deletePriceModalVisible.value = false; deletingPriceRecord.value = null; deletePriceInput.value = ''; - if (productId != null) await loadPriceTabData(productId); + if (productId != null) await loadPriceTabData(productId, { force: true }); } catch (e: any) { Message.error(e?.message || '删除失败'); } @@ -4188,7 +4229,7 @@ if (pid == null || record?.id == null) return try { await toggleSalesProductPriceStatus(record.id, toEnableStatus(!!record.status)) - await loadPriceTabData(pid) + await loadPriceTabData(pid, { force: true }) } catch (e: any) { record.status = !record.status Message.error(e?.message || '更新失败') diff --git a/src/views/channel/serviceChannels/index.vue b/src/views/channel/serviceChannels/index.vue index fa1a2b3..1420450 100644 --- a/src/views/channel/serviceChannels/index.vue +++ b/src/views/channel/serviceChannels/index.vue @@ -4106,9 +4106,10 @@ const publishDiffStatus = computed(() => { return 'changed' }) +/** 仅展示后端 sync-status.pending,前端不做快照 diff */ async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) { const cid = serviceChannelId ?? (selectedChannel.value as any)?.id - if (cid == null || !priceTableData.value.length) { + if (cid == null) { pricePublishChecking.value = false pricePublishPending.value = null return @@ -4119,7 +4120,8 @@ async function refreshPricePublishSyncStatus(serviceChannelId?: number | string) serviceChannelId: cid, operatorType: 'price', }) - pricePublishPending.value = !!(status?.pending ?? status?.Pending) + const pending = status?.pending ?? status?.Pending + pricePublishPending.value = typeof pending === 'boolean' ? pending : null } catch { pricePublishPending.value = null } finally { diff --git a/src/views/h5/customer-center/components/H5CouponsSheet.vue b/src/views/h5/customer-center/components/H5CouponsSheet.vue index 1bd8f8f..57f31bd 100644 --- a/src/views/h5/customer-center/components/H5CouponsSheet.vue +++ b/src/views/h5/customer-center/components/H5CouponsSheet.vue @@ -83,7 +83,11 @@

- {{ t('h5.coupons.minOrder', { amount: formatAmount(item.minOrderAmount) }) }} + {{ + Number(item.minOrderAmount) > 0 + ? t('h5.coupons.minOrder', { amount: formatAmount(item.minOrderAmount) }) + : t('h5.coupons.noThreshold') + }}

diff --git a/src/views/h5/customer-center/components/H5PickupOrdersSheet.vue b/src/views/h5/customer-center/components/H5PickupOrdersSheet.vue index c7a988d..578dc0f 100644 --- a/src/views/h5/customer-center/components/H5PickupOrdersSheet.vue +++ b/src/views/h5/customer-center/components/H5PickupOrdersSheet.vue @@ -453,6 +453,7 @@ defineExpose({ openWithDetail, reload: load }) } .h5-pklist__handle { + flex-shrink: 0; width: 36px; height: 4px; margin: 10px auto 0; @@ -461,6 +462,7 @@ defineExpose({ openWithDetail, reload: load }) } .h5-pklist__head { + flex-shrink: 0; display: flex; align-items: flex-start; justify-content: space-between; @@ -496,10 +498,14 @@ defineExpose({ openWithDetail, reload: load }) } .h5-pklist__filters { + flex-shrink: 0; + z-index: 2; display: flex; + align-items: center; gap: 8px; overflow-x: auto; - padding: 12px 16px; + padding: 12px 16px 10px; + background: #f5f6f8; -webkit-overflow-scrolling: touch; &::-webkit-scrollbar { @@ -526,10 +532,17 @@ defineExpose({ openWithDetail, reload: load }) } .h5-pklist__body { - flex: 1; - min-height: 220px; + flex: 1 1 auto; + min-height: 0; + width: 100%; + display: block; overflow: auto; - padding: 0 16px 16px; + padding: 4px 16px 16px; + -webkit-overflow-scrolling: touch; + + :deep(.arco-spin-children) { + min-height: 160px; + } } .h5-pklist-card { diff --git a/src/views/h5/customer-center/locale/en-US.ts b/src/views/h5/customer-center/locale/en-US.ts index 698cdac..bd9b4ef 100644 --- a/src/views/h5/customer-center/locale/en-US.ts +++ b/src/views/h5/customer-center/locale/en-US.ts @@ -837,6 +837,7 @@ export default { 'h5.coupons.tab.used': 'Used', 'h5.coupons.tab.expired': 'Expired', 'h5.coupons.minOrder': 'Min order {amount}', + 'h5.coupons.noThreshold': 'No minimum', 'h5.coupons.use': 'Use now', 'h5.coupons.discountUnit': ' off', 'h5.coupons.expiringSoon': 'Expiring soon', diff --git a/src/views/h5/customer-center/locale/zh-CN.ts b/src/views/h5/customer-center/locale/zh-CN.ts index b4c2ef4..f713dba 100644 --- a/src/views/h5/customer-center/locale/zh-CN.ts +++ b/src/views/h5/customer-center/locale/zh-CN.ts @@ -820,6 +820,7 @@ export default { 'h5.coupons.unnamed': '优惠券', 'h5.coupons.use': '去使用', 'h5.coupons.minOrder': '满{amount}可用', + 'h5.coupons.noThreshold': '无门槛', 'h5.coupons.discountUnit': '折', 'h5.coupons.expiringSoon': '即将过期', 'h5.coupons.badge.newcomer': '新人', diff --git a/src/views/order/components/OrderAttachmentsTab.vue b/src/views/order/components/OrderAttachmentsTab.vue index ce90f61..c534d3a 100644 --- a/src/views/order/components/OrderAttachmentsTab.vue +++ b/src/views/order/components/OrderAttachmentsTab.vue @@ -53,7 +53,6 @@ {{ $t('order.myOrders.drawer.attachments.preview') }} @@ -93,7 +92,6 @@ import { type OrderFileItem, } from '@/api/order'; import { resolveOmsAssetUrl } from '@/api/oms/common'; -import { fetchPrintPreviewBlob } from '@/utils/printPreview'; const FILE_TYPE_DEFS = [ { @@ -154,7 +152,6 @@ const { t } = useI18n(); const loading = ref(false); const uploadingType = ref(null); const deletingId = ref(null); -const previewingId = ref(null); const groups = ref>({}); const canUpload = computed(() => !props.readOnly && !props.customerView); @@ -169,51 +166,26 @@ const visibleSections = computed(() => { })); }); -function resolveUrl(path?: string) { - return resolveOmsAssetUrl(path); -} - -function guessAttachMime(file: OrderFileItem, url: string): string { - const ct = String(file.contentType || '').trim().toLowerCase(); - if (ct && !ct.includes('octet-stream') && !ct.includes('force-download')) { - return ct; +/** 与打印弹窗「查看面单」一致:同步打开可访问 URL(勿先 await 再开 blob,Chrome 会当搜索词) */ +function toAbsoluteUrl(path?: string) { + const resolved = resolveOmsAssetUrl(path) || String(path || '').trim(); + if (!resolved) return ''; + if (/^https?:\/\//i.test(resolved) || resolved.startsWith('blob:') || resolved.startsWith('data:')) { + return resolved; } - const hint = `${file.fileName || ''} ${url}`; - if (/\.pdf(\?|#|$)/i.test(hint)) return 'application/pdf'; - if (/\.png(\?|#|$)/i.test(hint)) return 'image/png'; - if (/\.jpe?g(\?|#|$)/i.test(hint)) return 'image/jpeg'; - if (/\.webp(\?|#|$)/i.test(hint)) return 'image/webp'; - if (/\.gif(\?|#|$)/i.test(hint)) return 'image/gif'; - if (/\.bmp(\?|#|$)/i.test(hint)) return 'image/bmp'; - return ct || 'application/octet-stream'; + if (resolved.startsWith('/')) return `${window.location.origin}${resolved}`; + return `${window.location.origin}/${resolved}`; } -/** 拉取为带正确 MIME 的 blob 再打开,走浏览器内置预览,避免 Content-Disposition 触发下载 */ -async function openFile(file: OrderFileItem) { - const raw = resolveUrl(file.fileUrl); - if (!raw) return; - if (previewingId.value === file.id) return; - - previewingId.value = file.id; - try { - const blob = await fetchPrintPreviewBlob(raw); - const mime = guessAttachMime(file, raw); - const typed = - blob.type && !/octet-stream|force-download/i.test(blob.type) - ? blob - : new Blob([blob], { type: mime }); - const blobUrl = URL.createObjectURL(typed); - const win = window.open(blobUrl, '_blank'); - if (!win) { - Message.warning(t('order.myOrders.drawer.attachments.previewBlocked')); - URL.revokeObjectURL(blobUrl); - return; - } - window.setTimeout(() => URL.revokeObjectURL(blobUrl), 120_000); - } catch (e: any) { - Message.error(e?.message || t('order.myOrders.drawer.attachments.previewFailed')); - } finally { - previewingId.value = null; +function openFile(file: OrderFileItem) { + const url = toAbsoluteUrl(file.fileUrl); + if (!url) { + Message.warning(t('order.myOrders.drawer.attachments.previewFailed')); + return; + } + const win = window.open(url, '_blank', 'noopener,noreferrer'); + if (!win) { + Message.warning(t('order.myOrders.drawer.attachments.previewBlocked')); } } diff --git a/src/views/order/myOrders/components/MarkClaimedModal.vue b/src/views/order/myOrders/components/MarkClaimedModal.vue index 9082384..752e1e2 100644 --- a/src/views/order/myOrders/components/MarkClaimedModal.vue +++ b/src/views/order/myOrders/components/MarkClaimedModal.vue @@ -207,23 +207,27 @@ function onClaimTypeChange(key: string) { formRef.value?.setValues?.({ ...formState, claimType: key }) } +const modalTitle = computed(() => + t('order.myOrders.claimed.modalTitle', { count: props.orderIds.length }), +) + watch( () => props.visible, async (open) => { if (!open) return resetForm() - modalMethods.setModalProps({ - title: t('order.myOrders.claimed.modalTitle', { count: props.orderIds.length }), - width: 720, - maskClosable: false, - }) - modalMethods.openModal(true) await nextTick() formRef.value?.setValues?.({ ...formState }) formRef.value?.formRef?.clearValidate?.() }, + { immediate: true }, ) +function onUpdateVisible(val: boolean) { + if (val === props.visible) return + emit('update:visible', val) +} + async function submitBatchJob(payload: Record) { const job = await postOrderBatchJob({ action: 'mark-claimed', @@ -314,14 +318,18 @@ async function handleSubmit() { function handleCancel() { emit('update:visible', false) - modalMethods.closeModal() }