feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式
This commit is contained in:
@@ -8,7 +8,6 @@ import MobileTabBar from './components/layout/MobileTabBar'
|
||||
import PortalLayout from './components/layout/PortalLayout'
|
||||
import PageContainer from './components/layout/PageContainer'
|
||||
import { SkeletonPage } from './components/ui/Skeleton'
|
||||
import OnboardingGuide from './components/OnboardingGuide'
|
||||
|
||||
const Login = lazy(() => import('./pages/auth/Login'))
|
||||
const Register = lazy(() => import('./pages/auth/Register'))
|
||||
@@ -62,7 +61,6 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
</PageContainer>
|
||||
</main>
|
||||
<MobileTabBar />
|
||||
<OnboardingGuide />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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">支持 PDF、图片、Word、Excel 等格式,每个文件最大 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ function BatchManager() {
|
||||
setShowCreateModal(false)
|
||||
toast.success('发薪批次已创建')
|
||||
},
|
||||
onError: () => toast.error('创建失败'),
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
const deleteBatchMutation = useMutation({
|
||||
@@ -140,11 +140,26 @@ function BatchManager() {
|
||||
onError: () => toast.error('重命名失败'),
|
||||
})
|
||||
|
||||
const unarchiveBatchMutation = useMutation({
|
||||
mutationFn: (batchId: string) => api.post(`/payroll2/batches/${batchId}/unarchive`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
toast.success('已取消归档,批次恢复为草稿状态')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
|
||||
})
|
||||
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
|
||||
if (selectedBatchId) {
|
||||
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
|
||||
return <BatchDetail batchId={selectedBatchId} onBack={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
|
||||
setSelectedBatchId(null)
|
||||
}} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -190,7 +205,14 @@ function BatchManager() {
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap shrink-0">{batches.length} 个批次</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button onClick={() => { setCreateType('REGULAR'); setShowCreateModal(true) }} className="shrink-0">
|
||||
<Button onClick={() => {
|
||||
const hasDraft = batches?.some((b: any) => b.status === 'DRAFT' && b.month === month)
|
||||
if (hasDraft) {
|
||||
toast.error('当月存在未归档的批次,请先归档后再创建新批次')
|
||||
return
|
||||
}
|
||||
setCreateType('REGULAR'); setShowCreateModal(true)
|
||||
}} className="shrink-0">
|
||||
<Plus className="w-4 h-4 mr-1" />创建发薪批次
|
||||
</Button>
|
||||
</div>
|
||||
@@ -353,6 +375,21 @@ function BatchManager() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{batch.status === 'ARCHIVED' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '取消归档', message: `确认取消归档「${batch.name}」?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。`, variant: 'primary' })) {
|
||||
unarchiveBatchMutation.mutate(batch.id)
|
||||
}
|
||||
}}
|
||||
disabled={unarchiveBatchMutation.isPending}
|
||||
className="p-1 rounded hover:bg-amber-50 text-gray-500 hover:text-warning transition-colors"
|
||||
aria-label="取消归档"
|
||||
title="取消归档"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -391,12 +428,16 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
api.put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
},
|
||||
})
|
||||
|
||||
const removeEmployeeMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => api.delete(`/payroll2/batches/${batchId}/employees/${employeeId}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['batch-detail'] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
},
|
||||
})
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
@@ -410,10 +451,23 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
},
|
||||
})
|
||||
|
||||
const unarchiveMutation = useMutation({
|
||||
mutationFn: () => api.post(`/payroll2/batches/${batchId}/unarchive`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
toast.success('已取消归档,批次恢复为草稿状态')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
|
||||
})
|
||||
|
||||
const importOvertimeMutation = useMutation({
|
||||
mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
|
||||
if (res.data?.imported > 0) {
|
||||
toast.success(`成功导入 ${res.data.imported} 条加班费记录`)
|
||||
@@ -443,6 +497,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
setPayrollImportResult(data)
|
||||
if (data.updated > 0) {
|
||||
toast.success(`成功更新 ${data.updated} 条工资记录`)
|
||||
@@ -640,11 +695,25 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
</div>
|
||||
)}
|
||||
{isArchived && (
|
||||
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Download className="w-4 h-4 mr-1" />银行代发文件
|
||||
<div className="flex gap-2">
|
||||
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Download className="w-4 h-4 mr-1" />银行代发文件
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '取消归档', message: '确认取消归档?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。', variant: 'primary' })) {
|
||||
unarchiveMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={unarchiveMutation.isPending}
|
||||
>
|
||||
{unarchiveMutation.isPending ? '取消中...' : '取消归档'}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -816,6 +885,7 @@ function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: ()
|
||||
mutationFn: (employeeIds: string[]) => api.post(`/payroll2/batches/${batchId}/employees`, { employeeIds }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import Pagination from '../components/ui/Pagination'
|
||||
import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared'
|
||||
import EmployeeProfile from './roster/EmployeeProfile'
|
||||
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
|
||||
import { ImportSettings } from './Settings'
|
||||
|
||||
export default function Roster() {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -21,6 +22,7 @@ export default function Roster() {
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebouncedValue(search, 300)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [showResignModal, setShowResignModal] = useState(false)
|
||||
const [resignEmployee, setResignEmployee] = useState<any>(null)
|
||||
const [showRehireModal, setShowRehireModal] = useState(false)
|
||||
@@ -263,7 +265,7 @@ export default function Roster() {
|
||||
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" />添加员工
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.hash = '#/settings'} className="h-9 shrink-0">
|
||||
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
|
||||
<Upload className="mr-1.5 h-4 w-4" />批量导入
|
||||
</Button>
|
||||
</div>
|
||||
@@ -536,6 +538,12 @@ export default function Roster() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && (
|
||||
<Modal open={showImportModal} onClose={() => setShowImportModal(false)} title="批量导入" size="lg">
|
||||
<ImportSettings />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showResignModal && resignEmployee && (
|
||||
<ResignModal
|
||||
employee={resignEmployee}
|
||||
|
||||
@@ -775,7 +775,7 @@ function NotificationSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
function ImportSettings() {
|
||||
export function ImportSettings() {
|
||||
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock } from 'lucide-react'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -16,6 +16,8 @@ export default function SocialInsurance() {
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [showAddCity, setShowAddCity] = useState(false)
|
||||
const [newCityName, setNewCityName] = useState('')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
@@ -161,14 +163,14 @@ export default function SocialInsurance() {
|
||||
|
||||
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/social/calculate', { base }) as any
|
||||
const res = await api.post('/social/calculate', { base, city }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/social/housing-calculate', { base }) as any
|
||||
const res = await api.post('/social/housing-calculate', { base, city }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
@@ -193,6 +195,45 @@ export default function SocialInsurance() {
|
||||
},
|
||||
})
|
||||
|
||||
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
|
||||
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
|
||||
const res = await api.post('/social/ai-suggest', vars) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (isHousing) {
|
||||
activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
baseMin: data.baseMin ?? activeNewVersion.baseMin,
|
||||
baseMax: data.baseMax ?? activeNewVersion.baseMax,
|
||||
housingOrg: data.housingOrg ?? activeNewVersion.housingOrg,
|
||||
housingEmp: data.housingEmp ?? activeNewVersion.housingEmp,
|
||||
})
|
||||
} else {
|
||||
activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
baseMin: data.baseMin ?? activeNewVersion.baseMin,
|
||||
baseMax: data.baseMax ?? activeNewVersion.baseMax,
|
||||
medicalBaseMin: data.medicalBaseMin ?? 0,
|
||||
medicalBaseMax: data.medicalBaseMax ?? 0,
|
||||
pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg,
|
||||
pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp,
|
||||
medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg,
|
||||
medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp,
|
||||
unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg,
|
||||
unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp,
|
||||
injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg,
|
||||
maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg,
|
||||
extraInsurances: data.extraInsurances ?? [],
|
||||
})
|
||||
}
|
||||
toast.success('AI建议已填入,请核对后保存')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('AI建议获取失败,请手动填写')
|
||||
},
|
||||
})
|
||||
|
||||
const previewAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
|
||||
@@ -337,16 +378,52 @@ export default function SocialInsurance() {
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-sm text-gray-500">城市:</label>
|
||||
<input
|
||||
list="social-cities"
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="输入或选择城市"
|
||||
/>
|
||||
<datalist id="social-cities">
|
||||
{cities.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
{showAddCity ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className="h-9 w-24 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={newCityName}
|
||||
onChange={(e) => setNewCityName(e.target.value)}
|
||||
placeholder="城市名"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newCityName.trim()) {
|
||||
setCity(newCityName.trim())
|
||||
setNewCityName('')
|
||||
setShowAddCity(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
|
||||
}
|
||||
if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') }
|
||||
}}
|
||||
/>
|
||||
<button className="h-9 px-2 text-xs text-primary" onClick={() => {
|
||||
if (newCityName.trim()) {
|
||||
setCity(newCityName.trim())
|
||||
setNewCityName('')
|
||||
setShowAddCity(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
|
||||
}
|
||||
}}>确定</button>
|
||||
<button className="h-9 px-2 text-xs text-gray-400" onClick={() => { setShowAddCity(false); setNewCityName('') }}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
{cities.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<button
|
||||
className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-400 hover:text-primary hover:border-primary transition"
|
||||
title="添加新城市"
|
||||
onClick={() => setShowAddCity(true)}
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -415,6 +492,17 @@ export default function SocialInsurance() {
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">失业(企业/个人)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">工伤(企业)</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">生育(企业)</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
|
||||
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
|
||||
<div key={idx} className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-500">{ins.name}(企业/个人)</span>
|
||||
<span className="font-medium">
|
||||
{ins.baseType === 'fixed'
|
||||
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
|
||||
: `${ins.orgRate}% / ${ins.empRate}%`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -573,9 +661,22 @@ export default function SocialInsurance() {
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
|
||||
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
|
||||
disabled={aiSuggestMut.isPending}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
|
||||
</button>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div><Label>生效月份</Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
|
||||
<div><Label>城市</Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
|
||||
<div><Label>城市</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })}>
|
||||
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div><Label>缴费基数下限</Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
|
||||
<div><Label>缴费基数上限</Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
|
||||
@@ -29,11 +29,10 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
|
||||
if (!file) return
|
||||
|
||||
// 文件类型校验
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
if (!allowedExts.includes(ext)) {
|
||||
toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,10 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
if (!allowedExts.includes(ext)) {
|
||||
toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式')
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
|
||||
@@ -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">支持 PDF、JPG、PNG、HEIC、GIF、BMP、WebP、TIFF、Word、Excel 等格式,每个文件最大 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user