From d7fdec49fa6ee64a9d21ef1b1234bc6b3ae1de3b Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sun, 16 Aug 2026 11:28:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=91=98=E5=B7=A5=E7=AB=AF=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E7=99=BB=E5=BD=95=E5=AE=8C=E6=95=B4=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. createEmployee 创建员工时自动设置默认密码(手机号后6位) 2. 管理员重置密码接口 POST /employees/:id/reset-password (重置为手机号后6位) 3. 员工自己修改密码接口 POST /portal/change-password (需验证旧密码,新密码至少6位) 4. 花名册详情添加"重置密码"按钮,显示默认密码提示 5. 员工端导航栏添加"修改密码"入口,弹窗修改密码 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/src/routes/employee.routes.ts | 25 +++++ backend/src/routes/portal.routes.ts | 32 ++++++ backend/src/services/contract.service.ts | 5 + frontend/src/lib/api-services.ts | 6 ++ frontend/src/pages/portal/PortalNav.tsx | 125 ++++++++++++++++++----- frontend/src/pages/roster/BasicInfo.tsx | 41 ++++++-- 6 files changed, 201 insertions(+), 33 deletions(-) diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index cceef7a..ce798f9 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -1,4 +1,5 @@ import { Router } from 'express' +import bcrypt from 'bcryptjs' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' import { createEvidence } from '../services/evidence.service' @@ -195,6 +196,30 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => { } }) +/** + * 管理员重置员工密码(重置为手机号后6位) + * POST /employees/:id/reset-password + */ +router.post('/:id/reset-password', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const employee = await prisma.employee.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + select: { id: true, phone: true, name: true }, + }) + if (!employee) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + } + // 重置为手机号后6位,无手机号则用 123456 + const defaultPassword = employee.phone ? employee.phone.slice(-6) : '123456' + const passwordHash = await bcrypt.hash(defaultPassword, 10) + await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash } }) + await auditLog(req, 'RESET_PASSWORD', 'EMPLOYEE', employee.id, { employeeName: employee.name }) + res.json({ success: true, data: { message: `密码已重置为手机号后6位:${defaultPassword}` } }) + } catch (err) { + next(err) + } +}) + // 批量续签合规预检 router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => { try { diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index c2296b6..6521ca3 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -123,6 +123,38 @@ router.post('/verify-code', async (req, res, next) => { } }) +/** + * 员工修改自己的密码 + * POST /portal/change-password body: { oldPassword, newPassword } + */ +router.post('/change-password', portalAuth, async (req: any, res, next) => { + try { + const { oldPassword, newPassword } = req.body + if (!oldPassword || !newPassword) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请输入旧密码和新密码' } }) + } + if (newPassword.length < 6) { + return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '新密码至少6位' } }) + } + const employee = await prisma.employee.findFirst({ + where: { id: req.employee.id }, + select: { id: true, passwordHash: true }, + }) + if (!employee || !employee.passwordHash) { + return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '当前未设置密码,请联系管理员重置' } }) + } + const valid = await bcrypt.compare(oldPassword, employee.passwordHash) + if (!valid) { + return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '旧密码错误' } }) + } + const passwordHash = await bcrypt.hash(newPassword, 10) + await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash } }) + res.json({ success: true, data: { message: '密码修改成功' } }) + } catch (err) { + next(err) + } +}) + // 工资条 router.get('/payslip', portalAuth, async (req: any, res, next) => { try { diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 808deeb..775f95a 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -2,6 +2,7 @@ import prisma from '../lib/prisma' import { encrypt, decrypt, sha256 } from '../lib/crypto' import { runRiskDetection } from './risk.service' import { extractBirthDateFromIdCard, extractGenderFromIdCard } from './retirement.service' +import bcrypt from 'bcryptjs' function daysBetween(a: Date, b: Date): number { return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)) @@ -250,6 +251,9 @@ export async function createEmployee(orgId: string, userId: string, data: any) { const housingFundStartMonth = data.housingFundStartMonth || hireMonth const employee = await prisma.$transaction(async (tx) => { + // 默认密码:手机号后6位(员工可在员工端自行修改) + const defaultPassword = data.phone ? data.phone.slice(-6) : '123456' + const passwordHash = await bcrypt.hash(defaultPassword, 10) const emp = await tx.employee.create({ data: { orgId, @@ -260,6 +264,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) { gender: data.gender, femaleWorkerType: data.femaleWorkerType, phone: data.phone, + passwordHash, idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null, idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null, isPregnant: data.isPregnant || false, diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 7a8d6b1..536b5c9 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -81,6 +81,9 @@ export const employeeApi = { /** 重新入职 */ rehire: (id: string, data: Record) => post(`/employees/${id}/rehire`, data), + /** 重置员工密码(管理员,重置为手机号后6位) */ + resetPassword: (id: string) => + post(`/employees/${id}/reset-password`).then(unwrap()), /** 添加合同 */ addContract: (data: Record) => post('/employees/contracts', data), @@ -1026,6 +1029,9 @@ export const portalApi = { /** 自动登录 */ autoLogin: (token: string) => portalGet('/auto-login', { params: { token } }).then(unwrap()), + /** 修改密码(员工自己) */ + changePassword: (oldPassword: string, newPassword: string) => + portalPost('/change-password', { oldPassword, newPassword }).then(unwrap()), /** 生成自动登录令牌(管理端) */ generateAutoLoginToken: (employeeId: string) => post('/portal/auto-login-token', { employeeId }).then(unwrap()), diff --git a/frontend/src/pages/portal/PortalNav.tsx b/frontend/src/pages/portal/PortalNav.tsx index 02e58ed..34e9c2b 100644 --- a/frontend/src/pages/portal/PortalNav.tsx +++ b/frontend/src/pages/portal/PortalNav.tsx @@ -2,8 +2,14 @@ * 员工端底部导航栏 */ +import { useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { DollarSign, FileText, ScrollText, LogOut, PenTool, ClipboardList } from 'lucide-react' +import { DollarSign, FileText, ScrollText, LogOut, PenTool, ClipboardList, KeyRound } from 'lucide-react' +import { toast } from 'sonner' +import { portalApi } from '../../lib/api-services' +import Modal from '../../components/ui/Modal' +import Button from '../../components/ui/Button' +import { Input, Label } from '../../components/ui/Input' const navItems = [ { path: '/portal/payslip', label: '工资条', icon: DollarSign }, @@ -16,6 +22,11 @@ const navItems = [ export default function PortalNav() { const location = useLocation() const navigate = useNavigate() + const [showPwdModal, setShowPwdModal] = useState(false) + const [oldPwd, setOldPwd] = useState('') + const [newPwd, setNewPwd] = useState('') + const [confirmPwd, setConfirmPwd] = useState('') + const [pwdLoading, setPwdLoading] = useState(false) const handleLogout = () => { localStorage.removeItem('portalToken') @@ -23,31 +34,95 @@ export default function PortalNav() { navigate('/portal/login') } + /** 修改密码 */ + const handleChangePassword = async () => { + if (!oldPwd || !newPwd || !confirmPwd) { + toast.error('请填写所有字段') + return + } + if (newPwd.length < 6) { + toast.error('新密码至少6位') + return + } + if (newPwd !== confirmPwd) { + toast.error('两次输入的新密码不一致') + return + } + setPwdLoading(true) + try { + const data: any = await portalApi.changePassword(oldPwd, newPwd) + toast.success(data?.message || '密码修改成功') + setShowPwdModal(false) + setOldPwd(''); setNewPwd(''); setConfirmPwd('') + } catch (err: any) { + toast.error(err?.response?.data?.error?.message || '修改失败') + } finally { + setPwdLoading(false) + } + } + return ( -
-
- {navItems.map((item) => { - const Icon = item.icon - const active = location.pathname === item.path - return ( - - - {item.label} - - ) - })} + <> +
+
+ {navItems.map((item) => { + const Icon = item.icon + const active = location.pathname === item.path + return ( + + + {item.label} + + ) + })} +
+
+ + +
- -
+ + {/* 修改密码弹窗 */} + {showPwdModal && ( + setShowPwdModal(false)} title="修改密码"> +
+
+ + setOldPwd(e.target.value)} placeholder="请输入旧密码" /> +
+
+ + setNewPwd(e.target.value)} placeholder="至少6位" /> +
+
+ + setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /> +
+
+ + +
+
+
+ )} + ) } diff --git a/frontend/src/pages/roster/BasicInfo.tsx b/frontend/src/pages/roster/BasicInfo.tsx index 08b9a3c..f8cad22 100644 --- a/frontend/src/pages/roster/BasicInfo.tsx +++ b/frontend/src/pages/roster/BasicInfo.tsx @@ -9,7 +9,7 @@ import api from '../../lib/api' import Card from "../../components/ui/Card" import Button from "../../components/ui/Button" import { Input, Label, Select } from "../../components/ui/Input" -import { AlertTriangle, Paperclip, Trash2, Eye, Download, Copy } from "lucide-react" +import { AlertTriangle, Paperclip, Trash2, Eye, Download, Copy, KeyRound } from "lucide-react" import { fmt } from "./shared" export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) { @@ -29,6 +29,13 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }), }) + /** 重置员工密码(重置为手机号后6位) */ + const resetPasswordMutation = useMutation({ + mutationFn: () => employeeApi.resetPassword(employeeId), + onSuccess: (data: any) => toast.success(data?.message || '密码已重置'), + onError: (err: any) => toast.error(err?.response?.data?.error?.message || '重置失败'), + }) + const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return @@ -493,12 +500,29 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil

员工端入口

- +
+ + +
@@ -509,7 +533,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil />
-

员工扫码进入员工端,使用手机号登录

+

员工扫码进入员工端,使用手机号+密码登录

+

默认密码:手机号后6位({profile.phone?.slice(-6)}),员工可在端内自行修改

可查看工资条、合同信息、确认签署

链接:{window.location.origin}/portal/login