feat: 附件管理拆为独立tab + 员工端附件上传

管理端:
- 花名册详情新增「附件资料」独立tab(人事信息分组下)
- 从 BasicInfo 移除附件管理代码到独立 AttachmentsTab 组件
- shared.ts 添加 attachments tab 类型和计数映射

员工端:
- 后端新增 GET/POST/DELETE /portal/attachments 接口
- 前端 api-services 添加 portalDelete + 附件 API 方法
- MyProfile 新增附件资料卡片,支持上传/查看/删除附件
- 附件类型:身份证/银行卡/学历证书/职业资格证书/合同/照片/其他
This commit is contained in:
freedakgmail
2026-08-17 19:27:45 +08:00
parent 7b2f722984
commit 007109e425
7 changed files with 321 additions and 50 deletions
+42 -1
View File
@@ -1027,7 +1027,48 @@ router.put('/profile', portalAuth, async (req: any, res, next) => {
} catch (err) { next(err) }
})
// ========== 员工端:离职申请 ==========
// ========== 员工端:附件管理 ==========
// 查看自己的附件列表
router.get('/attachments', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const attachments = await prisma.employeeAttachment.findMany({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: attachments })
} catch (err) { next(err) }
})
// 上传附件(文件URL由前端 FileReader 转为 base64 后传入)
router.post('/attachments', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { fileName, fileType, fileUrl, fileSize } = req.body
if (!fileName || !fileUrl) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '文件名和文件内容不能为空' } })
}
const attachment = await prisma.employeeAttachment.create({
data: { orgId, employeeId, fileName, fileType: fileType || 'OTHER', fileUrl, fileSize: fileSize || 0 },
})
res.json({ success: true, data: attachment })
} catch (err) { next(err) }
})
// 删除自己的附件
router.delete('/attachments/:id', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const attachment = await prisma.employeeAttachment.findFirst({
where: { id: req.params.id, employeeId, orgId },
})
if (!attachment) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '附件不存在' } })
}
await prisma.employeeAttachment.delete({ where: { id: attachment.id } })
res.json({ success: true })
} catch (err) { next(err) }
})
// 提交离职申请
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
try {
+10
View File
@@ -1130,6 +1130,7 @@ portalAxios.interceptors.response.use(
const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any
const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any
const portalPut = ((url: string, data?: any, config?: any) => portalAxios.put(url, data, config)) as any
const portalDelete = ((url: string, config?: any) => portalAxios.delete(url, config)) as any
export const portalApi = {
/** 登录 */
@@ -1198,6 +1199,15 @@ export const portalApi = {
/** 更新个人资料 */
updateProfile: (data: Record<string, unknown>) =>
portalPut('/profile', data).then(unwrap<any>()),
/** 获取附件列表 */
getAttachments: () =>
portalGet('/attachments').then(unwrap<any[]>()),
/** 上传附件 */
addAttachment: (data: Record<string, unknown>) =>
portalPost('/attachments', data).then(unwrap<any>()),
/** 删除附件 */
removeAttachment: (id: string) =>
portalDelete(`/attachments/${id}`),
/** 制度列表 */
policies: () =>
portalGet('/policies').then(unwrap<any[]>()),
+132 -2
View File
@@ -3,9 +3,9 @@
* 员工可查看自己的入职资料,并编辑部分字段(紧急联系人、地址、银行卡、学历等)
* 数据直接同步到花名册(Employee 表)
*/
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { User, Phone, IdCard, MapPin, Banknote, GraduationCap, AlertCircle, Check, Save, Briefcase, Calendar } from 'lucide-react'
import { User, Phone, IdCard, MapPin, Banknote, GraduationCap, AlertCircle, Check, Save, Briefcase, Calendar, Paperclip, Trash2, Eye, Upload } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
@@ -307,6 +307,136 @@ export default function MyProfile() {
</div>
)}
</Card>
{/* 附件资料卡片 */}
<AttachmentCard />
</div>
)
}
/** 附件资料卡片 — 员工可上传/查看/删除自己的附件 */
function AttachmentCard() {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const { data: attachments = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-attachments'],
queryFn: () => portalApi.getAttachments(),
})
const addMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => portalApi.addAttachment(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['portal-attachments'] }),
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '上传失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => portalApi.removeAttachment(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['portal-attachments'] }),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedExts.includes(ext)) {
toast.error('不支持的文件格式')
return
}
if (file.size > 10 * 1024 * 1024) {
toast.error('文件过大,请上传小于 10MB 的文件')
return
}
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addMutation.mutate({ fileName: file.name, fileType, fileUrl, fileSize: file.size })
}
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = {
ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书',
CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他',
}
const fileTypeColors: Record<string, string> = {
ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe',
EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600',
CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500',
}
const formatSize = (bytes: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
return (
<Card>
<div className="flex items-center gap-2 mb-4">
<Paperclip className="w-4 h-4 text-primary" />
<h2 className="text-sm font-medium">{attachments.length}</h2>
</div>
<div className="flex gap-2 items-center mb-3">
<select
value={fileType}
onChange={(e) => setFileType(e.target.value as any)}
className="h-9 px-2 rounded-md border border-gray-300 text-xs"
>
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="EDUCATION"></option>
<option value="CERTIFICATE"></option>
<option value="CONTRACT"></option>
<option value="PHOTO"></option>
<option value="OTHER"></option>
</select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addMutation.isPending}>
<Upload className="w-3.5 h-3.5 mr-1" />
{addMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</div>
{isLoading ? (
<div className="text-center py-4 text-gray-400 text-xs">...</div>
) : attachments.length > 0 ? (
<div className="space-y-1.5">
{attachments.map((att) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
<Eye className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`确认删除「${att.fileName}」?`)) deleteMutation.mutate(att.id)
}}
className="text-gray-300 hover:text-red-500"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-gray-400 text-xs text-center py-4"></div>
)}
</Card>
)
}
@@ -0,0 +1,132 @@
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { attachmentApi } from '../../lib/api-services'
import { useConfirm } from '../../hooks/useConfirm'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Select } from '../../components/ui/Input'
import { Paperclip, Trash2, Eye, Download } from 'lucide-react'
const fileTypeLabels: Record<string, string> = {
ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书',
CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', CONTRACT_SCAN: '合同扫描件',
PHOTO: '员工照片', OTHER: '其他',
TERMINATION_DOC: '离职文件', RETIREMENT_DOC: '退休文件',
INJURY_CERT: '工伤证明', MEDICAL_CERT: '医疗证明', PREGNANCY_CERT: '孕期证明',
DISCIPLINARY: '违纪文件',
}
const fileTypeColors: Record<string, string> = {
ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe',
EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600',
CONTRACT: 'bg-cyan-50 text-cyan-600', CONTRACT_SCAN: 'bg-cyan-50 text-cyan-600',
PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500',
TERMINATION_DOC: 'bg-red-50 text-red-600', RETIREMENT_DOC: 'bg-orange-50 text-orange-600',
INJURY_CERT: 'bg-red-50 text-red-600', MEDICAL_CERT: 'bg-orange-50 text-orange-600',
PREGNANCY_CERT: 'bg-pink-50 text-pink-600', DISCIPLINARY: 'bg-red-50 text-red-600',
}
const formatSize = (bytes: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
export default function AttachmentsTab({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const deleteAttachmentMutation = useMutation({
mutationFn: (id: string) => attachmentApi.remove(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
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 (!allowedExts.includes(ext)) {
toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式')
return
}
if (file.size > 10 * 1024 * 1024) {
toast.error('文件过大,请上传小于 10MB 的文件')
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 })
}
reader.readAsDataURL(file)
}
const handleDelete = async (att: any) => {
const ok = await confirm({ title: '删除附件', message: `确认删除「${att.fileName}」?` })
if (ok) deleteAttachmentMutation.mutate(att.id)
}
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-medium">{attachments?.length || 0}</h2>
<div className="flex gap-2 items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="EDUCATION"></option>
<option value="CERTIFICATE"></option>
<option value="CONTRACT"></option>
<option value="PHOTO"></option>
<option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</div>
</div>
{attachments?.length ? (
<div className="space-y-1.5">
{attachments.map((att) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
<Eye className="w-3.5 h-3.5" />
</button>
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
<Download className="w-3.5 h-3.5" />
</a>
<button onClick={() => handleDelete(att)} className="text-gray-300 hover:text-danger" title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-gray-400 text-xs text-center py-8"></div>
)}
</Card>
)
}
-46
View File
@@ -563,52 +563,6 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
)}
{/* 附件管理 */}
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-medium text-gray-600">{attachments?.length || 0}</h3>
{!editing && (
<div className="flex gap-2 items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="CERTIFICATE"></option><option value="CONTRACT"></option><option value="PHOTO"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</div>
)}
</div>
{!editing && attachments?.length ? (
<div className="space-y-1.5">
{attachments.map((att) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
<Eye className="w-3.5 h-3.5" />
</button>
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
<Download className="w-3.5 h-3.5" />
</a>
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
) : !editing ? <div className="text-gray-400 text-xs text-center py-3"></div> : null}
</div>
</Card>
)
}
@@ -13,6 +13,7 @@ import PerformanceInfo from './PerformanceInfo'
import TerminationInfo from './TerminationInfo'
import ChangeHistoryTab from './ChangeHistoryTab'
import EvidenceChain from './EvidenceChain'
import AttachmentsTab from './AttachmentsTab'
/**
* 使 EmployeeProfileShell
@@ -62,6 +63,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
{(activeTab) => (
<>
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
{activeTab === 'attachments' && <AttachmentsTab employeeId={employeeId} attachments={profile.attachments} />}
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} employee={profile} />}
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
+3 -1
View File
@@ -13,7 +13,7 @@ export const terminateReasonMap: Record<string, string> = {
}
/** 详情页 Tab 类型 */
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'evidence' | 'history'
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'evidence' | 'history' | 'attachments'
/** Tab 分组 */
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
@@ -25,6 +25,7 @@ export const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: strin
tabs: [
{ key: 'basic', label: '基本信息', icon: null },
{ key: 'contract', label: '劳动合同', icon: null },
{ key: 'attachments', label: '附件资料', icon: null },
],
},
{
@@ -64,4 +65,5 @@ export const TAB_COUNT_KEYS: Record<string, string> = {
disciplinary: 'disciplinaryRecords',
performance: 'performanceRecords',
termination: 'terminations',
attachments: 'attachments',
}