76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
464 lines
15 KiB
TypeScript
464 lines
15 KiB
TypeScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>
|
||
);
|
||
}
|