fix: 面单查看绕过 COS 签名分号拆坏,并补 SKU 产品标识与查价描述
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m3s

查看面单改走 stream/blob,代理 URL 将 ; 编成 %3B;SKU 申报扩展与查价卡片描述展示一并调整。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-23 11:19:14 +08:00
parent 6ec41b2dbe
commit 893adc0d56
8 changed files with 144 additions and 27 deletions

View File

@ -156,7 +156,8 @@ server {
location ^~ /marketing/referralTree { try_files $uri @spa; } location ^~ /marketing/referralTree { try_files $uri @spa; }
# 面单/截图同源代理:/__cos__/<host>/<path> → http://<host>/<path> # 面单/截图同源代理:/__cos__/<host>/<path> → http://<host>/<path>
# cos.ideship.com 自定义域名 https 证书不可用,上游走 http;避免浏览器直打 CORS / 混合内容 # cos.* 自定义域 https 证书常仍是 *.myqcloud.com;上游继续走 http。
# COS 签名 q-sign-time 含 ";":前端须编成 %3B,否则查询串被按 ";" 拆参 → Request has expired。
location ~ ^/__cos__/([^/]+)(/.*)$ { location ~ ^/__cos__/([^/]+)(/.*)$ {
set $cos_host $1; set $cos_host $1;
set $cos_uri $2; set $cos_uri $2;

View File

@ -104,6 +104,16 @@ export async function omsPost<T = any>(path: string, body?: Record<string, any>)
return unwrapData<T>(res.data) return unwrapData<T>(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__/<host>/...`(开发 Vite / 生产 nginx 反代)。 * COS 绝对地址 → 同源 `/__cos__/<host>/...`(开发 Vite / 生产 nginx 反代)。
* cos.ideship.com 等自定义域名 https 不可用,直链 http 又会在 https 页面触发混合内容。 * cos.ideship.com 等自定义域名 https 不可用,直链 http 又会在 https 页面触发混合内容。
@ -113,7 +123,7 @@ function toCosSameOriginProxyUrl(absoluteUrl: string): string | null {
const u = new URL(absoluteUrl) const u = new URL(absoluteUrl)
const host = u.hostname.toLowerCase() const host = u.hostname.toLowerCase()
if (!host.startsWith('cos.') && !host.includes('myqcloud.com')) return null 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 { } catch {
return null return null
} }

View File

@ -101,7 +101,7 @@ import {
import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState'; import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState';
import { resolveOmsAssetUrl } from '@/api/oms/common'; import { resolveOmsAssetUrl } from '@/api/oms/common';
import PrintOptionsForm from '@/components/PrintTemplate/PrintOptionsForm.vue'; import PrintOptionsForm from '@/components/PrintTemplate/PrintOptionsForm.vue';
import { fetchPrintAssetBytes } from '@/utils/printPreview'; import { fetchPrintAssetBytes, fetchPrintPreviewBlob } from '@/utils/printPreview';
import { import {
buildHiprintBlobPrintExtras, buildHiprintBlobPrintExtras,
HIPRINT_PAPER_PRESET_OPTIONS, HIPRINT_PAPER_PRESET_OPTIONS,
@ -258,12 +258,37 @@ function toAbsoluteUrl(url: string) {
return `${window.location.origin}/${resolved}`; 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) { if (!item?.url) {
Message.warning('当前无可打开的打印链接'); Message.warning('当前无可打开的打印链接');
return; 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() { function openActiveInNative() {
@ -271,7 +296,7 @@ function openActiveInNative() {
Message.warning('当前无可打开的打印链接'); Message.warning('当前无可打开的打印链接');
return; return;
} }
openItemInNative(activeItem.value); void openItemInNative(activeItem.value);
} }
async function silentPrintByUrl(url: string) { async function silentPrintByUrl(url: string) {
@ -320,8 +345,8 @@ async function printActive() {
flashPrintSuccess(); flashPrintSuccess();
Message.success('已发送到本机打印'); Message.success('已发送到本机打印');
} catch (e: any) { } catch (e: any) {
Message.warning(e?.message || '自动打印失败,将尝试浏览器打印'); Message.warning(e?.message || '自动打印失败,将尝试浏览器打开面单');
window.open(toAbsoluteUrl(activeItem.value.url) || activeItem.value.url, '_blank', 'noopener,noreferrer'); await openItemInNative(activeItem.value);
} finally { } finally {
printingActive.value = false; printingActive.value = false;
} }
@ -339,8 +364,8 @@ async function printAll() {
printingAll.value = true; printingAll.value = true;
try { try {
const tasks = printableItems.value.map((item) => const tasks = printableItems.value.map((item) =>
silentPrintByUrl(item.url).catch(() => { silentPrintByUrl(item.url).catch(async () => {
window.open(toAbsoluteUrl(item.url) || item.url, '_blank', 'noopener,noreferrer'); await openItemInNative(item);
}), }),
); );
const results = await Promise.allSettled(tasks); const results = await Promise.allSettled(tasks);

View File

@ -178,7 +178,14 @@ export default {
'warehouse.sku.form.imHsCodePlaceholder': 'Import HS code (optional)', 'warehouse.sku.form.imHsCodePlaceholder': 'Import HS code (optional)',
'warehouse.sku.form.declareElement': 'Export Declare Element', 'warehouse.sku.form.declareElement': 'Export Declare Element',
'warehouse.sku.form.declareElementPlaceholder': 'Customs export declare elements (optional)', '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.sku.form.hideExtra': 'Hide declaration extras',
'warehouse.modal.section.declarationExtra': 'Declaration extras', 'warehouse.modal.section.declarationExtra': 'Declaration extras',
'warehouse.sku.form.weightG': 'Weight', '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.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.volume': 'Auto-estimated from L×W×H; shows - if size is empty',
'warehouse.sku.tip.skuNameEn': 'English name for customs declaration', '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.common.noCurrentWarehouse': 'Please select a warehouse in the top-right first',
'warehouse.sku.form.dimensions': 'Dimensions', 'warehouse.sku.form.dimensions': 'Dimensions',
'warehouse.sku.form.length': 'L', 'warehouse.sku.form.length': 'L',

View File

@ -179,7 +179,13 @@ export default {
'warehouse.sku.form.imHsCodePlaceholder': '进口海关编码(选填)', 'warehouse.sku.form.imHsCodePlaceholder': '进口海关编码(选填)',
'warehouse.sku.form.declareElement': '出口申报要素', 'warehouse.sku.form.declareElement': '出口申报要素',
'warehouse.sku.form.declareElementPlaceholder': '海关出口申报要素(选填)', '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.sku.form.hideExtra': '收起申报扩展',
'warehouse.modal.section.declarationExtra': '申报扩展信息', 'warehouse.modal.section.declarationExtra': '申报扩展信息',
'warehouse.sku.form.weightG': '重量', 'warehouse.sku.form.weightG': '重量',
@ -200,6 +206,8 @@ export default {
'warehouse.sku.tip.weightSize': '重量与尺寸用于计费与装箱估算,均可选填', 'warehouse.sku.tip.weightSize': '重量与尺寸用于计费与装箱估算,均可选填',
'warehouse.sku.tip.volume': '由长×宽×高自动估算,未填尺寸时显示 -', 'warehouse.sku.tip.volume': '由长×宽×高自动估算,未填尺寸时显示 -',
'warehouse.sku.tip.skuNameEn': '用于报关申报的英文品名', 'warehouse.sku.tip.skuNameEn': '用于报关申报的英文品名',
'warehouse.sku.tip.stdProductId':
'填写国际通用标准商品条码,如 GTIN-14、UPC-A、EAN-13;无条码可留空,改用非标产品标识编码',
'warehouse.common.noCurrentWarehouse': '请先在右上角选择仓库', 'warehouse.common.noCurrentWarehouse': '请先在右上角选择仓库',
'warehouse.sku.form.dimensions': '尺寸', 'warehouse.sku.form.dimensions': '尺寸',
'warehouse.sku.form.length': '长', 'warehouse.sku.form.length': '长',

View File

@ -1,6 +1,11 @@
import { axiosInstance } from '@/api/rbac' import { axiosInstance } from '@/api/rbac'
import { normalizeFileStorageKey, streamFileBlob } from '@/api/fileStorage' 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 COS_PROXY_PREFIX = '/__cos__'
const PREVIEW_FETCH_TIMEOUT_MS = 25000 const PREVIEW_FETCH_TIMEOUT_MS = 25000
@ -49,7 +54,8 @@ export function toCosProxyUrl(url: string): string | null {
try { try {
const u = new URL(url) const u = new URL(url)
if (!isCosHost(u.host)) return null 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 { } catch {
return null return null
} }

View File

@ -88,14 +88,20 @@
String(props.record.productDescription || '').trim(), String(props.record.productDescription || '').trim(),
) )
/** 每满 25 字换一行,最多展示两行 */ /** 每满 32 字换一行,最多展示三行(超出靠 CSS clamp + title 悬停看全量) */
const productDescriptionDisplay = computed(() => { const productDescriptionDisplay = computed(() => {
const text = const text =
productDescription.value || t('order.priceCheck.card.missingProductDescription') productDescription.value || t('order.priceCheck.card.missingProductDescription')
if (text.length <= 25) return text const perLine = 32
const first = text.slice(0, 25) const maxLines = 3
const rest = text.slice(25, 50) const maxLen = perLine * maxLines
return rest ? `${first}\n${rest}` : first 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(() => { const hasFeeDetail = computed(() => {
@ -398,8 +404,8 @@
.quote-card__body { .quote-card__body {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(200px, 0.78fr) minmax(200px, 0.72fr)
minmax(240px, 1.45fr) minmax(300px, 1.7fr)
auto auto
auto; auto;
align-items: center; align-items: center;
@ -459,13 +465,13 @@
.quote-card__desc { .quote-card__desc {
margin: 0; margin: 0;
min-width: 0; min-width: 0;
max-height: 2.9em; max-height: calc(1.45em * 3);
color: var(--color-text-2); color: var(--color-text-2);
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
display: flex; display: -webkit-box;
align-items: center; -webkit-box-orient: vertical;
justify-content: center; -webkit-line-clamp: 3;
text-align: center; text-align: center;
white-space: pre-line; white-space: pre-line;
word-break: break-word; word-break: break-word;
@ -778,8 +784,8 @@
.quote-card__body { .quote-card__body {
column-gap: 16px; column-gap: 16px;
grid-template-columns: grid-template-columns:
minmax(170px, 0.75fr) minmax(170px, 0.7fr)
minmax(180px, 1.3fr) minmax(240px, 1.5fr)
auto auto
auto; auto;
} }

View File

@ -81,6 +81,9 @@ const formModel = reactive({
exHsCode: '', exHsCode: '',
imHsCode: '', imHsCode: '',
declareElement: '', declareElement: '',
productId: '',
nonStdProductId: '',
stdProductId: '',
weightG: undefined as number | undefined, weightG: undefined as number | undefined,
lengthCm: undefined as number | undefined, lengthCm: undefined as number | undefined,
widthCm: undefined as number | undefined, widthCm: undefined as number | undefined,
@ -152,6 +155,9 @@ function hasExtraFieldValues() {
|| formModel.exHsCode.trim() || formModel.exHsCode.trim()
|| formModel.imHsCode.trim() || formModel.imHsCode.trim()
|| formModel.declareElement.trim() || formModel.declareElement.trim()
|| formModel.productId.trim()
|| formModel.nonStdProductId.trim()
|| formModel.stdProductId.trim()
|| formModel.remark.trim() || formModel.remark.trim()
) )
} }
@ -198,6 +204,9 @@ function resetForm() {
formModel.exHsCode = '' formModel.exHsCode = ''
formModel.imHsCode = '' formModel.imHsCode = ''
formModel.declareElement = '' formModel.declareElement = ''
formModel.productId = ''
formModel.nonStdProductId = ''
formModel.stdProductId = ''
formModel.weightG = undefined formModel.weightG = undefined
formModel.lengthCm = undefined formModel.lengthCm = undefined
formModel.widthCm = undefined formModel.widthCm = undefined
@ -383,6 +392,9 @@ function handleEdit(row: any) {
formModel.exHsCode = String(pickField(raw, 'exHsCode', 'ExHsCode') ?? '') formModel.exHsCode = String(pickField(raw, 'exHsCode', 'ExHsCode') ?? '')
formModel.imHsCode = String(pickField(raw, 'imHsCode', 'ImHsCode') ?? '') formModel.imHsCode = String(pickField(raw, 'imHsCode', 'ImHsCode') ?? '')
formModel.declareElement = String(pickField(raw, 'declareElement', 'DeclareElement') ?? '') 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<number>(raw, 'weightG', 'WeightG') formModel.weightG = pickField<number>(raw, 'weightG', 'WeightG')
formModel.lengthCm = pickField<number>(raw, 'lengthCm', 'LengthCm') formModel.lengthCm = pickField<number>(raw, 'lengthCm', 'LengthCm')
formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm') formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm')
@ -765,6 +777,46 @@ onMounted(async () => {
:auto-size="{ minRows: 1, maxRows: 3 }" :auto-size="{ minRows: 1, maxRows: 3 }"
/> />
</a-form-item> </a-form-item>
<a-form-item>
<template #label>{{ t('warehouse.sku.form.productId') }}</template>
<a-input
v-model="formModel.productId"
allow-clear
:max-length="100"
:placeholder="t('warehouse.sku.form.productIdPlaceholder')"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item>
<template #label>{{ t('warehouse.sku.form.nonStdProductId') }}</template>
<a-input
v-model="formModel.nonStdProductId"
allow-clear
:max-length="100"
:placeholder="t('warehouse.sku.form.nonStdProductIdPlaceholder')"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item>
<template #label>
<span class="label-with-tip">
{{ t('warehouse.sku.form.stdProductId') }}
<a-tooltip :content="t('warehouse.sku.tip.stdProductId')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
</template>
<a-input
v-model="formModel.stdProductId"
allow-clear
:max-length="100"
:placeholder="t('warehouse.sku.form.stdProductIdPlaceholder')"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item> <a-form-item>
<template #label>{{ t('warehouse.sku.form.remark') }}</template> <template #label>{{ t('warehouse.sku.form.remark') }}</template>
<a-textarea <a-textarea