feat: 员工端新增个人资料页面,数据同步到花名册

- 后端新增 GET/PUT /portal/profile 接口,员工可查看和编辑个人资料
- 员工可编辑:紧急联系人、住址、银行卡、学历、参保城市
- 只读字段:姓名、性别、手机号、证件号码、部门、岗位、入职日期
- 数据直接写入 Employee 表(花名册),HR 管理端可见
- 更新操作记录证据链(PROFILE_UPDATE)
- 前端新增 MyProfile.tsx 页面,PortalNav 添加导航入口
This commit is contained in:
freedakgmail
2026-08-17 18:56:42 +08:00
parent cdbfeb4f99
commit c4a929fc8e
6 changed files with 319 additions and 1 deletions
+86
View File
@@ -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) => {
+1
View File
@@ -45,6 +45,7 @@ export type EvidenceCategory =
| 'TERMINATION'
| 'TRAINING'
| 'PERFORMANCE'
| 'PROFILE_UPDATE'
/**
* 创建证据链记录
+2
View File
@@ -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() {
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
<Route path="/portal/records" element={<PortalLayoutWrapper><MyRecords /></PortalLayoutWrapper>} />
<Route path="/portal/profile" element={<PortalLayoutWrapper><MyProfile /></PortalLayoutWrapper>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
+7
View File
@@ -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<any>()),
/** 获取个人资料 */
getProfile: () =>
portalGet('/profile').then(unwrap<any>()),
/** 更新个人资料 */
updateProfile: (data: Record<string, unknown>) =>
portalPut('/profile', data).then(unwrap<any>()),
/** 制度列表 */
policies: () =>
portalGet('/policies').then(unwrap<any[]>()),
+221
View File
@@ -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<any>({
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<string, unknown>) => 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 (
<div className="space-y-4">
<div className="h-32 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-48 bg-gray-100 rounded-xl animate-pulse" />
</div>
)
}
if (!profile) {
return <Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
}
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 (
<div className="space-y-4">
{/* 只读信息卡片 */}
<Card>
<div className="flex items-center gap-2 mb-4">
<User className="w-4 h-4 text-primary" />
<h2 className="text-sm font-medium"></h2>
<span className="text-xs text-gray-400"> HR HR</span>
</div>
<div className="grid grid-cols-2 gap-3">
{readOnlyFields.map((f) => {
const Icon = f.icon
return (
<div key={f.label} className="flex items-center gap-2 p-2.5 rounded-lg bg-gray-50">
<Icon className="w-4 h-4 text-gray-400 flex-shrink-0" />
<div className="min-w-0">
<div className="text-xs text-gray-400">{f.label}</div>
<div className="text-sm text-gray-700 truncate">{f.value}</div>
</div>
</div>
)
})}
</div>
</Card>
{/* 可编辑信息卡片 */}
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-primary" />
<h2 className="text-sm font-medium"></h2>
</div>
{!editing ? (
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>
</Button>
) : (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => { setEditing(false); }}>
</Button>
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
<Save className="w-3.5 h-3.5 mr-1" />
{updateMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label></Label>
<Input
value={form.emergencyContact}
onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })}
placeholder="请输入紧急联系人姓名"
disabled={!editing}
/>
</div>
<div>
<Label></Label>
<Input
type="tel"
value={form.emergencyPhone}
onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })}
placeholder="请输入紧急联系人电话"
maxLength={11}
disabled={!editing}
/>
</div>
<div className="md:col-span-2">
<Label></Label>
<Input
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="请输入现住址"
disabled={!editing}
/>
</div>
<div>
<Label></Label>
<Input
value={form.bankAccount}
onChange={(e) => setForm({ ...form, bankAccount: e.target.value })}
placeholder="请输入银行卡号"
disabled={!editing}
/>
</div>
<div>
<Label></Label>
<Input
value={form.bankName}
onChange={(e) => setForm({ ...form, bankName: e.target.value })}
placeholder="如:工商银行xx支行"
disabled={!editing}
/>
</div>
<div>
<Label></Label>
{editing ? (
<select
value={form.education}
onChange={(e) => setForm({ ...form, education: e.target.value })}
className="w-full h-10 px-3 rounded-md border border-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value=""></option>
{educationOptions.map((e) => <option key={e} value={e}>{e}</option>)}
</select>
) : (
<Input value={form.education || '未填写'} disabled />
)}
</div>
<div>
<Label></Label>
<Input
value={form.city}
onChange={(e) => setForm({ ...form, city: e.target.value })}
placeholder="如:北京"
disabled={!editing}
/>
</div>
</div>
{editing && (
<div className="mt-4 p-3 rounded-lg bg-blue-50 text-blue-600 text-xs flex items-center gap-2">
<Check className="w-3.5 h-3.5 flex-shrink-0" />
HR
</div>
)}
</Card>
</div>
)
}
+2 -1
View File
@@ -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() {