feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全
- 面包屑导航组件,集成至TopNav header - 侧边栏菜单分组间距增大,分组间分隔线 - 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计 - 修复Policies.tsx民主程序推进bug(字段名/API路径/参数) - 用工文本模板变量名英文转中文显示 - 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY) - 通知示例数据补充 - h2标题统一为text-sm font-medium - 新增run.md
This commit is contained in:
@@ -6,12 +6,11 @@ import fs from 'fs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 验证码临时存储(生产环境应使用 Redis)
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
|
||||
|
||||
// 员工端认证中间件
|
||||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
@@ -63,12 +62,12 @@ router.post('/send-code', async (req, res, next) => {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
|
||||
}
|
||||
// 频率限制:60秒内不可重复发送
|
||||
const existing = codeStore.get(data.phone)
|
||||
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
|
||||
const allowed = await checkRateLimit(data.phone)
|
||||
if (!allowed) {
|
||||
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
await setCode(data.phone, code)
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -79,20 +78,20 @@ router.post('/send-code', async (req, res, next) => {
|
||||
router.post('/verify-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalVerifyCodeSchema.parse(req.body)
|
||||
const stored = codeStore.get(data.phone)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
const stored = await getCode(data.phone)
|
||||
if (!stored) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
// 错误次数限制:5次后锁定
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(data.phone)
|
||||
await deleteCode(data.phone)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.code) {
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
await updateCode(data.phone, { failCount: stored.failCount + 1 })
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
await deleteCode(data.phone)
|
||||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
@@ -158,6 +157,14 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
channel: 'IN_APP',
|
||||
},
|
||||
})
|
||||
await createEvidence({
|
||||
orgId: req.employee.orgId,
|
||||
category: 'PAYSLIP_CONFIRM',
|
||||
refId: payslip.id,
|
||||
employeeId: req.employee.id,
|
||||
events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||||
createdBy: req.employee.id,
|
||||
}).catch(() => {})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -231,7 +238,7 @@ router.post('/contract-confirm/send-code', async (req, res, next) => {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
await setCode(`contract-${data.token}`, code)
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -250,19 +257,19 @@ router.post('/contract-confirm', async (req, res, next) => {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
// 验证码校验
|
||||
const stored = codeStore.get(`contract-${data.token}`)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
const stored = await getCode(`contract-${data.token}`)
|
||||
if (!stored) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
await deleteCode(`contract-${data.token}`)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.verifyCode) {
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
await updateCode(`contract-${data.token}`, { failCount: stored.failCount + 1 })
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||||
}
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
await deleteCode(`contract-${data.token}`)
|
||||
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const signEvidence = JSON.stringify({
|
||||
@@ -278,6 +285,14 @@ router.post('/contract-confirm', async (req, res, next) => {
|
||||
where: { id: link.contractId },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
|
||||
})
|
||||
await createEvidence({
|
||||
orgId: link.contract.orgId,
|
||||
category: 'CONTRACT_SIGN',
|
||||
refId: link.contractId,
|
||||
employeeId: link.contract.employeeId,
|
||||
events: [{ action: '合同签署确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent, smsCode: data.verifyCode }],
|
||||
createdBy: link.contract.employeeId,
|
||||
}).catch(() => {})
|
||||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -422,4 +437,111 @@ router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 员工端:规章制度公示 ==========
|
||||
|
||||
/** 获取已公示制度列表(含当前员工阅读状态) */
|
||||
router.get('/policies', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { orgId, id: employeeId } = (req as any).employee
|
||||
const policies = await prisma.policyDocument.findMany({
|
||||
where: { orgId, status: 'PUBLISHED' },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
publishedAt: true,
|
||||
readRecords: {
|
||||
where: { employeeId },
|
||||
select: { id: true, readAt: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = policies.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
type: p.type,
|
||||
publishedAt: p.publishedAt?.toISOString() || null,
|
||||
hasRead: p.readRecords.length > 0,
|
||||
readAt: p.readRecords[0]?.readAt?.toISOString() || null,
|
||||
}))
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取制度详情(已公示) */
|
||||
router.get('/policies/:id', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { orgId, id: employeeId } = (req as any).employee
|
||||
const policy = await prisma.policyDocument.findFirst({
|
||||
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
publishedAt: true,
|
||||
readRecords: {
|
||||
where: { employeeId },
|
||||
select: { id: true, readAt: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!policy) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: policy.id,
|
||||
title: policy.title,
|
||||
content: policy.content,
|
||||
type: policy.type,
|
||||
publishedAt: policy.publishedAt?.toISOString() || null,
|
||||
hasRead: policy.readRecords.length > 0,
|
||||
readAt: policy.readRecords[0]?.readAt?.toISOString() || null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 提交阅读确认(签收) */
|
||||
router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { orgId, id: employeeId } = (req as any).employee
|
||||
const policy = await prisma.policyDocument.findFirst({
|
||||
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!policy) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
|
||||
}
|
||||
|
||||
// 幂等:已存在则返回已有记录
|
||||
const existing = await prisma.policyReadRecord.findUnique({
|
||||
where: { policyId_employeeId: { policyId: req.params.id, employeeId } },
|
||||
})
|
||||
if (existing) {
|
||||
return res.json({ success: true, data: { readAt: existing.readAt.toISOString() } })
|
||||
}
|
||||
|
||||
const record = await prisma.policyReadRecord.create({
|
||||
data: {
|
||||
policyId: req.params.id,
|
||||
orgId,
|
||||
employeeId,
|
||||
ip: req.ip || req.socket.remoteAddress,
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { readAt: record.readAt.toISOString() } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user