From c4a929fc8e784d4c1009ef9eae48bdf295172531 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Mon, 17 Aug 2026 18:56:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=91=98=E5=B7=A5=E7=AB=AF=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=B8=AA=E4=BA=BA=E8=B5=84=E6=96=99=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E6=95=B0=E6=8D=AE=E5=90=8C=E6=AD=A5=E5=88=B0=E8=8A=B1?= =?UTF-8?q?=E5=90=8D=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 GET/PUT /portal/profile 接口,员工可查看和编辑个人资料 - 员工可编辑:紧急联系人、住址、银行卡、学历、参保城市 - 只读字段:姓名、性别、手机号、证件号码、部门、岗位、入职日期 - 数据直接写入 Employee 表(花名册),HR 管理端可见 - 更新操作记录证据链(PROFILE_UPDATE) - 前端新增 MyProfile.tsx 页面,PortalNav 添加导航入口 --- backend/src/routes/portal.routes.ts | 86 +++++++++ backend/src/services/evidence.service.ts | 1 + frontend/src/App.tsx | 2 + frontend/src/lib/api-services.ts | 7 + frontend/src/pages/portal/MyProfile.tsx | 221 +++++++++++++++++++++++ frontend/src/pages/portal/PortalNav.tsx | 3 +- 6 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/portal/MyProfile.tsx diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index 6521ca3..c61b87d 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -10,6 +10,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth' import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema' import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore' import { createEvidence, appendEvidence } from '../services/evidence.service' +import { encrypt, decrypt, sha256 } from '../lib/crypto' const router = Router() @@ -870,6 +871,91 @@ router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => { } catch (err) { next(err) } }) +// ========== 员工端:个人资料查看与编辑 ========== +// 查看个人资料 +router.get('/profile', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) + if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + + // 解密敏感字段 + let idCardNo = '' + let bankAccount = '' + try { if (emp.idCardNumber) idCardNo = decrypt(emp.idCardNumber) } catch {} + try { if (emp.bankAccount) bankAccount = decrypt(emp.bankAccount) } catch {} + + res.json({ + success: true, + data: { + name: emp.name, + gender: emp.gender || '', + phone: emp.phone || '', + idCardNo, + idType: emp.idType || 'ID_CARD', + department: emp.department, + position: emp.position || '', + hireDate: emp.hireDate ? emp.hireDate.toISOString().slice(0, 10) : '', + emergencyContact: emp.emergencyContact || '', + emergencyPhone: emp.emergencyPhone || '', + address: emp.address || '', + bankAccount, + bankName: emp.bankName || '', + education: emp.education || '', + city: emp.city || '', + birthDate: emp.birthDate ? emp.birthDate.toISOString().slice(0, 10) : '', + }, + }) + } catch (err) { next(err) } +}) + +// 更新个人资料(员工可编辑字段:紧急联系人、地址、银行卡、学历等) +router.put('/profile', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) + if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + + const { + emergencyContact, emergencyPhone, address, + bankAccount, bankName, education, city, + } = req.body + + // 构建更新数据(仅允许员工自行编辑的字段) + const updateData: any = {} + if (emergencyContact !== undefined) updateData.emergencyContact = emergencyContact || null + if (emergencyPhone !== undefined) updateData.emergencyPhone = emergencyPhone || null + if (address !== undefined) updateData.address = address || null + if (bankName !== undefined) updateData.bankName = bankName || null + if (education !== undefined) updateData.education = education || null + if (city !== undefined) updateData.city = city || null + // 银行卡号需要加密 + if (bankAccount !== undefined && bankAccount) { + updateData.bankAccount = encrypt(bankAccount) + } + + await prisma.employee.update({ where: { id: employeeId }, data: updateData }) + + // 记录证据链 + await createEvidence({ + orgId, + category: 'PROFILE_UPDATE', + refId: employeeId, + employeeId, + events: [{ + action: '员工更新个人资料', + timestamp: new Date().toISOString(), + ip: req.ip, + userAgent: req.headers['user-agent'], + location: `更新字段:${Object.keys(updateData).join(', ')}`, + }], + createdBy: employeeId, + }).catch(() => {}) + + res.json({ success: true, data: { message: '资料更新成功' } }) + } catch (err) { next(err) } +}) + // ========== 员工端:离职申请 ========== // 提交离职申请 router.post('/resignation/submit', portalAuth, async (req: any, res, next) => { diff --git a/backend/src/services/evidence.service.ts b/backend/src/services/evidence.service.ts index 6162c28..c29013f 100644 --- a/backend/src/services/evidence.service.ts +++ b/backend/src/services/evidence.service.ts @@ -45,6 +45,7 @@ export type EvidenceCategory = | 'TERMINATION' | 'TRAINING' | 'PERFORMANCE' + | 'PROFILE_UPDATE' /** * 创建证据链记录 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9458d23..fb41468 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,7 @@ const OnboardingProgress = lazyRetry(() => import('./pages/portal/OnboardingProg const ResignationApply = lazyRetry(() => import('./pages/portal/ResignationApply')) const MyEsign = lazyRetry(() => import('./pages/portal/MyEsign')) const MyRecords = lazyRetry(() => import('./pages/portal/MyRecords')) +const MyProfile = lazyRetry(() => import('./pages/portal/MyProfile')) const RiskCenter = lazyRetry(() => import('./pages/compliance/RiskCenter')) const SalaryDashboard = lazyRetry(() => import('./pages/SalaryDashboard')) const CommercialInsurance = lazyRetry(() => import('./pages/CommercialInsurance')) @@ -247,6 +248,7 @@ export default function App() { } /> } /> } /> + } /> {/* 兜底 */} } /> diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index a00c3b2..77fe103 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -1129,6 +1129,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 export const portalApi = { /** 登录 */ @@ -1191,6 +1192,12 @@ export const portalApi = { /** 入职进度 */ onboardingProgress: () => portalGet('/onboarding/progress').then(unwrap()), + /** 获取个人资料 */ + getProfile: () => + portalGet('/profile').then(unwrap()), + /** 更新个人资料 */ + updateProfile: (data: Record) => + portalPut('/profile', data).then(unwrap()), /** 制度列表 */ policies: () => portalGet('/policies').then(unwrap()), diff --git a/frontend/src/pages/portal/MyProfile.tsx b/frontend/src/pages/portal/MyProfile.tsx new file mode 100644 index 0000000..694bc76 --- /dev/null +++ b/frontend/src/pages/portal/MyProfile.tsx @@ -0,0 +1,221 @@ +/** + * 员工端 — 个人资料查看与编辑 + * 员工可查看自己的入职资料,并编辑部分字段(紧急联系人、地址、银行卡、学历等) + * 数据直接同步到花名册(Employee 表) + */ +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { User, Phone, IdCard, MapPin, Banknote, GraduationCap, AlertCircle, Check, Save } from 'lucide-react' +import { portalApi } from '../../lib/api-services' +import Card from '../../components/ui/Card' +import Button from '../../components/ui/Button' +import { Input, Label } from '../../components/ui/Input' +import { toast } from 'sonner' + +export default function MyProfile() { + const queryClient = useQueryClient() + const [editing, setEditing] = useState(false) + const [form, setForm] = useState({ + emergencyContact: '', + emergencyPhone: '', + address: '', + bankAccount: '', + bankName: '', + education: '', + city: '', + }) + + const { data: profile, isLoading } = useQuery({ + queryKey: ['portal-profile'], + queryFn: () => portalApi.getProfile(), + }) + + useEffect(() => { + if (profile) { + setForm({ + emergencyContact: profile.emergencyContact || '', + emergencyPhone: profile.emergencyPhone || '', + address: profile.address || '', + bankAccount: profile.bankAccount || '', + bankName: profile.bankName || '', + education: profile.education || '', + city: profile.city || '', + }) + } + }, [profile]) + + const updateMutation = useMutation({ + mutationFn: (data: Record) => portalApi.updateProfile(data), + onSuccess: () => { + toast.success('资料更新成功') + setEditing(false) + queryClient.invalidateQueries({ queryKey: ['portal-profile'] }) + }, + onError: (err: any) => { + toast.error(err?.response?.data?.error?.message || '更新失败') + }, + }) + + const handleSave = () => { + updateMutation.mutate(form) + } + + if (isLoading) { + return ( +
+
+
+
+ ) + } + + if (!profile) { + return
暂无个人资料信息
+ } + + const readOnlyFields = [ + { label: '姓名', value: profile.name, icon: User }, + { label: '性别', value: profile.gender === 'M' ? '男' : profile.gender === 'F' ? '女' : '未填写', icon: User }, + { label: '手机号', value: profile.phone || '未填写', icon: Phone }, + { label: '证件号码', value: profile.idCardNo || '未填写', icon: IdCard }, + { label: '部门', value: profile.department || '未填写', icon: User }, + { label: '岗位', value: profile.position || '未填写', icon: User }, + { label: '入职日期', value: profile.hireDate || '未填写', icon: User }, + { label: '出生日期', value: profile.birthDate || '未填写', icon: User }, + ] + + const educationOptions = ['博士', '硕士', '本科', '大专', '高中', '其他'] + + return ( +
+ {/* 只读信息卡片 */} + +
+ +

基本信息

+ (由 HR 维护,如需修改请联系 HR) +
+
+ {readOnlyFields.map((f) => { + const Icon = f.icon + return ( +
+ +
+
{f.label}
+
{f.value}
+
+
+ ) + })} +
+
+ + {/* 可编辑信息卡片 */} + +
+
+ +

入职资料补充

+
+ {!editing ? ( + + ) : ( +
+ + +
+ )} +
+ +
+
+ + setForm({ ...form, emergencyContact: e.target.value })} + placeholder="请输入紧急联系人姓名" + disabled={!editing} + /> +
+
+ + setForm({ ...form, emergencyPhone: e.target.value })} + placeholder="请输入紧急联系人电话" + maxLength={11} + disabled={!editing} + /> +
+
+ + setForm({ ...form, address: e.target.value })} + placeholder="请输入现住址" + disabled={!editing} + /> +
+
+ + setForm({ ...form, bankAccount: e.target.value })} + placeholder="请输入银行卡号" + disabled={!editing} + /> +
+
+ + setForm({ ...form, bankName: e.target.value })} + placeholder="如:工商银行xx支行" + disabled={!editing} + /> +
+
+ + {editing ? ( + + ) : ( + + )} +
+
+ + setForm({ ...form, city: e.target.value })} + placeholder="如:北京" + disabled={!editing} + /> +
+
+ + {editing && ( +
+ + 保存后资料将同步到花名册,HR 可在管理端查看 +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/portal/PortalNav.tsx b/frontend/src/pages/portal/PortalNav.tsx index 34e9c2b..8d454e3 100644 --- a/frontend/src/pages/portal/PortalNav.tsx +++ b/frontend/src/pages/portal/PortalNav.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { DollarSign, FileText, ScrollText, LogOut, PenTool, ClipboardList, KeyRound } from 'lucide-react' +import { DollarSign, FileText, ScrollText, LogOut, PenTool, ClipboardList, KeyRound, UserCircle } from 'lucide-react' import { toast } from 'sonner' import { portalApi } from '../../lib/api-services' import Modal from '../../components/ui/Modal' @@ -17,6 +17,7 @@ const navItems = [ { path: '/portal/esign', label: '电子签署', icon: PenTool }, { path: '/portal/policies', label: '规章制度', icon: ScrollText }, { path: '/portal/records', label: '我的记录', icon: ClipboardList }, + { path: '/portal/profile', label: '个人资料', icon: UserCircle }, ] export default function PortalNav() {