Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user