diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index a975864..23abe49 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -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 { diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 77fe103..342c495 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -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) => portalPut('/profile', data).then(unwrap()), + /** 获取附件列表 */ + getAttachments: () => + portalGet('/attachments').then(unwrap()), + /** 上传附件 */ + addAttachment: (data: Record) => + portalPost('/attachments', data).then(unwrap()), + /** 删除附件 */ + removeAttachment: (id: string) => + portalDelete(`/attachments/${id}`), /** 制度列表 */ policies: () => portalGet('/policies').then(unwrap()), diff --git a/frontend/src/pages/portal/MyProfile.tsx b/frontend/src/pages/portal/MyProfile.tsx index e91365c..0d941b3 100644 --- a/frontend/src/pages/portal/MyProfile.tsx +++ b/frontend/src/pages/portal/MyProfile.tsx @@ -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() { )} + + {/* 附件资料卡片 */} + ) } + +/** 附件资料卡片 — 员工可上传/查看/删除自己的附件 */ +function AttachmentCard() { + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD') + + const { data: attachments = [], isLoading } = useQuery({ + queryKey: ['portal-attachments'], + queryFn: () => portalApi.getAttachments(), + }) + + const addMutation = useMutation({ + mutationFn: (data: Record) => 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) => { + 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 = { + ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', + CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他', + } + const fileTypeColors: Record = { + 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 ( + +
+ +

附件资料({attachments.length}个)

+
+ +
+ + + +
+ + {isLoading ? ( +
加载中...
+ ) : attachments.length > 0 ? ( +
+ {attachments.map((att) => ( +
+
+ +
+
{att.fileName}
+
+ {fileTypeLabels[att.fileType] || att.fileType} + {formatSize(att.fileSize)} +
+
+
+
+ + +
+
+ ))} +
+ ) : ( +
暂无附件,请点击上方上传
+ )} +
+ ) +} diff --git a/frontend/src/pages/roster/AttachmentsTab.tsx b/frontend/src/pages/roster/AttachmentsTab.tsx new file mode 100644 index 0000000..5dfc8d5 --- /dev/null +++ b/frontend/src/pages/roster/AttachmentsTab.tsx @@ -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 = { + 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 = { + 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(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) => { + 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 ( + +
+

附件资料({attachments?.length || 0}个)

+
+ + + +
+
+ + {attachments?.length ? ( +
+ {attachments.map((att) => ( +
+
+ +
+
{att.fileName}
+
+ {fileTypeLabels[att.fileType] || att.fileType} + {formatSize(att.fileSize)} +
+
+
+
+ + + + + +
+
+ ))} +
+ ) : ( +
暂无附件,请点击右上角上传
+ )} +
+ ) +} diff --git a/frontend/src/pages/roster/BasicInfo.tsx b/frontend/src/pages/roster/BasicInfo.tsx index 146c684..5c0187b 100644 --- a/frontend/src/pages/roster/BasicInfo.tsx +++ b/frontend/src/pages/roster/BasicInfo.tsx @@ -563,52 +563,6 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil )} - {/* 附件管理 */} -
-
-

附件管理({attachments?.length || 0}个)

- {!editing && ( -
- - - -
- )} -
- {!editing && attachments?.length ? ( -
- {attachments.map((att) => ( -
-
- -
-
{att.fileName}
-
- {fileTypeLabels[att.fileType] || att.fileType} - {formatSize(att.fileSize)} -
-
-
-
- - - - - -
-
- ))} -
- ) : !editing ?
暂无附件
: null} -
) } diff --git a/frontend/src/pages/roster/EmployeeProfile.tsx b/frontend/src/pages/roster/EmployeeProfile.tsx index 6c78421..86950f5 100644 --- a/frontend/src/pages/roster/EmployeeProfile.tsx +++ b/frontend/src/pages/roster/EmployeeProfile.tsx @@ -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' && } + {activeTab === 'attachments' && } {activeTab === 'contract' && } {activeTab === 'payslip' && } {activeTab === 'disciplinary' && } diff --git a/frontend/src/pages/roster/shared.ts b/frontend/src/pages/roster/shared.ts index 3bfa314..1e3f46e 100644 --- a/frontend/src/pages/roster/shared.ts +++ b/frontend/src/pages/roster/shared.ts @@ -13,7 +13,7 @@ export const terminateReasonMap: Record = { } /** 详情页 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 = { disciplinary: 'disciplinaryRecords', performance: 'performanceRecords', termination: 'terminations', + attachments: 'attachments', }