feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)

P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
freedakgmail
2026-08-09 11:59:02 +08:00
parent c355a7d208
commit a2e9ba55c2
43 changed files with 2913 additions and 324 deletions
+85 -30
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from "react"
import mammoth from "mammoth"
import api from '../../lib/api'
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
@@ -18,18 +19,36 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('附件')
const [wordHtml, setWordHtml] = useState<string | null>(null)
const uploadAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('附件已上传')
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已更新')
},
onError: () => toast.error('上传失败'),
})
const deleteAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已删除')
},
onError: () => toast.error('删除失败'),
})
const handleDeleteAttachment = async (contractId: string, atts: { name: string; url: string }[], idx: number) => {
if (!await confirm({ title: '删除附件', message: '确定删除此附件?删除后不可恢复。' })) return
const newAtts = atts.filter((_, i) => i !== idx)
deleteAttachmentMutation.mutate({ contractId, attachmentUrl: newAtts.length > 0 ? JSON.stringify(newAtts) : '' })
}
const handleSupplementUpload = (e: React.ChangeEvent<HTMLInputElement>, contractId: string, existingAtts: { name: string; url: string }[]) => {
const files = e.target.files
if (!files || files.length === 0) return
@@ -332,31 +351,42 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<button onClick={() => { setPreviewName(att.name); 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"
className="text-gray-400 hover:text-primary ml-2 shrink-0"
title="下载附件"
onClick={() => {
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 = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
const a = document.createElement('a')
a.href = blobUrl
a.download = att.name
a.click()
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
}}
>
<Download className="w-3 h-3" />
</button>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
type="button"
className="text-gray-400 hover:text-primary"
title="下载附件"
onClick={() => {
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 = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
const a = document.createElement('a')
a.href = blobUrl
a.download = att.name
a.click()
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
}}
>
<Download className="w-3 h-3" />
</button>
<button
type="button"
className="text-gray-400 hover:text-danger"
title="删除附件"
disabled={deleteAttachmentMutation.isPending}
onClick={() => handleDeleteAttachment(c.id, atts, idx)}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
<input id={`contract-file-${c.id}`} type="file" multiple className="hidden" onChange={(e) => handleSupplementUpload(e, c.id, atts)} />
@@ -405,12 +435,28 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
const isWord = mime === 'application/msword' || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || previewName.endsWith('.doc') || previewName.endsWith('.docx')
// 如果是 Word 文件且尚未转换,异步转换
if (isWord && !wordHtml) {
fetch(blobUrl)
.then(r => r.arrayBuffer())
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
.then(result => setWordHtml(result.value))
.catch(() => setWordHtml('<p style="text-align:center;color:#999;">Word 文件转换失败,请下载查看</p>'))
}
const closePreview = () => {
if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl)
setPreviewUrl(null)
setWordHtml(null)
}
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="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={closePreview}>
<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>
<span className="text-sm font-medium"> - {previewName}</span>
<div className="flex items-center gap-2">
<button type="button" onClick={() => {
const a = document.createElement('a')
@@ -422,7 +468,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
}} className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</button>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<button onClick={closePreview} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
@@ -432,6 +478,15 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<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" />
) : isWord ? (
wordHtml ? (
<div className="prose prose-sm max-w-none w-full" dangerouslySetInnerHTML={{ __html: wordHtml }} />
) : (
<div className="text-center space-y-3">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
<p className="text-sm text-gray-500"> Word ...</p>
</div>
)
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />