fix: 订单附件预览改为与查看面单一致的打开方式
修正 PDF magic 误判,并避免 await 后打开 blob 被 Chrome 当成搜索。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4a8a4eddca
commit
fde3553c94
@ -149,17 +149,81 @@ async function buildPrintStreamKeyCandidates(rawUrl: string): Promise<string[]>
|
|||||||
return keys
|
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) {
|
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 {
|
function bytesStartWithPdfMagic(bytes: Uint8Array): boolean {
|
||||||
const head = bytes.slice(0, 5)
|
// 标准 PDF 头为 "%PDF-1.x";此前误用 5 字节与 4 字符 "%PDF" 比较,合法面单也会被拒
|
||||||
if (head.length < 5) return false
|
if (bytes.length < PDF_MAGIC.length) return false
|
||||||
return String.fromCharCode(...head) === PDF_MAGIC
|
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 (/^<!DOCTYPE\s+html/i.test(sample) || /^<html[\s>]/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) {
|
async function throwIfBytesLookLikeApiError(bytes: Uint8Array) {
|
||||||
@ -182,20 +246,47 @@ async function throwIfBytesLookLikeApiError(bytes: Uint8Array) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验并规范化 PDF 字节(拒绝 JSON/HTML 错误体) */
|
type NormalizedPreviewBytes = { bytes: Uint8Array; mime: string }
|
||||||
export async function normalizePdfBytes(blob: Blob, rawUrl: string): Promise<Uint8Array> {
|
|
||||||
if (!isPrintPreviewPdf(rawUrl, blob)) {
|
/** 规范化预览字节:按内容嗅探 MIME;扩展名/响应头标成 PDF 时再严格校验 */
|
||||||
return new Uint8Array(await blob.arrayBuffer())
|
async function normalizePreviewBytes(blob: Blob, rawUrl: string): Promise<NormalizedPreviewBytes> {
|
||||||
|
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')
|
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<Uint8Array> {
|
||||||
|
const { bytes, mime } = await normalizePreviewBytes(blob, rawUrl)
|
||||||
|
if (mime.includes('pdf')) {
|
||||||
|
if (!bytesStartWithPdfMagic(bytes)) {
|
||||||
|
throw new Error('面单文件不是有效的 PDF')
|
||||||
|
}
|
||||||
|
}
|
||||||
return bytes
|
return bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blobFromNormalized(normalized: NormalizedPreviewBytes): Blob {
|
||||||
|
return new Blob([normalized.bytes], { type: normalized.mime })
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchBlobViaSameOriginPath(path: string): Promise<Blob> {
|
async function fetchBlobViaSameOriginPath(path: string): Promise<Blob> {
|
||||||
const res = await axiosInstance.get(path, {
|
const res = await axiosInstance.get(path, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
@ -232,14 +323,8 @@ async function fetchBlobViaCosProxy(rawUrl: string): Promise<Blob> {
|
|||||||
throw new Error(`COS 代理下载失败 HTTP ${res.status}`)
|
throw new Error(`COS 代理下载失败 HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
const mime = String(res.headers.get('content-type') || guessMime(rawUrl))
|
const mime = String(res.headers.get('content-type') || guessMime(rawUrl))
|
||||||
const bytes = stripUtf8Bom(new Uint8Array(await res.arrayBuffer()))
|
const rawBlob = new Blob([await res.arrayBuffer()], { type: mime })
|
||||||
await throwIfBytesLookLikeApiError(bytes)
|
return blobFromNormalized(await normalizePreviewBytes(rawBlob, rawUrl))
|
||||||
if (/\.pdf(\?|#|$)/i.test(rawUrl) && !bytesStartWithPdfMagic(bytes)) {
|
|
||||||
throw new Error('面单文件不是有效的 PDF')
|
|
||||||
}
|
|
||||||
return new Blob([bytes], {
|
|
||||||
type: /\.pdf(\?|#|$)/i.test(rawUrl) ? 'application/pdf' : mime,
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
@ -252,10 +337,7 @@ async function fetchViaFileStorageStream(rawUrl: string, errors: string[]): Prom
|
|||||||
const blob = await streamFileBlob(key)
|
const blob = await streamFileBlob(key)
|
||||||
const mime = guessMime(key)
|
const mime = guessMime(key)
|
||||||
const typed = blob.type ? blob : new Blob([blob], { type: mime })
|
const typed = blob.type ? blob : new Blob([blob], { type: mime })
|
||||||
const bytes = await normalizePdfBytes(typed, rawUrl)
|
return blobFromNormalized(await normalizePreviewBytes(typed, rawUrl))
|
||||||
return new Blob([bytes], {
|
|
||||||
type: isPrintPreviewPdf(rawUrl, typed) ? 'application/pdf' : mime,
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = extractOmsRequestError(e)
|
const msg = extractOmsRequestError(e)
|
||||||
if (msg) errors.push(msg)
|
if (msg) errors.push(msg)
|
||||||
@ -274,11 +356,8 @@ async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
|
|||||||
// 开发:测试 Admin 可能尚未部署 /file-storage,先走同源 COS 代理
|
// 开发:测试 Admin 可能尚未部署 /file-storage,先走同源 COS 代理
|
||||||
if (import.meta.env.DEV && isCos) {
|
if (import.meta.env.DEV && isCos) {
|
||||||
try {
|
try {
|
||||||
const blob = await fetchBlobViaCosProxy(raw)
|
// fetchBlobViaCosProxy 内已按内容规范化 MIME
|
||||||
const bytes = await normalizePdfBytes(blob, raw)
|
return await fetchBlobViaCosProxy(raw)
|
||||||
return new Blob([bytes], {
|
|
||||||
type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw),
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
|
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
|
||||||
}
|
}
|
||||||
@ -291,11 +370,7 @@ async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
|
|||||||
// 同源 /__cos__ 代理(生产 nginx;开发上面已试过则跳过)
|
// 同源 /__cos__ 代理(生产 nginx;开发上面已试过则跳过)
|
||||||
if (isCos && !import.meta.env.DEV) {
|
if (isCos && !import.meta.env.DEV) {
|
||||||
try {
|
try {
|
||||||
const blob = await fetchBlobViaCosProxy(raw)
|
return await fetchBlobViaCosProxy(raw)
|
||||||
const bytes = await normalizePdfBytes(blob, raw)
|
|
||||||
return new Blob([bytes], {
|
|
||||||
type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw),
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
|
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
|
||||||
}
|
}
|
||||||
@ -306,10 +381,7 @@ async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
|
|||||||
if (sameOriginPath.startsWith('/')) {
|
if (sameOriginPath.startsWith('/')) {
|
||||||
try {
|
try {
|
||||||
const blob = await fetchBlobViaSameOriginPath(sameOriginPath)
|
const blob = await fetchBlobViaSameOriginPath(sameOriginPath)
|
||||||
const bytes = await normalizePdfBytes(blob, raw)
|
return blobFromNormalized(await normalizePreviewBytes(blob, raw))
|
||||||
return new Blob([bytes], {
|
|
||||||
type: isPrintPreviewPdf(raw, blob) ? 'application/pdf' : blob.type || guessMime(raw),
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = extractOmsRequestError(e)
|
const msg = extractOmsRequestError(e)
|
||||||
if (msg) errors.push(msg)
|
if (msg) errors.push(msg)
|
||||||
@ -351,6 +423,8 @@ export async function resolvePrintPreviewSrc(rawUrl: string): Promise<string> {
|
|||||||
export function isPrintPreviewPdf(rawUrl: string, blob?: Blob | null): boolean {
|
export function isPrintPreviewPdf(rawUrl: string, blob?: Blob | null): boolean {
|
||||||
const mime = String(blob?.type || '').toLowerCase()
|
const mime = String(blob?.type || '').toLowerCase()
|
||||||
if (mime.includes('pdf')) return true
|
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 || ''))
|
return /\.pdf(\?|#|$)/i.test(String(rawUrl || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,6 @@
|
|||||||
<a-button
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
size="mini"
|
size="mini"
|
||||||
:loading="previewingId === file.id"
|
|
||||||
@click="openFile(file)"
|
@click="openFile(file)"
|
||||||
>
|
>
|
||||||
{{ $t('order.myOrders.drawer.attachments.preview') }}
|
{{ $t('order.myOrders.drawer.attachments.preview') }}
|
||||||
@ -93,7 +92,6 @@ import {
|
|||||||
type OrderFileItem,
|
type OrderFileItem,
|
||||||
} from '@/api/order';
|
} from '@/api/order';
|
||||||
import { resolveOmsAssetUrl } from '@/api/oms/common';
|
import { resolveOmsAssetUrl } from '@/api/oms/common';
|
||||||
import { fetchPrintPreviewBlob } from '@/utils/printPreview';
|
|
||||||
|
|
||||||
const FILE_TYPE_DEFS = [
|
const FILE_TYPE_DEFS = [
|
||||||
{
|
{
|
||||||
@ -154,7 +152,6 @@ const { t } = useI18n();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const uploadingType = ref<string | null>(null);
|
const uploadingType = ref<string | null>(null);
|
||||||
const deletingId = ref<number | null>(null);
|
const deletingId = ref<number | null>(null);
|
||||||
const previewingId = ref<number | null>(null);
|
|
||||||
const groups = ref<Record<string, OrderFileItem[]>>({});
|
const groups = ref<Record<string, OrderFileItem[]>>({});
|
||||||
|
|
||||||
const canUpload = computed(() => !props.readOnly && !props.customerView);
|
const canUpload = computed(() => !props.readOnly && !props.customerView);
|
||||||
@ -169,51 +166,26 @@ const visibleSections = computed(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
function resolveUrl(path?: string) {
|
/** 与打印弹窗「查看面单」一致:同步打开可访问 URL(勿先 await 再开 blob,Chrome 会当搜索词) */
|
||||||
return resolveOmsAssetUrl(path);
|
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;
|
||||||
|
}
|
||||||
|
if (resolved.startsWith('/')) return `${window.location.origin}${resolved}`;
|
||||||
|
return `${window.location.origin}/${resolved}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function guessAttachMime(file: OrderFileItem, url: string): string {
|
function openFile(file: OrderFileItem) {
|
||||||
const ct = String(file.contentType || '').trim().toLowerCase();
|
const url = toAbsoluteUrl(file.fileUrl);
|
||||||
if (ct && !ct.includes('octet-stream') && !ct.includes('force-download')) {
|
if (!url) {
|
||||||
return ct;
|
Message.warning(t('order.myOrders.drawer.attachments.previewFailed'));
|
||||||
}
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 拉取为带正确 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;
|
return;
|
||||||
}
|
}
|
||||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 120_000);
|
const win = window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
} catch (e: any) {
|
if (!win) {
|
||||||
Message.error(e?.message || t('order.myOrders.drawer.attachments.previewFailed'));
|
Message.warning(t('order.myOrders.drawer.attachments.previewBlocked'));
|
||||||
} finally {
|
|
||||||
previewingId.value = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user