Files
Public/apps/web/src/pages/FileAttachments.tsx
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

464 lines
15 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 { 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<string, string> = {
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<void>;
}) {
return (
<Space wrap>
<Upload
showUploadList={false}
accept="image/*,.pdf"
customRequest={async (opt) => {
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);
}
}}
>
<Button>{fileId ? '重新上传' : `上传${label}`}</Button>
</Upload>
{fileId ? (
<BizFileActions
file={{
id: fileId,
fileName: `${label}.jpg`,
size: 0,
mimeType: 'image/jpeg',
bizType,
}}
/>
) : (
<span style={{ color: '#cf1322' }}></span>
)}
</Space>
);
}
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<FilePreview | null> {
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: `<style>body{font:15px/1.7 system-ui;padding:24px;color:#222}img{max-width:100%}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:6px}</style>${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 = `<h2>${sheetName.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)}</h2>`;
return heading + XLSX.utils.sheet_to_html(workbook.Sheets[sheetName]);
}).join('');
return {
kind: 'html',
fileName: display || 'Excel 表格',
html: `<style>body{font:14px/1.5 system-ui;padding:20px;color:#222}table{border-collapse:collapse;margin-bottom:28px}td,th{border:1px solid #bbb;padding:5px 8px;white-space:nowrap}tr:first-child{background:#f5f5f5}</style>${sheets}`,
};
}
await downloadBizFile(id, display);
message.info('旧版 Word .doc 等格式已下载,请用本地软件打开');
return null;
}
export function AttachmentPreviewModal({
preview,
onClose,
}: {
preview: FilePreview | null;
onClose: () => void;
}) {
return (
<Modal
title={preview?.fileName || '预览'}
open={Boolean(preview)}
onCancel={onClose}
footer={null}
width="80vw"
destroyOnClose
>
{preview?.kind === 'image' ? (
<img src={preview.url} alt={preview.fileName} style={{ width: '100%' }} />
) : preview?.kind === 'pdf' ? (
<Suspense fallback={<Spin />}>
<PdfPreview data={preview.data} fileName={preview.fileName} />
</Suspense>
) : preview?.kind === 'html' ? (
<iframe
title={preview.fileName}
sandbox=""
srcDoc={preview.html}
style={{ width: '100%', height: '72vh', border: 0 }}
/>
) : null}
</Modal>
);
}
export function BizFileActions({
file,
onDelete,
}: {
file: FileRow;
onDelete?: () => void;
}) {
const [preview, setPreview] = useState<FilePreview | null>(null);
const name = displayFileName(file.fileName);
const kindLabel = file.bizType && FILE_KIND_LABEL[file.bizType] ? `${FILE_KIND_LABEL[file.bizType]}` : '';
const closePreview = () => {
if (preview?.kind === 'image') URL.revokeObjectURL(preview.url);
setPreview(null);
};
const openPreview = async () => {
try {
const next = await loadBizPreview(file.id, file.mimeType, name);
if (next) setPreview(next);
} catch (e) {
message.error(e instanceof Error ? e.message : '无法打开附件');
}
};
return (
<>
<Button type="link" onClick={() => void openPreview()}>
{kindLabel}
{name}
</Button>
<Button type="link" onClick={() => void downloadBizFile(file.id, name)}>
</Button>
{onDelete ? (
<Button type="link" danger onClick={onDelete}>
</Button>
) : null}
<AttachmentPreviewModal preview={preview} onClose={closePreview} />
</>
);
}
export function BizFileList({ files, empty = '没有附件' }: { files?: FileRow[]; empty?: string }) {
const rows = files || [];
if (!rows.length) return <p style={{ color: 'var(--mute)' }}>{empty}</p>;
return (
<ul style={{ paddingLeft: 18, marginBottom: 16 }}>
{rows.map((f) => (
<li key={f.id}>
<BizFileActions file={f} />
</li>
))}
</ul>
);
}
export function BizFileUpload({
bizType,
bizId,
files,
disabled,
label,
extra,
queryKey,
onUploaded,
}: {
bizType: string;
bizId?: string;
files?: FileRow[];
disabled?: boolean;
label?: string;
extra?: string;
queryKey: unknown[];
onUploaded?: () => void;
}) {
const qc = useQueryClient();
const rows = files || [];
const del = useMutation({
mutationFn: (id: string) => api.delete(`/files/${id}`),
onSuccess: () => {
message.success('已删除附件');
qc.invalidateQueries({ queryKey });
onUploaded?.();
},
onError: (e: Error) => message.error(e.message),
});
return (
<div style={{ marginBottom: 16 }}>
{label ? <div style={{ marginBottom: 8 }}>{label}</div> : null}
{extra ? <p style={{ color: 'var(--mute)', marginBottom: 8 }}>{extra}</p> : null}
{!bizId ? (
<p style={{ color: 'var(--mute)' }}></p>
) : (
<>
<ul style={{ paddingLeft: 18 }}>
{rows.map((f) => (
<li key={f.id}>
<BizFileActions file={f} onDelete={disabled ? undefined : () => del.mutate(f.id)} />
</li>
))}
{!rows.length ? <li style={{ color: 'var(--mute)' }}></li> : null}
</ul>
{!disabled ? (
<Upload
multiple
showUploadList={false}
beforeUpload={() => true}
customRequest={async (opt) => {
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('已上传');
qc.invalidateQueries({ queryKey });
onUploaded?.();
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
const err = e as Error;
message.error(err.message);
opt.onError?.(err);
}
}}
>
<Button></Button>
</Upload>
) : null}
</>
)}
</div>
);
}
export function FileAttachments({
bizType,
bizId,
disabled,
label,
}: {
bizType: 'EXPENSE' | 'LOAN';
bizId?: string;
disabled?: boolean;
label: string;
}) {
const qc = useQueryClient();
const q = useQuery({
queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId],
enabled: Boolean(bizId),
queryFn: () => api.get(bizType === 'EXPENSE' ? `/expenses/${bizId}` : `/loans/${bizId}`),
});
const files = ((q.data?.data?.files ?? []) as FileRow[]);
const del = useMutation({
mutationFn: (id: string) => api.delete(`/files/${id}`),
onSuccess: () => {
message.success('已删除附件');
qc.invalidateQueries({ queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>{label}</div>
{!bizId ? (
<p style={{ color: 'var(--mute)' }}>稿</p>
) : (
<>
<ul style={{ paddingLeft: 18 }}>
{files.map((f) => (
<li key={f.id}>
<BizFileActions file={f} onDelete={disabled ? undefined : () => del.mutate(f.id)} />
</li>
))}
{!files.length ? <li style={{ color: 'var(--mute)' }}></li> : null}
</ul>
{!disabled ? (
<Upload
showUploadList={false}
beforeUpload={() => true}
customRequest={async (opt) => {
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('已上传');
qc.invalidateQueries({ queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId] });
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
const err = e as Error;
message.error(err.message);
opt.onError?.(err);
}
}}
>
<Button> / </Button>
</Upload>
) : null}
</>
)}
</div>
);
}