fix: 面单查看绕过 COS 签名分号拆坏,并补 SKU 产品标识与查价描述
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m3s
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:
parent
6ec41b2dbe
commit
893adc0d56
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '长',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user