From fde3553c9454e9f2f2e5edd6bff28bb534a6e7eb Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 7 Oct 2026 20:08:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=A2=E5=8D=95=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=94=B9=E4=B8=BA=E4=B8=8E=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E9=9D=A2=E5=8D=95=E4=B8=80=E8=87=B4=E7=9A=84=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正 PDF magic 误判,并避免 await 后打开 blob 被 Chrome 当成搜索。 Co-authored-by: Cursor --- src/utils/printPreview.ts | 152 +++++++++++++----- .../order/components/OrderAttachmentsTab.vue | 62 ++----- 2 files changed, 130 insertions(+), 84 deletions(-) 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/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')); } }