All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m43s
开发环境避免不存在的 file-storage 404 挡住预览;分区明细改为前端生成 Excel。 Co-authored-by: Cursor <cursoragent@cursor.com>
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
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())
|
||
}
|