front/src/utils/printPreview.ts
zzh 5178b6379f
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m43s
feat: 附件预览优先走 COS 代理,并补齐 KPI 导出与模板下载
开发环境避免不存在的 file-storage 404 挡住预览;分区明细改为前端生成 Excel。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:21:05 +08:00

365 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { axiosInstance } from '@/api/rbac'
import { normalizeFileStorageKey, streamFileBlob } from '@/api/fileStorage'
import { extractOmsRequestError, omsUrl, resolveOmsAssetUrl } from '@/api/oms/common'
const COS_PROXY_PREFIX = '/__cos__'
const PREVIEW_FETCH_TIMEOUT_MS = 25000
export function withPreviewTimeout<T>(promise: Promise<T>, message = '面单加载超时'): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), PREVIEW_FETCH_TIMEOUT_MS)
promise
.then((value) => {
window.clearTimeout(timer)
resolve(value)
})
.catch((error) => {
window.clearTimeout(timer)
reject(error)
})
})
}
const PDF_MAGIC = '%PDF'
function guessMime(pathOrUrl: string): string {
if (/\.pdf(\?|#|$)/i.test(pathOrUrl)) return 'application/pdf'
if (/\.html?(\?|#|$)/i.test(pathOrUrl)) return 'text/html; charset=utf-8'
return 'application/octet-stream'
}
function isCosHost(hostname: string): boolean {
const host = String(hostname || '').toLowerCase()
return host.includes('myqcloud.com') || host.startsWith('cos.')
}
function isCosAbsoluteUrl(raw: string): boolean {
try {
const u = new URL(raw)
return isCosHost(u.hostname)
} catch {
return false
}
}
/**
* 把 COS 绝对地址改写为同源 `/__cos__/<host>/...` 代理路径。
* 开发由 Vite 代理;生产由 nginx-k3s `/__cos__/` 反代(避免浏览器直打 COS 撞 CORS)。
*/
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}`
} catch {
return null
}
}
/** @deprecated 使用 toCosProxyUrl */
export function toDevCosProxyUrl(url: string): string | null {
if (!import.meta.env.DEV) return null
return toCosProxyUrl(url)
}
/** 开发环境把生产 Admin 绝对地址改写成 Vite 同源代理路径 */
function rewriteDevAdminAssetPath(url: string): string {
if (!import.meta.env.DEV) return url
const trimmed = String(url || '').trim()
if (!trimmed) return trimmed
const proxied = trimmed.replace(/^https?:\/\/api\.(?:ideship|nanafa)\.com\/admin/i, '')
return proxied || trimmed
}
function stripAdminPrefix(key: string): string {
return key.replace(/^admin\//i, '')
}
/** 从 COS / Admin 完整 URL 或相对路径推断库内 object key(不含签名参数) */
export function guessStorageKeyFromPrintUrl(raw: string): string | null {
const value = String(raw || '').trim()
if (!value) return null
try {
if (/^https?:\/\//i.test(value)) {
const path = decodeURIComponent(new URL(value).pathname).replace(/^\/+/, '')
const stripped = stripAdminPrefix(path)
return stripped || null
}
const rel = value.replace(/^\/+/, '')
const stripped = stripAdminPrefix(rel)
return stripped || null
} catch {
return null
}
}
function toSameOriginPath(url: string): string {
const resolved = rewriteDevAdminAssetPath(resolveOmsAssetUrl(url) || url)
if (!resolved) return ''
if (/^https?:\/\//i.test(resolved)) return resolved
return resolved.startsWith('/') ? resolved : `/${resolved}`
}
function pushUniqueKey(keys: string[], key: string | null | undefined) {
const value = String(key || '').trim()
if (!value || keys.includes(value)) return
keys.push(value)
}
/** 构建 /file-storage/stream 候选 key(含完整签名 URL,供后端代拉) */
async function buildPrintStreamKeyCandidates(rawUrl: string): Promise<string[]> {
const raw = String(rawUrl || '').trim()
if (!raw) return []
const keys: string[] = []
// 完整签名 URL 优先:后端可按 key 读,失败时再 HTTPS 代拉(无浏览器 CORS)
if (/^https?:\/\//i.test(raw) && isCosAbsoluteUrl(raw)) {
pushUniqueKey(keys, raw)
}
try {
pushUniqueKey(keys, await normalizeFileStorageKey(raw))
} catch {
// ignore
}
const guessed = guessStorageKeyFromPrintUrl(raw)
pushUniqueKey(keys, guessed)
if (guessed && !guessed.startsWith('admin/')) {
pushUniqueKey(keys, `admin/${guessed}`)
}
const asset = resolveOmsAssetUrl(raw)
const guessedAsset = guessStorageKeyFromPrintUrl(asset)
pushUniqueKey(keys, guessedAsset)
if (guessedAsset && !guessedAsset.startsWith('admin/')) {
pushUniqueKey(keys, `admin/${guessedAsset}`)
}
pushUniqueKey(keys, guessStorageKeyFromPrintUrl(rewriteDevAdminAssetPath(raw)))
if (asset) {
pushUniqueKey(keys, guessStorageKeyFromPrintUrl(rewriteDevAdminAssetPath(asset)))
}
const sameOriginPath = toSameOriginPath(raw)
if (sameOriginPath.startsWith('/')) {
pushUniqueKey(keys, guessStorageKeyFromPrintUrl(sameOriginPath))
}
return keys
}
function stripUtf8Bom(bytes: Uint8Array): Uint8Array {
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
return bytes.slice(3)
}
return bytes
}
function bytesStartWithPdfMagic(bytes: Uint8Array): boolean {
const head = bytes.slice(0, 5)
if (head.length < 5) return false
return String.fromCharCode(...head) === PDF_MAGIC
}
async function throwIfBytesLookLikeApiError(bytes: Uint8Array) {
const sample = new TextDecoder('utf-8', { fatal: false }).decode(bytes.slice(0, 256)).trim()
if (!sample) return
if (sample.startsWith('{') || sample.startsWith('[')) {
try {
const body = JSON.parse(sample)
const msg = body?.msg ?? body?.message ?? body?.Message
throw new Error(msg ? String(msg) : '面单下载失败')
} catch (e) {
if (e instanceof Error && e.message !== '面单下载失败' && !e.message.startsWith('Unexpected')) {
throw e
}
throw new Error('面单下载失败')
}
}
if (sample.startsWith('<!DOCTYPE') || sample.startsWith('<html') || sample.startsWith('<')) {
throw new Error('面单下载失败')
}
}
/** 校验并规范化 PDF 字节(拒绝 JSON/HTML 错误体) */
export async function normalizePdfBytes(blob: Blob, rawUrl: string): Promise<Uint8Array> {
if (!isPrintPreviewPdf(rawUrl, blob)) {
return new Uint8Array(await blob.arrayBuffer())
}
let bytes = stripUtf8Bom(new Uint8Array(await blob.arrayBuffer()))
if (!bytesStartWithPdfMagic(bytes)) {
await throwIfBytesLookLikeApiError(bytes)
throw new Error('面单文件不是有效的 PDF')
}
return bytes
}
async function fetchBlobViaSameOriginPath(path: string): Promise<Blob> {
const res = await axiosInstance.get(path, {
responseType: 'blob',
timeout: 120000,
headers: { 'Content-Type': undefined as unknown as string },
})
const type = String(res.headers['content-type'] || guessMime(path))
if (res.data instanceof Blob) {
return res.data.type ? res.data : new Blob([res.data], { type })
}
return new Blob([res.data], { type })
}
/**
* 仅经同源 `/__cos__` 代理拉取 COS(开发 Vite / 生产 nginx)。
* 禁止浏览器直连 cos.*:私有桶未配 CORS 时必失败。
*/
async function fetchBlobViaCosProxy(rawUrl: string): Promise<Blob> {
const proxied = toCosProxyUrl(rawUrl)
if (!proxied) {
throw new Error('无法构造 COS 同源代理地址')
}
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), 120000)
try {
const res = await fetch(proxied, {
method: 'GET',
credentials: 'omit',
mode: 'same-origin',
signal: controller.signal,
headers: { Accept: '*/*' },
})
if (!res.ok) {
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,
})
} finally {
window.clearTimeout(timer)
}
}
async function fetchViaFileStorageStream(rawUrl: string, errors: string[]): Promise<Blob | null> {
const keys = await buildPrintStreamKeyCandidates(rawUrl)
for (const key of keys) {
try {
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,
})
} catch (e: unknown) {
const msg = extractOmsRequestError(e)
if (msg) errors.push(msg)
}
}
return null
}
async function fetchPreviewBlob(rawUrl: string): Promise<Blob> {
const raw = String(rawUrl || '').trim()
if (!raw) throw new Error('打印地址为空')
const errors: string[] = []
const isCos = /^https?:\/\//i.test(raw) && isCosAbsoluteUrl(raw)
// 开发:测试 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),
})
} catch (e: unknown) {
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
}
}
// 鉴权 stream(含签名 URL 服务端代拉)— 生产主路径,无浏览器 CORS
const streamed = await fetchViaFileStorageStream(raw, errors)
if (streamed) return streamed
// 同源 /__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),
})
} catch (e: unknown) {
errors.push(extractOmsRequestError(e) || 'COS 同源代理下载失败')
}
}
// 3) 同源 /uploads 路径
const sameOriginPath = toSameOriginPath(raw)
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),
})
} catch (e: unknown) {
const msg = extractOmsRequestError(e)
if (msg) errors.push(msg)
}
}
const detail = errors.find(Boolean)
throw new Error(detail || '无法通过服务端加载面单文件')
}
/** 经 COS 代理 / file-storage/stream 拉取面单 Blob(PDF / HTML) */
export async function fetchPrintPreviewBlob(rawUrl: string): Promise<Blob> {
return fetchPreviewBlob(rawUrl)
}
/** 开发环境优先返回同源 COS 代理 URL,供预览组件 fetch 后按字节渲染 */
export function resolvePrintPreviewPdfSource(rawUrl: string): string | null {
const raw = String(rawUrl || '').trim()
if (!raw || !isPrintPreviewPdf(raw)) return null
// 生产勿把 /__cos__ 交给前端直链:nginx 未配时会落到 SPA HTML
if (!import.meta.env.DEV) return null
return toCosProxyUrl(raw)
}
/** 拉取并校验 PDF 字节,供预览组件 / 静默打印使用 */
export async function fetchPrintPreviewPdfBytes(rawUrl: string): Promise<Uint8Array> {
const blob = await withPreviewTimeout(fetchPreviewBlob(rawUrl))
return normalizePdfBytes(blob, rawUrl)
}
/**
* 将打印 URL 转为同源 blob: 地址(HTML 回落预览等场景)。
*/
export async function resolvePrintPreviewSrc(rawUrl: string): Promise<string> {
const blob = await fetchPreviewBlob(rawUrl)
return URL.createObjectURL(blob)
}
export function isPrintPreviewPdf(rawUrl: string, blob?: Blob | null): boolean {
const mime = String(blob?.type || '').toLowerCase()
if (mime.includes('pdf')) return true
return /\.pdf(\?|#|$)/i.test(String(rawUrl || ''))
}
/** 供 hiprint 静默打印:优先经 COS 代理 / file-storage/stream 拉取字节 */
export async function fetchPrintAssetBytes(rawUrl: string): Promise<Uint8Array> {
const blob = await fetchPreviewBlob(rawUrl)
if (isPrintPreviewPdf(rawUrl, blob)) {
return normalizePdfBytes(blob, rawUrl)
}
return new Uint8Array(await blob.arrayBuffer())
}