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
+142 -36
View File
@@ -12,40 +12,48 @@ import { fmt } from "./shared"
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', electronicContractNo: '', electronicContractUrl: '' })
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
const contractFileRef = useRef<HTMLInputElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const addContractMutation = useMutation({
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => api.delete(`/employees/contracts/${contractId}`),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已删除') },
})
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const files = e.target.files
if (!files || files.length === 0) return
// 文件类型校验
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
return
}
// 文件大小校验(10MB
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB`
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)}`)
return
}
const reader = new FileReader()
reader.onload = (event) => {
setForm({ ...form, attachmentUrl: event.target?.result as string })
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) => {
setForm(prev => ({
...prev,
attachments: [...prev.attachments, { name: file.name, url: event.target?.result as string }],
}))
}
reader.readAsDataURL(file)
}
reader.readAsDataURL(file)
// 清空 input 以便重复选择同一文件
e.target.value = ''
}
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
@@ -102,6 +110,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
probationSalary: 0,
signMethod: 'PAPER',
attachmentUrl: '',
attachments: [],
electronicContractNo: '',
electronicContractUrl: '',
})
@@ -158,14 +167,29 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
</div>
{form.signMethod === 'PAPER' && (
<div className="md:col-span-2">
<Label> *</Label>
<input ref={contractFileRef} type="file" className="hidden" onChange={handleContractFileUpload} />
<Label> *</Label>
<input ref={contractFileRef} type="file" multiple className="hidden" onChange={handleContractFileUpload} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
<Paperclip className="w-4 h-4 mr-1" />
<Paperclip className="w-4 h-4 mr-1" />
</Button>
{form.attachmentUrl && <span className="text-xs text-safe"> </span>}
{form.attachments.length > 0 && <span className="text-xs text-gray-500">{form.attachments.length} </span>}
</div>
{form.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{form.attachments.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button type="button" onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<button type="button" onClick={() => setForm(prev => ({ ...prev, attachments: prev.attachments.filter((_, i) => i !== idx) }))} className="text-danger hover:underline ml-2 shrink-0">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 mt-1"> PDFJPGPNGHEICGIFBMPWebPTIFFWordExcel 10MB</p>
</div>
)}
{form.signMethod === 'ELECTRONIC' && (
@@ -178,6 +202,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<Button onClick={() => {
const payload = {
...form,
attachmentUrl: form.attachments.length > 0 ? JSON.stringify(form.attachments) : '',
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
@@ -185,7 +210,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
addContractMutation.mutate(payload)
}} disabled={
addContractMutation.isPending || !form.startDate ||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
(form.signMethod === 'PAPER' && form.attachments.length === 0) ||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
}>
{addContractMutation.isPending ? '保存中...' : '保存'}
@@ -211,15 +236,36 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
{c.signMethod === 'PAPER' && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
{c.attachmentUrl ? (
<a href={c.attachmentUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<Paperclip className="w-3 h-3" />
</a>
) : (
<span className="text-gray-400"></span>
)}
<div className="md:col-span-3">
<span className="text-gray-500"></span>
{(() => {
let atts: { name: string; url: string }[] = []
try {
const parsed = JSON.parse(c.attachmentUrl)
atts = Array.isArray(parsed) ? parsed : [{ name: '附件', url: c.attachmentUrl }]
} catch {
if (c.attachmentUrl) {
const mime = c.attachmentUrl.match(/data:(.*?);/)?.[1] || ''
const ext = mime.split('/')[1] || '文件'
atts = [{ name: `附件.${ext}`, url: c.attachmentUrl }]
}
}
if (atts.length === 0) return <span className="text-gray-400 ml-2"></span>
return (
<div className="mt-1 space-y-1">
{atts.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<a href={att.url} download={att.name} className="text-gray-400 hover:text-primary ml-2 shrink-0">
<Download className="w-3 h-3" />
</a>
</div>
))}
</div>
)
})()}
</div>
)}
{c.signMethod === 'ELECTRONIC' && (
@@ -236,9 +282,69 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
</>
)}
</div>
<button
onClick={() => { if (confirm('确定删除此合同记录?')) deleteContractMutation.mutate(c.id) }}
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
title="删除合同"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Card>
))}
{/* 附件预览弹窗 */}
{previewUrl && (() => {
// 将 data URL 转为 blob URL,解决浏览器阻止 iframe 加载 data URL 的问题
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'
const previewable = isImage || isPdf
return (
<div className="fixed inset-0 z-50 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>
)
}