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; }
# 面单/截图同源代理:/__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__/([^/]+)(/.*)$ {
set $cos_host $1;
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)
}
/**
* 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.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
}

View File

@ -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);

View File

@ -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',

View File

@ -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': '长',

View File

@ -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
}

View File

@ -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;
}

View File

@ -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<number>(raw, 'weightG', 'WeightG')
formModel.lengthCm = pickField<number>(raw, 'lengthCm', 'LengthCm')
formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm')
@ -765,6 +777,46 @@ onMounted(async () => {
:auto-size="{ minRows: 1, maxRows: 3 }"
/>
</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>
<template #label>{{ t('warehouse.sku.form.remark') }}</template>
<a-textarea