feat: 员工端新增个人资料页面,数据同步到花名册
- 后端新增 GET/PUT /portal/profile 接口,员工可查看和编辑个人资料 - 员工可编辑:紧急联系人、住址、银行卡、学历、参保城市 - 只读字段:姓名、性别、手机号、证件号码、部门、岗位、入职日期 - 数据直接写入 Employee 表(花名册),HR 管理端可见 - 更新操作记录证据链(PROFILE_UPDATE) - 前端新增 MyProfile.tsx 页面,PortalNav 添加导航入口
This commit is contained in:
@@ -10,6 +10,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
|||||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||||
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
||||||
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||||||
|
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -870,6 +871,91 @@ router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} 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) => {
|
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type EvidenceCategory =
|
|||||||
| 'TERMINATION'
|
| 'TERMINATION'
|
||||||
| 'TRAINING'
|
| 'TRAINING'
|
||||||
| 'PERFORMANCE'
|
| 'PERFORMANCE'
|
||||||
|
| 'PROFILE_UPDATE'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建证据链记录
|
* 创建证据链记录
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const OnboardingProgress = lazyRetry(() => import('./pages/portal/OnboardingProg
|
|||||||
const ResignationApply = lazyRetry(() => import('./pages/portal/ResignationApply'))
|
const ResignationApply = lazyRetry(() => import('./pages/portal/ResignationApply'))
|
||||||
const MyEsign = lazyRetry(() => import('./pages/portal/MyEsign'))
|
const MyEsign = lazyRetry(() => import('./pages/portal/MyEsign'))
|
||||||
const MyRecords = lazyRetry(() => import('./pages/portal/MyRecords'))
|
const MyRecords = lazyRetry(() => import('./pages/portal/MyRecords'))
|
||||||
|
const MyProfile = lazyRetry(() => import('./pages/portal/MyProfile'))
|
||||||
const RiskCenter = lazyRetry(() => import('./pages/compliance/RiskCenter'))
|
const RiskCenter = lazyRetry(() => import('./pages/compliance/RiskCenter'))
|
||||||
const SalaryDashboard = lazyRetry(() => import('./pages/SalaryDashboard'))
|
const SalaryDashboard = lazyRetry(() => import('./pages/SalaryDashboard'))
|
||||||
const CommercialInsurance = lazyRetry(() => import('./pages/CommercialInsurance'))
|
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/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
|
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/records" element={<PortalLayoutWrapper><MyRecords /></PortalLayoutWrapper>} />
|
<Route path="/portal/records" element={<PortalLayoutWrapper><MyRecords /></PortalLayoutWrapper>} />
|
||||||
|
<Route path="/portal/profile" element={<PortalLayoutWrapper><MyProfile /></PortalLayoutWrapper>} />
|
||||||
|
|
||||||
{/* 兜底 */}
|
{/* 兜底 */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -1129,6 +1129,7 @@ portalAxios.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any
|
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 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 = {
|
export const portalApi = {
|
||||||
/** 登录 */
|
/** 登录 */
|
||||||
@@ -1191,6 +1192,12 @@ export const portalApi = {
|
|||||||
/** 入职进度 */
|
/** 入职进度 */
|
||||||
onboardingProgress: () =>
|
onboardingProgress: () =>
|
||||||
portalGet('/onboarding/progress').then(unwrap<any>()),
|
portalGet('/onboarding/progress').then(unwrap<any>()),
|
||||||
|
/** 获取个人资料 */
|
||||||
|
getProfile: () =>
|
||||||
|
portalGet('/profile').then(unwrap<any>()),
|
||||||
|
/** 更新个人资料 */
|
||||||
|
updateProfile: (data: Record<string, unknown>) =>
|
||||||
|
portalPut('/profile', data).then(unwrap<any>()),
|
||||||
/** 制度列表 */
|
/** 制度列表 */
|
||||||
policies: () =>
|
policies: () =>
|
||||||
portalGet('/policies').then(unwrap<any[]>()),
|
portalGet('/policies').then(unwrap<any[]>()),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
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 { toast } from 'sonner'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
import Modal from '../../components/ui/Modal'
|
import Modal from '../../components/ui/Modal'
|
||||||
@@ -17,6 +17,7 @@ const navItems = [
|
|||||||
{ path: '/portal/esign', label: '电子签署', icon: PenTool },
|
{ path: '/portal/esign', label: '电子签署', icon: PenTool },
|
||||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
||||||
{ path: '/portal/records', label: '我的记录', icon: ClipboardList },
|
{ path: '/portal/records', label: '我的记录', icon: ClipboardList },
|
||||||
|
{ path: '/portal/profile', label: '个人资料', icon: UserCircle },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function PortalNav() {
|
export default function PortalNav() {
|
||||||
|
|||||||
Reference in New Issue
Block a user