From e01bad48159c36189b9005c3fbfca3f2d53463eb Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Fri, 24 Jul 2026 07:58:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E4=BC=98=E5=8C=961-6?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E5=8A=9F=E8=83=BD=20=E2=80=94=20Portal?= =?UTF-8?q?=E5=AE=89=E5=85=A8/AI=E5=A2=9E=E5=BC=BA/Settings=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=AF=BC=E5=87=BA/=E7=A4=BE=E4=BF=9D=E5=85=AC?= =?UTF-8?q?=E7=A7=AF=E9=87=91=E7=89=88=E6=9C=AC=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化-1: 社保公积金独立配置+版本化缴费记录+多城市支持+迁移脚本 - 优化-2: AI流式输出/RAG集成/风险角标/审计日志/二维码/批量续签/忘记密码/语音输入/PDF导出/速率限制/套餐人数上限 - 优化-3: 批次重命名/费用实时预览/模拟版本管理/续签合规预检/社保重置/搜索分页/批量解聘/到期预警/税率试算 - 优化-4: 会话历史/待办批量/结果关联档案/风险下钻/预测上下文/附件校验/Tab级联/薪税导出 - 优化-5: 表单回填/用户编辑禁用/导入预览/选择性导出/通知测试/错误日志导出/脱敏导出/gzip压缩 - 优化-6: 工资条确认通知HR/AI上下文增强/电子签名/用量限制修复/入职文件上传/RAG管理/工资趋势/用量事务/验证码加固/审查结构化/链接撤回/超时机制/确认重发/案例转待办 --- backend/prisma/schema.prisma | 17 + backend/src/routes/ai.routes.ts | 89 +++- backend/src/routes/export.routes.ts | 173 +++++-- backend/src/routes/import.routes.ts | 213 +++++++- backend/src/routes/notification.routes.ts | 35 ++ backend/src/routes/portal.routes.ts | 187 ++++++- backend/src/routes/settings.routes.ts | 71 ++- backend/src/schemas/portal.schema.ts | 5 + backend/src/services/ai.service.ts | 27 +- backend/src/services/auth.service.ts | 4 + frontend/src/pages/AIAssistant.tsx | 293 ++++++++++- frontend/src/pages/Roster.tsx | 9 +- frontend/src/pages/Settings.tsx | 484 ++++++++++++++++-- frontend/src/pages/portal/ContractConfirm.tsx | 50 +- frontend/src/pages/portal/MyContract.tsx | 37 +- frontend/src/pages/portal/Onboarding.tsx | 97 +++- frontend/src/pages/portal/Payslip.tsx | 71 ++- 17 files changed, 1721 insertions(+), 141 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3e10f31..54226d7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -98,6 +98,7 @@ enum OnboardingStatus { PENDING APPROVED REJECTED + CANCELLED } enum ContractConfirmStatus { @@ -157,6 +158,7 @@ model User { passwordHash String name String role Role @default(ADMIN) + disabled Boolean @default(false) createdAt DateTime @default(now()) lastLoginAt DateTime? } @@ -789,3 +791,18 @@ model AIReviewRecord { @@index([orgId, employeeId]) } + +// ========== RAG 知识库 ========== + +model RagKnowledge { + id String @id + title String + content String + source String + category String + embedding Unsupported("vector(1536)")? + createdAt DateTime @default(now()) @map("created_at") + + @@index([category]) + @@map("rag_knowledge") +} diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index c36b316..967ad86 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -19,12 +19,13 @@ async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE const limit = limits[type] if (limit === 0) return - const month = new Date().toISOString().slice(0, 7) + const now = new Date() + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) const count = await prisma.auditLog.count({ where: { orgId, action: `AI_${type.toUpperCase()}`, - detail: JSON.stringify({ month }) as any, + createdAt: { gte: monthStart }, }, }) if (count >= limit) { @@ -59,12 +60,20 @@ async function buildOrgContext(orgId: string): Promise { }), ]) + const now = new Date() const empSummary = employees.map((e) => { const contract = e.contracts[0] - return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同类型:${contract.contractType}` : '未签合同'}` + const daysToExpire = contract?.endDate + ? Math.floor((new Date(contract.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : null + const specialStatus: string[] = [] + if (e.isPregnant) specialStatus.push('孕期/哺乳期') + if (e.isInMedicalPeriod) specialStatus.push('医疗期') + if (e.isWorkInjured) specialStatus.push('工伤') + return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同:${contract.contractType},${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}` }).join('\n') - const riskSummary = risks.map((r) => `- ${r.title}(${r.level})`).join('\n') + const riskSummary = risks.map((r) => `- ${r.title}(${r.level}):${r.description || '无详细描述'}`).join('\n') return `员工列表(${employees.length}人): ${empSummary} @@ -100,12 +109,19 @@ router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next) res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') - for await (const delta of chatStream(messages, orgContext)) { - res.write(`data: ${JSON.stringify({ delta })}\n\n`) + let usageRecorded = false + try { + for await (const delta of chatStream(messages, orgContext)) { + res.write(`data: ${JSON.stringify({ delta })}\n\n`) + } + res.write('data: [DONE]\n\n') + } finally { + if (!usageRecorded) { + await recordUsage(req.user!.orgId, req.user!.id, 'chat') + usageRecorded = true + } } - res.write('data: [DONE]\n\n') res.end() - await recordUsage(req.user!.orgId, req.user!.id, 'chat') } catch (err) { if (!res.headersSent) next(err) else res.end() @@ -121,7 +137,7 @@ router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => { await checkUsageLimit(req.user!.orgId, 'review') const result = await reviewContract(contractText) await recordUsage(req.user!.orgId, req.user!.id, 'review') - res.json({ success: true, data: { result } }) + res.json({ success: true, data: { text: result.text, structured: result.structured } }) } catch (err) { next(err) } @@ -142,6 +158,34 @@ router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) = } }) +// 案例匹配结果转待办(RiskItem) +router.post('/case-to-todo', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const schema = z.object({ + employeeId: z.string().min(1), + title: z.string().min(1), + description: z.string().min(1), + level: z.enum(['HIGH', 'MEDIUM', 'LOW']).default('MEDIUM'), + type: z.enum(['CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING']).default('TERMINATION'), + }) + const data = schema.parse(req.body) + const risk = await prisma.riskItem.create({ + data: { + orgId: req.user!.orgId, + employeeId: data.employeeId, + title: data.title, + description: data.description, + level: data.level, + type: data.type, + status: 'PENDING', + }, + }) + res.json({ success: true, data: risk }) + } catch (err) { + next(err) + } +}) + router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => { try { const scope = (req.query.scope as string) || 'all' @@ -330,4 +374,31 @@ router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) = } }) +// 知识库列表 +router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const category = req.query.category as string | undefined + const items = await prisma.$queryRaw` + SELECT id, title, content, source, category, created_at + FROM rag_knowledge + ${category ? prisma.$queryRaw`WHERE category = ${category}` : prisma.$queryRaw``} + ORDER BY created_at DESC + LIMIT 200 + ` as any[] + res.json({ success: true, data: items }) + } catch (err) { + next(err) + } +}) + +// 删除知识条目 +router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => { + try { + await prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}` + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/export.routes.ts b/backend/src/routes/export.routes.ts index c835ac1..e44a680 100644 --- a/backend/src/routes/export.routes.ts +++ b/backend/src/routes/export.routes.ts @@ -3,48 +3,155 @@ import { authMiddleware, AuthRequest } from '../middleware/auth' import prisma from '../lib/prisma' import { decrypt } from '../lib/crypto' import ExcelJS from 'exceljs' +import { createGzip } from 'zlib' +import { Writable } from 'stream' const router = Router() +// 敏感字段脱敏 +function maskIdCard(idCard: string | null): string | null { + if (!idCard) return null + if (idCard.length >= 11) return idCard.slice(0, 3) + '*'.repeat(idCard.length - 7) + idCard.slice(-4) + return idCard +} +function maskBankAccount(account: string | null): string | null { + if (!account) return null + if (account.length > 4) return '*'.repeat(account.length - 4) + account.slice(-4) + return account +} + +// 导出全部数据(支持模块选择、格式选择、脱敏) router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => { try { const orgId = req.user!.orgId + const format = (req.query.format as string) || 'json' + const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN' + const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',') - const [employees, contracts, terminations, payrollBatches, payslips, socialRecords, housingRecords, riskItems] = await Promise.all([ - prisma.employee.findMany({ where: { orgId } }), - prisma.laborContract.findMany({ where: { orgId } }), - prisma.terminationRecord.findMany({ where: { orgId } }), - prisma.payrollBatch.findMany({ where: { orgId } }), - prisma.payslip.findMany({ where: { orgId } }), - prisma.employeeSocialInsRecord.findMany({ where: { orgId } }), - prisma.employeeHousingFundRecord.findMany({ where: { orgId } }), - prisma.riskItem.findMany({ where: { orgId } }), - ]) - - const safeEmployees = employees.map((e) => { - let salary = 0 - try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 } - let idCard = null - try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber } - return { ...e, monthlySalary: salary, idCardNumber: idCard } - }) - - const data = { - exportedAt: new Date().toISOString(), - orgId, - employees: safeEmployees, - contracts, - terminations, - payrollBatches, - payslips, - socialRecords, - housingRecords, - riskItems, + const fetchMap: Record Promise> = { + employees: () => prisma.employee.findMany({ where: { orgId } }), + contracts: () => prisma.laborContract.findMany({ where: { orgId } }), + terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }), + payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }), + payslips: () => prisma.payslip.findMany({ where: { orgId } }), + socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }), + housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }), + riskItems: () => prisma.riskItem.findMany({ where: { orgId } }), } - res.setHeader('Content-Type', 'application/json') - res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`) - res.json(data) + const useGzip = req.query.gzip !== 'false' + const batchSize = 500 + + if (format === 'excel') { + const data: any = { exportedAt: new Date().toISOString(), orgId } + + if (modules.includes('employees')) { + const employees = await fetchMap.employees() + data.employees = employees.map((e: any) => { + let salary = 0 + try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 } + let idCard: string | null = null + try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber } + let bankAccount: string | null = null + try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount } + if (mask) { + idCard = maskIdCard(idCard) + bankAccount = maskBankAccount(bankAccount) + if (salary) salary = 0 + } + return { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount } + }) + } + + for (const mod of modules) { + if (mod === 'employees') continue + if (fetchMap[mod]) { + data[mod] = await fetchMap[mod]() + } + } + + const workbook = new ExcelJS.Workbook() + for (const mod of modules) { + if (!data[mod] || !data[mod].length) continue + const ws = workbook.addWorksheet(mod.slice(0, 31)) + const rows = data[mod] + const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object') + ws.columns = keys.map(k => ({ header: k, key: k, width: 18 })) + ws.getRow(1).font = { bold: true } + for (const row of rows) { + const flat: any = {} + for (const k of keys) flat[k] = typeof row[k] === 'object' ? JSON.stringify(row[k]) : row[k] + ws.addRow(flat) + } + } + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`) + await workbook.xlsx.write(res) + res.end() + } else { + // JSON 流式导出 + gzip 压缩 + if (useGzip) { + res.setHeader('Content-Encoding', 'gzip') + res.setHeader('Content-Type', 'application/json') + res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`) + } else { + res.setHeader('Content-Type', 'application/json') + res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`) + } + + const gzip = useGzip ? createGzip() : null + const output: Writable = gzip || res + if (gzip) { gzip.pipe(res) } + + const write = (chunk: string) => { + output.write(Buffer.from(chunk)) + } + + write('{"exportedAt":"' + new Date().toISOString() + '","orgId":"' + orgId + '"') + + for (const mod of modules) { + write(',"' + mod + '":[') + + if (mod === 'employees') { + // 员工数据分批查询,避免内存溢出 + let skip = 0 + let first = true + while (true) { + const batch = await prisma.employee.findMany({ where: { orgId }, skip, take: batchSize }) + if (batch.length === 0) break + for (const e of batch) { + let salary = 0 + try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 } + let idCard: string | null = null + try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber } + let bankAccount: string | null = null + try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount } + if (mask) { + idCard = maskIdCard(idCard) + bankAccount = maskBankAccount(bankAccount) + if (salary) salary = 0 + } + const row = { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount } + write((first ? '' : ',') + JSON.stringify(row)) + first = false + } + skip += batchSize + if (batch.length < batchSize) break + } + } else if (fetchMap[mod]) { + const rows = await fetchMap[mod]() + for (let i = 0; i < rows.length; i++) { + write((i === 0 ? '' : ',') + JSON.stringify(rows[i])) + } + } + + write(']') + } + + write('}') + if (gzip) gzip.end() + else res.end() + } } catch (err) { next(err) } diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index da58e38..253e96d 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -8,6 +8,54 @@ import prisma from '../lib/prisma' const router = Router() const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }) +// 身份证号格式校验(18位正则 + 校验位算法) +function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } { + if (!idCard) return { valid: true } + const s = idCard.trim() + // 15位身份证号升级为18位 + if (/^\d{15}$/.test(s)) { + const upgraded = upgrade15To18(s) + return { valid: true, upgraded } + } + if (!/^\d{17}[\dXx]$/.test(s)) { + return { valid: false, error: '身份证号格式错误(应为18位)' } + } + // 校验位算法 + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] + const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0) + const expected = checkCodes[sum % 11] + if (s.charAt(17).toUpperCase() !== expected) { + return { valid: false, error: '身份证号校验位错误' } + } + return { valid: true } +} + +function upgrade15To18(s15: string): string { + const born = '19' + s15.substring(6, 12) + const body = s15.substring(0, 6) + born + s15.substring(12) + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] + const sum = body.split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0) + return body + checkCodes[sum % 11] +} + +// 社保基数范围校验 +const SOCIAL_INS_LIMITS: Record = { + '北京': { min: 6326, max: 33891 }, + '上海': { min: 7310, max: 36549 }, + '广州': { min: 5284, max: 27501 }, + '深圳': { min: 3523, max: 27501 }, + '杭州': { min: 4812, max: 24060 }, +} +function validateSocialBase(base: number, city?: string): { valid: boolean; warning?: string } { + if (!city || !SOCIAL_INS_LIMITS[city]) return { valid: true } + const limits = SOCIAL_INS_LIMITS[city] + if (base < limits.min) return { valid: true, warning: `基数${base}低于${city}下限${limits.min}` } + if (base > limits.max) return { valid: true, warning: `基数${base}高于${city}上限${limits.max}` } + return { valid: true } +} + function dateToMonth(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` } @@ -35,6 +83,133 @@ function num(v: any): number { return isNaN(n) ? 0 : n } +// ========== 导入预览(不写入数据库) ========== + +router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => { + try { + if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } }) + const orgId = req.user!.orgId + + const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true }) + const preview: any = { employees: [], contracts: [], overtime: [], disciplinary: [], attendance: [], errors: [] as any[] } + + const empSheet = wb.Sheets['员工信息'] + if (empSheet) { + const rows = XLSX.utils.sheet_to_json(empSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] } + if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') } + const hireDate = parseDate(r['入职日期']) + if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') } + if (row.salary === 0) { row.status = 'error'; row.errors.push('月工资为空') } + if (row.idCard) { + const idCheck = validateIdCard(row.idCard) + if (!idCheck.valid) { row.status = row.status === 'normal' ? 'warning' : row.status; row.warnings.push(idCheck.error!) } + if (idCheck.upgraded) { row.idCard = idCheck.upgraded; row.warnings.push('15位身份证已升级为18位') } + } + if (row.status === 'error') preview.errors.push({ sheet: '员工信息', row: i + 2, name: row.name, errors: row.errors }) + preview.employees.push(row) + } + } + + const contractSheet = wb.Sheets['劳动合同'] + if (contractSheet) { + const rows = XLSX.utils.sheet_to_json(contractSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), contractType: val(r['合同类型']), startDate: r['合同开始日期'], endDate: r['合同结束日期'], status: 'normal', errors: [] as string[] } + if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } + const sd = parseDate(r['合同开始日期']) + if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') } + if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors }) + preview.contracts.push(row) + } + } + + const otSheet = wb.Sheets['加班记录'] + if (otSheet) { + const rows = XLSX.utils.sheet_to_json(otSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + const otType = val(r['加班类型']) || '工作日加班' + const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], hours: num(r['加班时长']), otType, status: 'normal', errors: [] as string[] } + if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } + const dt = parseDate(r['日期']) + if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') } + if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors }) + preview.overtime.push(row) + } + } + + const discSheet = wb.Sheets['违纪记录'] + if (discSheet) { + const rows = XLSX.utils.sheet_to_json(discSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], violationType: val(r['违纪类型']), description: val(r['描述']), status: 'normal', errors: [] as string[] } + if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } + if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors }) + preview.disciplinary.push(row) + } + } + + const attSheet = wb.Sheets['考勤记录'] + if (attSheet) { + const rows = XLSX.utils.sheet_to_json(attSheet) + for (let i = 0; i < rows.length; i++) { + const r = rows[i] as any + const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], attStatus: val(r['考勤状态']), status: 'normal', errors: [] as string[] } + if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') } + const dt = parseDate(r['日期']) + if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') } + if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors }) + preview.attendance.push(row) + } + } + + const summary = { + totalRows: preview.employees.length + preview.contracts.length + preview.overtime.length + preview.disciplinary.length + preview.attendance.length, + normalRows: 0, + warningRows: 0, + errorRows: preview.errors.length, + sheets: Object.keys(wb.Sheets).filter(s => !s.startsWith('!')), + } + summary.normalRows = summary.totalRows - summary.errorRows + preview.summary = summary + + res.json({ success: true, data: preview }) + } catch (err) { + next(err) + } +}) + +// ========== 错误日志导出 ========== + +router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Response, next) => { + try { + const { errors } = req.body as { errors: any[] } + if (!errors || !errors.length) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无错误数据' } }) + } + const data = errors.map(e => ({ + 'Sheet': e.sheet || '', + '行号': e.row || '', + '员工姓名': e.name || '', + '错误类型': Array.isArray(e.errors) ? e.errors.join('; ') : (e.error || ''), + })) + const ws = XLSX.utils.json_to_sheet(data) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '错误日志') + const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + res.setHeader('Content-Disposition', `attachment; filename="import-errors-${Date.now()}.xlsx"`) + res.send(buf) + } catch (err) { + next(err) + } +}) + router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => { try { if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } }) @@ -58,14 +233,21 @@ router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthReq const salary = String(num(r['月工资'])) if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue } + let idCard = val(r['身份证号']) + if (idCard) { + const idCheck = validateIdCard(idCard) + if (!idCheck.valid) { result.errors.push(`员工第${i + 2}行:${idCheck.error}`); continue } + if (idCheck.upgraded) idCard = idCheck.upgraded + } + const emp = await prisma.employee.create({ data: { orgId, name, department: dept, hireDate, monthlySalary: encrypt(salary), gender: val(r['性别']) || null, phone: val(r['手机号']) || null, - idCardNumber: val(r['身份证号']) ? encrypt(val(r['身份证号'])) : null, - idCardHash: val(r['身份证号']) ? sha256(val(r['身份证号'])) : null, + idCardNumber: idCard ? encrypt(idCard) : null, + idCardHash: idCard ? sha256(idCard) : null, emergencyContact: val(r['紧急联系人']) || null, emergencyPhone: val(r['紧急联系电话']) || null, address: val(r['住址']) || null, @@ -147,9 +329,12 @@ router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthReq const date = parseDate(r['日期']) if (!date) continue const month = dateToMonth(date) - const hours = num(r['加班时长']) const otType = val(r['加班类型']) || '工作日加班' - await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0, createdBy: userId } as any }) + const hours = num(r['加班时长']) + const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0) + const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0) + const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0) + await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any }) result.overtime++ } } @@ -214,7 +399,7 @@ router.get('/template', authMiddleware, async (req: AuthRequest, res: Response) XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同') const otData = [ - { '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '加班时长': 2, '加班类型': '工作日加班', '倍率': 1.5, '是否审批': '是' }, + { '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' }, ] XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录') @@ -247,7 +432,7 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR } const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true }) - const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[] } + const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } } const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } }) const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e])) @@ -298,13 +483,16 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR const otMonth = dateToMonth(date) const hours = num(r['加班时长']) const otType = val(r['加班类型']) || '工作日加班' + const wdHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0) + const weHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0) + const hoHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0) await prisma.overtimeRecord.upsert({ where: { employeeId_month: { employeeId: emp.id, month: otMonth } }, - create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0 } as any, + create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any, update: { - weekdayHours: { increment: otType.includes('工作日') ? hours : 0 }, - weekendHours: { increment: otType.includes('休息日') ? hours : 0 }, - holidayHours: { increment: otType.includes('法定') ? hours : 0 }, + weekdayHours: { increment: wdHours }, + weekendHours: { increment: weHours }, + holidayHours: { increment: hoHours }, }, }) result.overtime++ @@ -347,7 +535,10 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue } const changeType = val(r['变动类型']) const base = num(r['缴费基数']) + const city = val(r['城市']) || '北京' if (changeType === '增员' || changeType === '调基') { + const baseCheck = validateSocialBase(base, city) + if (baseCheck.warning) result.errors.push(`社保第${i + 2}行警告:${baseCheck.warning}`) // 关闭之前有效记录 await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } }) await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } }) @@ -397,7 +588,7 @@ router.get('/monthly-template', authMiddleware, async (req: AuthRequest, res: Re const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }] XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录') - const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '加班时长': 2, '加班类型': '工作日加班' }] + const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }] XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录') const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }] diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts index 925dd62..8ca1b8e 100644 --- a/backend/src/routes/notification.routes.ts +++ b/backend/src/routes/notification.routes.ts @@ -131,4 +131,39 @@ router.post('/check-contracts', async (req: AuthRequest, res: Response, next: Ne } }) +// 测试通知渠道 +router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { channel } = req.body as { channel: 'wechat' | 'email' } + const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } }) + if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } }) + + if (channel === 'wechat') { + if (!setting.wechatWebhook) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置企业微信 Webhook' } }) + try { + const resp = await fetch(setting.wechatWebhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msgtype: 'text', text: { content: '【测试消息】通知渠道连接正常,配置有效。' } }), + }) + const data = await resp.json() as any + if (data.errcode && data.errcode !== 0) { + return res.json({ success: false, error: { code: 'TEST_FAILED', message: `Webhook 返回错误: ${data.errmsg || data.errcode}` } }) + } + res.json({ success: true, data: { message: '测试消息已发送到企业微信' } }) + } catch (e: any) { + res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } }) + } + } else if (channel === 'email') { + if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } }) + // 邮件发送(开发阶段仅返回成功) + res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } }) + } else { + res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } }) + } + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index fd10e11..e43db11 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -1,13 +1,16 @@ import { Router, Request, Response, NextFunction } from 'express' import bcrypt from 'bcryptjs' +import multer from 'multer' +import path from 'path' +import fs from 'fs' import prisma from '../lib/prisma' import { signAccessToken, verifyAccessToken } from '../lib/jwt' -import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema } from '../schemas/portal.schema' +import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema' const router = Router() // 验证码临时存储(生产环境应使用 Redis) -const codeStore = new Map() +const codeStore = new Map() // 员工端认证中间件 function portalAuth(req: Request, res: Response, next: NextFunction) { @@ -59,8 +62,13 @@ router.post('/send-code', async (req, res, next) => { if (!employee) { 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) { + 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 }) + codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() }) res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } }) } catch (err) { next(err) @@ -75,8 +83,14 @@ router.post('/verify-code', async (req, res, next) => { if (!stored || stored.expiresAt < Date.now()) { return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } }) } + // 错误次数限制:5次后锁定 + if (stored.failCount >= 5) { + codeStore.delete(data.phone) + return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } }) + } if (stored.code !== data.code) { - return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } }) + stored.failCount++ + return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } }) } codeStore.delete(data.phone) const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } }) @@ -106,11 +120,26 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => { } }) +// 工资条历史(最近6个月) +router.get('/payslip/history', portalAuth, async (req: any, res, next) => { + try { + const payslips = await prisma.payslip.findMany({ + where: { employeeId: req.employee.id, orgId: req.employee.orgId }, + orderBy: { month: 'desc' }, + take: 6, + }) + res.json({ success: true, data: payslips }) + } catch (err) { + next(err) + } +}) + // 工资条确认已阅 router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => { try { const payslip = await prisma.payslip.findFirst({ where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id }, + include: { employee: true }, }) if (!payslip) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } }) @@ -119,6 +148,16 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => { where: { id: req.params.id }, data: { confirmedAt: new Date(), confirmedIp: req.ip }, }) + // 通知 HR + await prisma.notificationLog.create({ + data: { + orgId: req.employee.orgId, + title: '工资条确认通知', + content: `员工 ${payslip.employee.name} 已确认 ${payslip.month} 月工资条(IP: ${req.ip})`, + type: 'PAYSLIP_CONFIRM', + channel: 'IN_APP', + }, + }) res.json({ success: true }) } catch (err) { next(err) @@ -176,23 +215,68 @@ router.post('/onboarding', async (req, res, next) => { } }) +// 合同签署验证码发送 +router.post('/contract-confirm/send-code', async (req, res, next) => { + try { + const data = contractSendCodeSchema.parse(req.body) + const link = await prisma.contractConfirmLink.findFirst({ + where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } }, + include: { contract: { include: { employee: true } } }, + }) + if (!link) { + return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } }) + } + const phone = link.contract.employee.phone + if (!phone) { + 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() }) + res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } }) + } catch (err) { + next(err) + } +}) + // 合同签署确认 router.post('/contract-confirm', async (req, res, next) => { try { const data = contractConfirmSchema.parse(req.body) const link = await prisma.contractConfirmLink.findFirst({ where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } }, + include: { contract: { include: { employee: true } } }, }) if (!link) { 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()) { + return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } }) + } + if (stored.failCount >= 5) { + codeStore.delete(`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}次机会)` } }) + } + codeStore.delete(`contract-${data.token}`) + + const userAgent = req.headers['user-agent'] || '' + const signEvidence = JSON.stringify({ + ip: req.ip, + userAgent, + timestamp: new Date().toISOString(), + }) await prisma.contractConfirmLink.update({ where: { id: link.id }, data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip }, }) await prisma.laborContract.update({ where: { id: link.contractId }, - data: { attachmentName: `confirmed:${new Date().toISOString()}` }, + data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` }, }) res.json({ success: true, data: { message: '合同签署确认成功' } }) } catch (err) { @@ -216,6 +300,28 @@ router.get('/onboarding/:token', async (req, res, next) => { } }) +// 撤回入职链接(HR 端调用,需要认证) +router.post('/onboarding/:id/revoke', portalAuth, async (req: any, res, next) => { + try { + const link = await prisma.onboardingLink.findFirst({ + where: { id: req.params.id, orgId: req.employee.orgId }, + }) + if (!link) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } }) + } + if (link.status !== 'PENDING') { + return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '仅待填报状态的链接可撤回' } }) + } + await prisma.onboardingLink.update({ + where: { id: link.id }, + data: { status: 'CANCELLED' }, + }) + res.json({ success: true, data: { message: '入职链接已撤回' } }) + } catch (err) { + next(err) + } +}) + // 获取合同确认信息(通过 token) router.get('/contract-confirm/:token', async (req, res, next) => { try { @@ -245,4 +351,75 @@ router.get('/contract-confirm/:token', async (req, res, next) => { } }) +// 重发合同确认链接(HR 端调用,需要认证) +router.post('/contract-confirm/:id/resend', portalAuth, async (req: any, res, next) => { + try { + const link = await prisma.contractConfirmLink.findFirst({ + where: { id: req.params.id, orgId: req.employee.orgId }, + include: { contract: { include: { employee: true } } }, + }) + if (!link) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } }) + } + if (link.status === 'CONFIRMED') { + return res.status(400).json({ success: false, error: { code: 'ALREADY_CONFIRMED', message: '合同已确认,无需重发' } }) + } + // 生成新 token 并延长过期时间 + const crypto = await import('crypto') + const newToken = crypto.randomUUID() + await prisma.contractConfirmLink.update({ + where: { id: link.id }, + data: { + token: newToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + status: 'UNCONFIRMED', + }, + }) + res.json({ success: true, data: { token: newToken, message: '确认链接已重发,有效期7天' } }) + } catch (err) { + next(err) + } +}) + +// 入职文件上传 +const uploadDir = path.join(process.cwd(), 'uploads', 'onboarding') +if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }) + +const onboardingUpload = multer({ + storage: multer.diskStorage({ + destination: uploadDir, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname) + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`) + }, + }), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp'] + const ext = path.extname(file.originalname).toLowerCase() + if (allowed.includes(ext)) cb(null, true) + else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式')) + }, +}) + +router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async (req, res, next) => { + try { + if (!req.file) { + return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } }) + } + const link = await prisma.onboardingLink.findFirst({ + where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } }, + }) + if (!link) { + fs.unlinkSync(req.file.path) + return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } }) + } + const fileType = (req.body.fileType as string) || 'OTHER' + const fileUrl = `/uploads/onboarding/${req.file.filename}` + res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileType, fileSize: req.file.size } }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 2e507e8..0d74cee 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -11,6 +11,7 @@ const updateUserSchema = z.object({ name: z.string().min(1).optional(), phone: z.string().regex(/^1[3-9]\d{9}$/).optional(), email: z.string().email().optional(), + role: z.enum(['ADMIN', 'HR', 'VIEWER']).optional(), }) const createUserSchema = z.object({ @@ -56,7 +57,7 @@ router.get('/users', async (req: AuthRequest, res, next) => { try { const users = await prisma.user.findMany({ where: { orgId: req.user!.orgId }, - select: { id: true, name: true, phone: true, email: true, role: true, createdAt: true }, + select: { id: true, name: true, phone: true, email: true, role: true, disabled: true, createdAt: true, lastLoginAt: true }, orderBy: { createdAt: 'asc' }, }) res.json({ success: true, data: users }) @@ -97,7 +98,7 @@ router.put('/users/:id', async (req: AuthRequest, res, next) => { const user = await prisma.user.update({ where: { id: req.params.id }, data: data, - select: { id: true, name: true, phone: true, role: true }, + select: { id: true, name: true, phone: true, email: true, role: true, disabled: true }, }) res.json({ success: true, data: user }) } catch (err) { @@ -118,4 +119,70 @@ router.delete('/users/:id', async (req: AuthRequest, res, next) => { } }) +// 禁用/启用用户 +router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => { + try { + if (req.params.id === req.user!.id) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能禁用自己' } }) + } + const existing = await prisma.user.findUnique({ where: { id: req.params.id } }) + if (!existing) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '用户不存在' } }) + } + const user = await prisma.user.update({ + where: { id: req.params.id }, + data: { disabled: !existing.disabled }, + select: { id: true, name: true, disabled: true }, + }) + res.json({ success: true, data: user }) + } catch (err) { + next(err) + } +}) + +// 切换套餐 +router.put('/plan', async (req: AuthRequest, res, next) => { + try { + const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' } + if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的套餐' } }) + } + const maxEmployees = plan === 'FREE' ? 10 : plan === 'PRO' ? 100 : 999999 + const org = await prisma.organization.update({ + where: { id: req.user!.orgId }, + data: { plan, maxEmployees }, + select: { id: true, name: true, plan: true, maxEmployees: true }, + }) + res.json({ success: true, data: org }) + } catch (err) { + next(err) + } +}) + +// 用量统计 +router.get('/usage', async (req: AuthRequest, res, next) => { + try { + const orgId = req.user!.orgId + const [employeeCount, aiConversations, contracts] = await Promise.all([ + prisma.employee.count({ where: { orgId } }), + prisma.aIConversation.count({ where: { orgId } }), + prisma.laborContract.count({ where: { orgId } }), + ]) + const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { plan: true, maxEmployees: true } }) + res.json({ + success: true, + data: { + plan: org?.plan || 'FREE', + maxEmployees: org?.maxEmployees || 10, + employeeCount, + aiConversations, + contracts, + employeeUsage: `${employeeCount}/${org?.maxEmployees || 10}`, + }, + }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/schemas/portal.schema.ts b/backend/src/schemas/portal.schema.ts index db897e7..6e4293c 100644 --- a/backend/src/schemas/portal.schema.ts +++ b/backend/src/schemas/portal.schema.ts @@ -29,4 +29,9 @@ export const onboardingSchema = z.object({ export const contractConfirmSchema = z.object({ token: z.string().min(1, '缺少 token'), agreed: z.boolean().refine((v) => v === true, '请勾选确认签署'), + verifyCode: z.string().length(6, '验证码为6位数字'), +}) + +export const contractSendCodeSchema = z.object({ + token: z.string().min(1, '缺少 token'), }) diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index 891390c..1180f57 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -4,7 +4,7 @@ import { searchKnowledge } from './rag.service' const apiKey = process.env.DASHSCOPE_API_KEY || '' const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1' -const client = new OpenAI({ apiKey, baseURL }) +const client = new OpenAI({ apiKey, baseURL, timeout: 30 * 1000, maxRetries: 1 }) const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。 @@ -83,7 +83,7 @@ export async function* chatStream(messages: { role: 'user' | 'assistant'; conten } } -export async function reviewContract(contractText: string) { +export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: string } }> { const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。 合同文本: @@ -108,7 +108,28 @@ ${contractText} max_tokens: 3000, }) - return response.choices[0]?.message?.content || '' + const text = response.choices[0]?.message?.content || '' + + // 解析结构化数据 + const riskItems: { level: string; title: string; description: string; suggestion: string }[] = [] + const riskRegex = /(🔴|🟡|🟢)\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]/g + let match + while ((match = riskRegex.exec(text)) !== null) { + riskItems.push({ + level: match[1] === '🔴' ? 'RED' : match[1] === '🟡' ? 'YELLOW' : 'GREEN', + title: match[2], + description: match[3], + suggestion: match[4], + }) + } + + const scoreMatch = text.match(/【合规评分】\s*(\d+)\s*\/\s*100/) + const score = scoreMatch ? parseInt(scoreMatch[1]) : 0 + + const summaryMatch = text.match(/【总体建议】\s*([\s\S]*?)(?:$|$)/) + const summary = summaryMatch ? summaryMatch[1].trim() : '' + + return { text, structured: { riskItems, score, summary } } } export async function matchCase(scenario: string) { diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 1f009c2..af1d129 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -53,6 +53,10 @@ export async function login(phone: string, password: string) { throw { code: 'AUTH_FAILED', message: '手机号或密码错误' } } + if (user.disabled) { + throw { code: 'ACCOUNT_DISABLED', message: '该账号已被禁用,请联系管理员' } + } + await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx index d51e553..68a25c3 100644 --- a/frontend/src/pages/AIAssistant.tsx +++ b/frontend/src/pages/AIAssistant.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save } from 'lucide-react' +import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react' import api from '../lib/api' import { useAuthStore } from '../store/authStore' import Card from '../components/ui/Card' @@ -8,7 +8,7 @@ import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import Modal from '../components/ui/Modal' -type Tab = 'chat' | 'predict' | 'review' | 'case' +type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' interface Message { role: 'user' | 'assistant' @@ -30,6 +30,7 @@ export default function AIAssistant() { { key: 'predict', label: '风险预测', icon: Sparkles }, { key: 'review', label: '合同审查', icon: FileSearch }, { key: 'case', label: '案例匹配', icon: Scale }, + { key: 'knowledge', label: '知识库', icon: BookOpen }, ] return ( @@ -58,6 +59,7 @@ export default function AIAssistant() { {tab === 'predict' && } {tab === 'review' && } {tab === 'case' && } + {tab === 'knowledge' && } ) } @@ -166,6 +168,8 @@ function ChatTab() { try { const token = useAuthStore.getState().accessToken + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 35 * 1000) const response = await fetch('/api/v1/ai/chat-stream', { method: 'POST', headers: { @@ -173,7 +177,9 @@ function ChatTab() { ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ messages: newMessages }), + signal: controller.signal, }) + clearTimeout(timeoutId) if (!response.ok) { const errData = await response.json().catch(() => null) @@ -213,7 +219,8 @@ function ChatTab() { setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }]) } } catch (err: any) { - setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.message || '请稍后重试'}` }]) + const isTimeout = err.name === 'AbortError' + setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }]) } finally { setLoading(false) } @@ -390,7 +397,7 @@ function PredictTab() { function ReviewTab() { const [contractText, setContractText] = useState('') - const [result, setResult] = useState('') + const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [showSaveModal, setShowSaveModal] = useState(false) const [saveEmployeeId, setSaveEmployeeId] = useState('') @@ -406,12 +413,12 @@ function ReviewTab() { const handleReview = async () => { if (!contractText.trim()) return setLoading(true) - setResult('') + setResult(null) try { const res = await api.post('/ai/review', { contractText }) as any - setResult(res.data.result) + setResult(res.data) } catch (err: any) { - setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`) + setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` }) } finally { setLoading(false) } @@ -420,7 +427,7 @@ function ReviewTab() { const handleSave = async () => { if (!saveEmployeeId || !result) return try { - await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result }) + await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result: result.text || JSON.stringify(result) }) setShowSaveModal(false) setSaveEmployeeId('') alert('已保存到员工档案') @@ -429,6 +436,12 @@ function ReviewTab() { } } + const levelConfig: Record = { + RED: { color: 'text-red-600', bg: 'bg-red-50', label: '高风险' }, + YELLOW: { color: 'text-yellow-600', bg: 'bg-yellow-50', label: '中风险' }, + GREEN: { color: 'text-green-600', bg: 'bg-green-50', label: '低风险' }, + } + return (
@@ -456,7 +469,55 @@ function ReviewTab() {

审查结果

-
{result}
+ {result.error ? ( +
{result.error}
+ ) : result.structured ? ( +
+ {/* 合规评分 */} +
+ 合规评分 + = 80 ? 'text-safe' : result.structured.score >= 60 ? 'text-warning' : 'text-danger'}`}> + {result.structured.score}/100 + +
+ + {/* 风险项列表 */} + {result.structured.riskItems.length > 0 && ( +
+

风险项({result.structured.riskItems.length})

+ {result.structured.riskItems.map((item: any, i: number) => { + const cfg = levelConfig[item.level] || levelConfig.YELLOW + return ( +
+
+ {cfg.label} + {item.title} +
+
{item.description}
+
建议:{item.suggestion}
+
+ ) + })} +
+ )} + + {/* 总体建议 */} + {result.structured.summary && ( +
+

总体建议

+

{result.structured.summary}

+
+ )} + + {/* 原始文本可展开 */} +
+ 查看原始文本 +
{result.text}
+
+
+ ) : ( +
{result.text || JSON.stringify(result)}
+ )} )} @@ -486,6 +547,12 @@ function CaseTab() { const [loading, setLoading] = useState(false) const [showSaveModal, setShowSaveModal] = useState(false) const [saveEmployeeId, setSaveEmployeeId] = useState('') + const [showTodoModal, setShowTodoModal] = useState(false) + const [todoEmployeeId, setTodoEmployeeId] = useState('') + const [todoTitle, setTodoTitle] = useState('') + const [todoLevel, setTodoLevel] = useState('MEDIUM') + const [todoType, setTodoType] = useState('TERMINATION') + const [creatingTodo, setCreatingTodo] = useState(false) const { data: employees } = useQuery({ queryKey: ['roster-list'], @@ -521,6 +588,28 @@ function CaseTab() { } } + const handleCreateTodo = async () => { + if (!todoEmployeeId || !todoTitle) return + setCreatingTodo(true) + try { + await api.post('/ai/case-to-todo', { + employeeId: todoEmployeeId, + title: todoTitle, + description: result.slice(0, 500), + level: todoLevel, + type: todoType, + }) + setShowTodoModal(false) + setTodoEmployeeId('') + setTodoTitle('') + alert('已创建待办风险项') + } catch (err: any) { + alert('创建失败:' + (err.response?.data?.error?.message || '请稍后重试')) + } finally { + setCreatingTodo(false) + } + } + return (
@@ -546,7 +635,10 @@ function CaseTab() {

分析结果

- +
+ + +
{result}
@@ -568,6 +660,187 @@ function CaseTab() {
)} + + {showTodoModal && ( + setShowTodoModal(false)}> +
+

转为待办风险项

+
+ + +
+
+ + setTodoTitle(e.target.value)} placeholder="如:未签合同风险处理" /> +
+
+
+ + +
+
+ + +
+
+
分析结果将作为待办描述自动填入
+
+ + +
+
+
+ )} + + ) +} + +function KnowledgeTab() { + const queryClient = useQueryClient() + const [showAdd, setShowAdd] = useState(false) + const [newItem, setNewItem] = useState({ title: '', content: '', source: '自定义', category: '其他' }) + const [adding, setAdding] = useState(false) + + const { data: knowledgeList, isLoading } = useQuery({ + queryKey: ['rag-knowledge'], + queryFn: async () => { + const res = await api.get('/ai/rag/list') as any + return res.data + }, + }) + + const addMutation = useMutation({ + mutationFn: async (data: typeof newItem) => { + return await api.post('/ai/rag/add', data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }) + setShowAdd(false) + setNewItem({ title: '', content: '', source: '自定义', category: '其他' }) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/ai/rag/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }), + }) + + const seedMutation = useMutation({ + mutationFn: () => api.post('/ai/rag/seed'), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }), + }) + + const handleAdd = async () => { + if (!newItem.title || !newItem.content) return + setAdding(true) + try { + await addMutation.mutateAsync(newItem) + } finally { + setAdding(false) + } + } + + return ( +
+
+ 共 {knowledgeList?.length || 0} 条知识 +
+ + +
+
+ + {isLoading ? ( +
加载中...
+ ) : !knowledgeList || knowledgeList.length === 0 ? ( +
知识库为空,请点击「初始化知识库」
+ ) : ( +
+ {knowledgeList.map((item: any) => ( + +
+
+
+ {item.title} + {item.category} +
+

{item.content}

+
来源:{item.source}
+
+ +
+
+ ))} +
+ )} + + {showAdd && ( + setShowAdd(false)}> +
+

添加知识条目

+
+ + setNewItem({ ...newItem, title: e.target.value })} placeholder="如:劳动合同法第十条" /> +
+
+ +