feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式

This commit is contained in:
freedakgmail
2026-07-27 23:45:22 +08:00
parent 71d7ab2de5
commit 5e22a82163
21 changed files with 1246 additions and 178 deletions
+103 -24
View File
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X, FileText } from 'lucide-react'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download, Eye } from 'lucide-react'
import { toast } from 'sonner'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -351,6 +352,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const { data: employee } = useQuery<any>({
queryKey: ['employee-detail', employeeId],
@@ -379,26 +381,42 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({
employeeId,
fileName: file.name,
fileType,
fileUrl,
fileSize: file.size,
})
const files = e.target.files
if (!files || files.length === 0) return
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const maxSize = 10 * 1024 * 1024
for (const file of Array.from(files)) {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedExts.includes(ext)) {
toast.error(`不支持的文件格式: ${file.name}`)
continue
}
if (file.size > maxSize) {
toast.error(`文件过大: ${file.name}(最大 10MB`)
continue
}
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({
employeeId,
fileName: file.name,
fileType,
fileUrl,
fileSize: file.size,
})
}
reader.readAsDataURL(file)
}
reader.readAsDataURL(file)
e.target.value = ''
}
const fileTypeLabels: Record<string, string> = {
ID_CARD: '身份证',
BANK_CARD: '银行卡',
CONTRACT_SCAN: '合同扫描件',
CONTRACT_SCAN: '合同件',
EDUCATION: '学历证书',
OTHER: '其他',
}
@@ -474,13 +492,14 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="CONTRACT_SCAN"></option>
<option value="CONTRACT_SCAN"></option>
<option value="EDUCATION"></option>
<option value="OTHER"></option>
</Select>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileUpload}
/>
@@ -490,9 +509,10 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
onClick={() => fileInputRef.current?.click()}
disabled={addAttachmentMutation.isPending}
>
{addAttachmentMutation.isPending ? '上传中...' : '上传'}
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</div>
<p className="text-xs text-gray-400 mb-2"> PDFWordExcel 10MB</p>
{attachments && attachments.length > 0 ? (
<div className="space-y-2">
@@ -501,18 +521,26 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate">{att.fileName}</div>
<button onClick={() => setPreviewUrl(att.fileUrl)} className="text-primary hover:underline truncate text-left">
{att.fileName}
</button>
<div className="text-xs text-gray-400">
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
</div>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger shrink-0 ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 shrink-0 ml-2">
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-primary p-1" title="下载">
<Download className="w-4 h-4" />
</a>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger p-1"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
@@ -522,6 +550,57 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
</div>
</div>
</div>
{/* 附件预览弹窗 */}
{previewUrl && (() => {
const dataToBlobUrl = (dataUrl: string) => {
try {
const arr = dataUrl.split(',')
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
const bstr = atob(arr[1])
const u8 = new Uint8Array(bstr.length)
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
return URL.createObjectURL(new Blob([u8], { type: mime }))
} catch { return dataUrl }
}
const blobUrl = previewUrl.startsWith('data:') ? dataToBlobUrl(previewUrl) : previewUrl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{isImage ? (
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
) : isPdf ? (
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</div>
)
})()}
</div>
)
}