feat: 员工端密码登录完整支持
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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,6 +81,9 @@ export const employeeApi = {
|
||||
/** 重新入职 */
|
||||
rehire: (id: string, data: Record<string, unknown>) =>
|
||||
post(`/employees/${id}/rehire`, data),
|
||||
/** 重置员工密码(管理员,重置为手机号后6位) */
|
||||
resetPassword: (id: string) =>
|
||||
post(`/employees/${id}/reset-password`).then(unwrap<any>()),
|
||||
/** 添加合同 */
|
||||
addContract: (data: Record<string, unknown>) =>
|
||||
post('/employees/contracts', data),
|
||||
@@ -1026,6 +1029,9 @@ export const portalApi = {
|
||||
/** 自动登录 */
|
||||
autoLogin: (token: string) =>
|
||||
portalGet('/auto-login', { params: { token } }).then(unwrap<any>()),
|
||||
/** 修改密码(员工自己) */
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
portalPost('/change-password', { oldPassword, newPassword }).then(unwrap<any>()),
|
||||
/** 生成自动登录令牌(管理端) */
|
||||
generateAutoLoginToken: (employeeId: string) =>
|
||||
post('/portal/auto-login-token', { employeeId }).then(unwrap<any>()),
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-1 text-sm ${active ? 'text-primary font-medium' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-1 text-sm ${active ? 'text-primary font-medium' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowPwdModal(true)}
|
||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
修改密码
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
{showPwdModal && (
|
||||
<Modal open onClose={() => setShowPwdModal(false)} title="修改密码">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>旧密码</Label>
|
||||
<Input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} placeholder="请输入旧密码" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>新密码</Label>
|
||||
<Input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} placeholder="至少6位" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>确认新密码</Label>
|
||||
<Input type="password" value={confirmPwd} onChange={(e) => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setShowPwdModal(false)}>取消</Button>
|
||||
<Button onClick={handleChangePassword} disabled={pwdLoading}>
|
||||
{pwdLoading ? '修改中...' : '确认修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
@@ -493,12 +500,29 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-medium text-gray-600">员工端入口</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
const url = `${window.location.origin}/portal/login`
|
||||
navigator.clipboard?.writeText(url)
|
||||
}}>
|
||||
复制链接
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
const url = `${window.location.origin}/portal/login`
|
||||
navigator.clipboard?.writeText(url)
|
||||
}}>
|
||||
复制链接
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: '重置员工密码',
|
||||
message: `将重置「${profile.name}」的员工端密码为手机号后6位(${profile.phone?.slice(-6)}),确认操作?`,
|
||||
})
|
||||
if (ok) resetPasswordMutation.mutate()
|
||||
}}
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5 mr-1" />
|
||||
重置密码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-white p-3 rounded-lg border">
|
||||
@@ -509,7 +533,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>员工扫码进入员工端,使用手机号登录</p>
|
||||
<p>员工扫码进入员工端,使用手机号+密码登录</p>
|
||||
<p>默认密码:手机号后6位({profile.phone?.slice(-6)}),员工可在端内自行修改</p>
|
||||
<p>可查看工资条、合同信息、确认签署</p>
|
||||
<p className="text-gray-400">链接:{window.location.origin}/portal/login</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user