diff --git a/nginx-k3s.conf b/nginx-k3s.conf index 6b3f6ab..5785f83 100644 --- a/nginx-k3s.conf +++ b/nginx-k3s.conf @@ -156,7 +156,8 @@ server { location ^~ /marketing/referralTree { try_files $uri @spa; } # 面单/截图同源代理:/__cos__// → http:/// - # cos.ideship.com 自定义域名 https 证书不可用,上游走 http;避免浏览器直打 CORS / 混合内容 + # cos.* 自定义域 https 证书常仍是 *.myqcloud.com;上游继续走 http。 + # COS 签名 q-sign-time 含 ";":前端须编成 %3B,否则查询串被按 ";" 拆参 → Request has expired。 location ~ ^/__cos__/([^/]+)(/.*)$ { set $cos_host $1; set $cos_uri $2; diff --git a/src/api/oms/common.ts b/src/api/oms/common.ts index 7af3918..0367c20 100644 --- a/src/api/oms/common.ts +++ b/src/api/oms/common.ts @@ -104,6 +104,16 @@ export async function omsPost(path: string, body?: Record) return unwrapData(res.data) } +/** + * COS 签名参数里的 `q-sign-time=start;end` 含分号。 + * 线上 nginx / 部分网关会把 `;` 当查询分隔符,拆成 `q-sign-time=start&end=…`, + * COS 随即返回 AccessDenied / Request has expired;本地 Vite 代理通常不拆,故本地正常。 + */ +export function encodeCosSignedQuery(search: string): string { + if (!search) return '' + return search.replace(/;/g, '%3B') +} + /** * COS 绝对地址 → 同源 `/__cos__//...`(开发 Vite / 生产 nginx 反代)。 * cos.ideship.com 等自定义域名 https 不可用,直链 http 又会在 https 页面触发混合内容。 @@ -113,7 +123,7 @@ function toCosSameOriginProxyUrl(absoluteUrl: string): string | null { const u = new URL(absoluteUrl) const host = u.hostname.toLowerCase() if (!host.startsWith('cos.') && !host.includes('myqcloud.com')) return null - return `/__cos__/${u.host}${u.pathname}${u.search}` + return `/__cos__/${u.host}${u.pathname}${encodeCosSignedQuery(u.search)}` } catch { return null } diff --git a/src/components/PrintTemplate/OrderPrintModal.vue b/src/components/PrintTemplate/OrderPrintModal.vue index c3b99cc..c9126ef 100644 --- a/src/components/PrintTemplate/OrderPrintModal.vue +++ b/src/components/PrintTemplate/OrderPrintModal.vue @@ -101,7 +101,7 @@ import { import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState'; import { resolveOmsAssetUrl } from '@/api/oms/common'; import PrintOptionsForm from '@/components/PrintTemplate/PrintOptionsForm.vue'; -import { fetchPrintAssetBytes } from '@/utils/printPreview'; +import { fetchPrintAssetBytes, fetchPrintPreviewBlob } from '@/utils/printPreview'; import { buildHiprintBlobPrintExtras, HIPRINT_PAPER_PRESET_OPTIONS, @@ -258,12 +258,37 @@ function toAbsoluteUrl(url: string) { return `${window.location.origin}/${resolved}`; } -function openItemInNative(item: PrintItem) { +/** + * 查看面单:经 file-storage/stream(或 /__cos__)拉字节再开 blob。 + * 勿把带 `q-sign-time=a;b` 的 COS 链直接丢给浏览器——线上反代/跳转常把 `;` 拆成 `&`, + * COS 会回 AccessDenied/Request has expired(本地 Vite 不拆,故本地正常)。 + */ +async function openItemInNative(item: PrintItem) { if (!item?.url) { Message.warning('当前无可打开的打印链接'); return; } - window.open(toAbsoluteUrl(item.url) || item.url, '_blank', 'noopener,noreferrer'); + // 同步开空白页,避免 await 后被 Chrome 拦截弹窗 + const win = window.open('about:blank', '_blank'); + if (!win) { + Message.warning('请允许浏览器弹窗以查看面单'); + return; + } + try { + win.document.title = '面单加载中…'; + const blob = await fetchPrintPreviewBlob(item.url); + const blobUrl = URL.createObjectURL(blob); + win.location.replace(blobUrl); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + } catch (e: unknown) { + try { + win.close(); + } catch { + // ignore + } + const msg = e instanceof Error ? e.message : String(e || ''); + Message.warning(msg || '面单打开失败'); + } } function openActiveInNative() { @@ -271,7 +296,7 @@ function openActiveInNative() { Message.warning('当前无可打开的打印链接'); return; } - openItemInNative(activeItem.value); + void openItemInNative(activeItem.value); } async function silentPrintByUrl(url: string) { @@ -320,8 +345,8 @@ async function printActive() { flashPrintSuccess(); Message.success('已发送到本机打印'); } catch (e: any) { - Message.warning(e?.message || '自动打印失败,将尝试浏览器打印'); - window.open(toAbsoluteUrl(activeItem.value.url) || activeItem.value.url, '_blank', 'noopener,noreferrer'); + Message.warning(e?.message || '自动打印失败,将尝试浏览器打开面单'); + await openItemInNative(activeItem.value); } finally { printingActive.value = false; } @@ -339,8 +364,8 @@ async function printAll() { printingAll.value = true; try { const tasks = printableItems.value.map((item) => - silentPrintByUrl(item.url).catch(() => { - window.open(toAbsoluteUrl(item.url) || item.url, '_blank', 'noopener,noreferrer'); + silentPrintByUrl(item.url).catch(async () => { + await openItemInNative(item); }), ); const results = await Promise.allSettled(tasks); diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 4cb84b6..cb30d39 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -178,7 +178,14 @@ export default { 'warehouse.sku.form.imHsCodePlaceholder': 'Import HS code (optional)', 'warehouse.sku.form.declareElement': 'Export Declare Element', 'warehouse.sku.form.declareElementPlaceholder': 'Customs export declare elements (optional)', - 'warehouse.sku.form.showExtra': 'Show declaration extras (EN name / HS / declare element / remark)', + 'warehouse.sku.form.productId': 'Product ID', + 'warehouse.sku.form.productIdPlaceholder': 'Product ID (optional, max 100)', + 'warehouse.sku.form.nonStdProductId': 'Non-standard product ID', + 'warehouse.sku.form.nonStdProductIdPlaceholder': 'Non-standard product ID (optional, max 100)', + 'warehouse.sku.form.stdProductId': 'Standard product ID', + 'warehouse.sku.form.stdProductIdPlaceholder': 'e.g. GTIN / UPC / EAN (optional, max 100)', + 'warehouse.sku.form.showExtra': + 'Show declaration extras (EN name / HS / declare element / product IDs / remark)', 'warehouse.sku.form.hideExtra': 'Hide declaration extras', 'warehouse.modal.section.declarationExtra': 'Declaration extras', 'warehouse.sku.form.weightG': 'Weight', @@ -199,6 +206,8 @@ export default { 'warehouse.sku.tip.weightSize': 'Used for billing and packing estimates; all optional', 'warehouse.sku.tip.volume': 'Auto-estimated from L×W×H; shows - if size is empty', 'warehouse.sku.tip.skuNameEn': 'English name for customs declaration', + 'warehouse.sku.tip.stdProductId': + 'Enter a standard barcode such as GTIN-14, UPC-A, or EAN-13. Leave empty if none; use non-standard product ID instead.', 'warehouse.common.noCurrentWarehouse': 'Please select a warehouse in the top-right first', 'warehouse.sku.form.dimensions': 'Dimensions', 'warehouse.sku.form.length': 'L', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index e80d0ab..e395522 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -179,7 +179,13 @@ export default { 'warehouse.sku.form.imHsCodePlaceholder': '进口海关编码(选填)', 'warehouse.sku.form.declareElement': '出口申报要素', 'warehouse.sku.form.declareElementPlaceholder': '海关出口申报要素(选填)', - 'warehouse.sku.form.showExtra': '展开申报扩展(英文名 / HS / 申报要素 / 备注)', + 'warehouse.sku.form.productId': '产品标识编码', + 'warehouse.sku.form.productIdPlaceholder': '产品标识编码(选填,最长 100)', + 'warehouse.sku.form.nonStdProductId': '非标产品标识编码', + 'warehouse.sku.form.nonStdProductIdPlaceholder': '非标产品标识编码(选填,最长 100)', + 'warehouse.sku.form.stdProductId': '标准产品标识编码', + 'warehouse.sku.form.stdProductIdPlaceholder': '如 GTIN / UPC / EAN(选填,最长 100)', + 'warehouse.sku.form.showExtra': '展开申报扩展(英文名 / HS / 申报要素 / 产品标识 / 备注)', 'warehouse.sku.form.hideExtra': '收起申报扩展', 'warehouse.modal.section.declarationExtra': '申报扩展信息', 'warehouse.sku.form.weightG': '重量', @@ -200,6 +206,8 @@ export default { 'warehouse.sku.tip.weightSize': '重量与尺寸用于计费与装箱估算,均可选填', 'warehouse.sku.tip.volume': '由长×宽×高自动估算,未填尺寸时显示 -', 'warehouse.sku.tip.skuNameEn': '用于报关申报的英文品名', + 'warehouse.sku.tip.stdProductId': + '填写国际通用标准商品条码,如 GTIN-14、UPC-A、EAN-13;无条码可留空,改用非标产品标识编码', 'warehouse.common.noCurrentWarehouse': '请先在右上角选择仓库', 'warehouse.sku.form.dimensions': '尺寸', 'warehouse.sku.form.length': '长', diff --git a/src/utils/printPreview.ts b/src/utils/printPreview.ts index c1af762..373d977 100644 --- a/src/utils/printPreview.ts +++ b/src/utils/printPreview.ts @@ -1,6 +1,11 @@ import { axiosInstance } from '@/api/rbac' import { normalizeFileStorageKey, streamFileBlob } from '@/api/fileStorage' -import { extractOmsRequestError, omsUrl, resolveOmsAssetUrl } from '@/api/oms/common' +import { + encodeCosSignedQuery, + extractOmsRequestError, + omsUrl, + resolveOmsAssetUrl, +} from '@/api/oms/common' const COS_PROXY_PREFIX = '/__cos__' const PREVIEW_FETCH_TIMEOUT_MS = 25000 @@ -49,7 +54,8 @@ export function toCosProxyUrl(url: string): string | null { try { const u = new URL(url) if (!isCosHost(u.host)) return null - return `${COS_PROXY_PREFIX}/${u.host}${u.pathname}${u.search}` + // 分号必须 %3B,否则线上反代会拆坏签名时间窗(见 encodeCosSignedQuery) + return `${COS_PROXY_PREFIX}/${u.host}${u.pathname}${encodeCosSignedQuery(u.search)}` } catch { return null } diff --git a/src/views/order/priceCheck/components/QuoteResultCard.vue b/src/views/order/priceCheck/components/QuoteResultCard.vue index f2637b9..6155f72 100644 --- a/src/views/order/priceCheck/components/QuoteResultCard.vue +++ b/src/views/order/priceCheck/components/QuoteResultCard.vue @@ -88,14 +88,20 @@ String(props.record.productDescription || '').trim(), ) - /** 每满 25 字换一行,最多展示两行 */ + /** 每满 32 字换一行,最多展示三行(超出靠 CSS clamp + title 悬停看全量) */ const productDescriptionDisplay = computed(() => { const text = productDescription.value || t('order.priceCheck.card.missingProductDescription') - if (text.length <= 25) return text - const first = text.slice(0, 25) - const rest = text.slice(25, 50) - return rest ? `${first}\n${rest}` : first + const perLine = 32 + const maxLines = 3 + const maxLen = perLine * maxLines + const clipped = text.length > maxLen ? text.slice(0, maxLen) : text + if (clipped.length <= perLine) return clipped + const lines: string[] = [] + for (let i = 0; i < clipped.length; i += perLine) { + lines.push(clipped.slice(i, i + perLine)) + } + return lines.join('\n') }) const hasFeeDetail = computed(() => { @@ -398,8 +404,8 @@ .quote-card__body { display: grid; grid-template-columns: - minmax(200px, 0.78fr) - minmax(240px, 1.45fr) + minmax(200px, 0.72fr) + minmax(300px, 1.7fr) auto auto; align-items: center; @@ -459,13 +465,13 @@ .quote-card__desc { margin: 0; min-width: 0; - max-height: 2.9em; + max-height: calc(1.45em * 3); color: var(--color-text-2); font-size: 12px; line-height: 1.45; - display: flex; - align-items: center; - justify-content: center; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; text-align: center; white-space: pre-line; word-break: break-word; @@ -778,8 +784,8 @@ .quote-card__body { column-gap: 16px; grid-template-columns: - minmax(170px, 0.75fr) - minmax(180px, 1.3fr) + minmax(170px, 0.7fr) + minmax(240px, 1.5fr) auto auto; } diff --git a/src/views/warehouse/sku/index.vue b/src/views/warehouse/sku/index.vue index 7e91c9b..5a9a505 100644 --- a/src/views/warehouse/sku/index.vue +++ b/src/views/warehouse/sku/index.vue @@ -81,6 +81,9 @@ const formModel = reactive({ exHsCode: '', imHsCode: '', declareElement: '', + productId: '', + nonStdProductId: '', + stdProductId: '', weightG: undefined as number | undefined, lengthCm: undefined as number | undefined, widthCm: undefined as number | undefined, @@ -152,6 +155,9 @@ function hasExtraFieldValues() { || formModel.exHsCode.trim() || formModel.imHsCode.trim() || formModel.declareElement.trim() + || formModel.productId.trim() + || formModel.nonStdProductId.trim() + || formModel.stdProductId.trim() || formModel.remark.trim() ) } @@ -198,6 +204,9 @@ function resetForm() { formModel.exHsCode = '' formModel.imHsCode = '' formModel.declareElement = '' + formModel.productId = '' + formModel.nonStdProductId = '' + formModel.stdProductId = '' formModel.weightG = undefined formModel.lengthCm = undefined formModel.widthCm = undefined @@ -383,6 +392,9 @@ function handleEdit(row: any) { formModel.exHsCode = String(pickField(raw, 'exHsCode', 'ExHsCode') ?? '') formModel.imHsCode = String(pickField(raw, 'imHsCode', 'ImHsCode') ?? '') formModel.declareElement = String(pickField(raw, 'declareElement', 'DeclareElement') ?? '') + formModel.productId = String(pickField(raw, 'productId', 'ProductId') ?? '') + formModel.nonStdProductId = String(pickField(raw, 'nonStdProductId', 'NonStdProductId') ?? '') + formModel.stdProductId = String(pickField(raw, 'stdProductId', 'StdProductId') ?? '') formModel.weightG = pickField(raw, 'weightG', 'WeightG') formModel.lengthCm = pickField(raw, 'lengthCm', 'LengthCm') formModel.widthCm = pickField(raw, 'widthCm', 'WidthCm') @@ -765,6 +777,46 @@ onMounted(async () => { :auto-size="{ minRows: 1, maxRows: 3 }" /> + + + + + + + + + + + + + + + + + +