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:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+463
View File
@@ -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) => ({ '&': '&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>
);
}