import { Suspense, lazy, useState } from 'react'; import { Button, Modal, Space, Spin, Upload, message } from 'antd'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '../api/client'; const PdfPreview = lazy(() => import('./PdfPreview').then((m) => ({ default: m.PdfPreview }))); type FileRow = { id: string; fileName: string; size: number; mimeType: string; bizType?: string }; export const FILE_KIND_LABEL: Record = { BID_ANNOUNCE: '招标 / 询价文件', BID_FILE: '投标附件', BID_FORMAL: '正式标书', BID_PROOF: '递交 / 保证金证明', CONTRACT: '合同扫描件', CONTRACT_ACCEPT: '验收单', CONTRACT_AWARD: '中标通知书', PROJECT: '项目附件', PROJECT_ACCEPT: '验收材料', PURCHASE: '采购合同', PURCHASE_ACCEPT: '采购验收', EXPENSE: '发票 / 凭证', LOAN: '借据 / 凭证', USER_AVATAR: '头像', USER_ID_PHOTO: '证件照', EMPLOYEE_ID_PHOTO: '证件照', EMPLOYEE_ID_FRONT: '身份证正面', EMPLOYEE_ID_BACK: '身份证反面', EMPLOYEE_CERT: '个人证书', EMPLOYEE_LABOR: '劳动合同', NOTICE: '通知附件', }; /** 纠正 multer latin1 解出来的中文文件名。 */ export function displayFileName(name: string) { if (!name) return '附件'; if (/[\u4e00-\u9fff]/.test(name)) return name; try { const bytes = Uint8Array.from(name, (c) => c.charCodeAt(0) & 0xff); const decoded = new TextDecoder('utf-8').decode(bytes); if (/[\u4e00-\u9fff]/.test(decoded) && !decoded.includes('\uFFFD')) return decoded; } catch { /* keep original */ } return name; } function sniffMime(bytes: Uint8Array, name?: string, declared?: string) { const declaredType = (declared || '').toLowerCase(); if (bytes.length >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) { return 'application/pdf'; } if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { return 'image/png'; } if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'; if (declaredType.startsWith('image/') || declaredType === 'application/pdf') return declaredType; if (/\.pdf$/i.test(name || '')) return 'application/pdf'; if (/\.(png|jpe?g|gif|webp|bmp)$/i.test(name || '')) { const ext = (name || '').split('.').pop()?.toLowerCase(); if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'; if (ext === 'png') return 'image/png'; if (ext === 'gif') return 'image/gif'; if (ext === 'webp') return 'image/webp'; if (ext === 'bmp') return 'image/bmp'; } return declaredType; } function isPreviewable(name: string, mime?: string) { const type = (mime || '').toLowerCase(); if (type.startsWith('image/') || type === 'application/pdf') return true; return /\.(png|jpe?g|gif|webp|bmp|pdf)$/i.test(name); } async function fetchBizFileBlob(id: string, mime?: string, name?: string) { const raw = (await api.get(`/files/${id}`, { responseType: 'blob' })) as unknown; let blob: Blob | null = null; if (raw instanceof Blob) blob = raw; else if (raw && typeof raw === 'object' && (raw as { data?: unknown }).data instanceof Blob) { blob = (raw as { data: Blob }).data; } if (!blob) throw new Error('无法读取附件'); const head = new Uint8Array(await blob.slice(0, 24).arrayBuffer()); const headText = new TextDecoder().decode(head).trimStart(); if (headText.startsWith('{') || headText.startsWith('<')) { const full = await blob.text(); try { const parsed = JSON.parse(full) as { message?: string }; throw new Error(parsed.message || '无法打开附件'); } catch (e) { if (e instanceof Error && e.message !== '无法打开附件' && !e.message.includes('JSON')) throw e; throw new Error('无法打开附件'); } } const type = sniffMime(head, name, mime || blob.type); if (type && blob.type !== type) return new Blob([await blob.arrayBuffer()], { type }); return blob; } export async function downloadBizFile(id: string, name: string) { const blob = await fetchBizFileBlob(id, undefined, name); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = displayFileName(name); a.click(); URL.revokeObjectURL(url); } export function nonImageFiles(files?: FileRow[]) { return (files || []).filter((f) => !(f.mimeType || '').startsWith('image/')); } export async function uploadBizFile(bizType: string, bizId: string, file: File) { const fd = new FormData(); fd.append('file', file); fd.append('bizType', bizType); fd.append('bizId', bizId); const res = (await api.post('/files', fd)) as { data: FileRow }; return res.data; } export function IdScanUpload({ label, bizType, bizId, fileId, onDone, }: { label: string; bizType: string; bizId: string; fileId?: string | null; onDone?: () => void | Promise; }) { return ( { try { const fd = new FormData(); fd.append('file', opt.file as File); fd.append('bizType', bizType); fd.append('bizId', bizId); await api.post('/files', fd); message.success(`${label}已上传`); await onDone?.(); opt.onSuccess?.({}, new XMLHttpRequest()); } catch (e) { message.error((e as Error).message); opt.onError?.(e as Error); } }} > {fileId ? ( ) : ( 未上传 )} ); } export type FilePreview = | { kind: 'image'; url: string; fileName: string } | { kind: 'pdf'; data: ArrayBuffer; fileName: string } | { kind: 'html'; html: string; fileName: string }; export async function loadBizPreview(id: string, mime?: string, name?: string): Promise { const display = displayFileName(name || ''); const blob = await fetchBizFileBlob(id, mime, display); if (isPreviewable(display, blob.type)) { const isImage = (blob.type || '').startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp)$/i.test(display); if (isImage) { return { kind: 'image', url: URL.createObjectURL(blob), fileName: display || '图片' }; } const buf = await blob.arrayBuffer(); return { kind: 'pdf', data: buf.slice(0), fileName: display || '附件.pdf' }; } const buffer = await blob.arrayBuffer(); if (/\.docx$/i.test(display) || blob.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { const mammoth = await import('mammoth/mammoth.browser'); const result = await mammoth.convertToHtml({ arrayBuffer: buffer }); return { kind: 'html', fileName: display || 'Word 文档', html: `${result.value}`, }; } if (/\.(xlsx?|csv)$/i.test(display) || /spreadsheet|ms-excel|csv/i.test(blob.type)) { const XLSX = await import('xlsx'); const workbook = XLSX.read(buffer, { type: 'array' }); const sheets = workbook.SheetNames.map((sheetName) => { const heading = `

${sheetName.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)}

`; return heading + XLSX.utils.sheet_to_html(workbook.Sheets[sheetName]); }).join(''); return { kind: 'html', fileName: display || 'Excel 表格', html: `${sheets}`, }; } await downloadBizFile(id, display); message.info('旧版 Word .doc 等格式已下载,请用本地软件打开'); return null; } export function AttachmentPreviewModal({ preview, onClose, }: { preview: FilePreview | null; onClose: () => void; }) { return ( {preview?.kind === 'image' ? ( {preview.fileName} ) : preview?.kind === 'pdf' ? ( }> ) : preview?.kind === 'html' ? (