diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4ffaf43..e1d3d53 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -117,6 +117,8 @@ model Organization { plan Plan @default(FREE) maxEmployees Int @default(20) city String? + contactName String? + contactPhone String? payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index 0c49e74..cd3fa68 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' -import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service' +import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream } from '../services/ai.service' import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service' import prisma from '../lib/prisma' import { z } from 'zod' @@ -48,6 +48,31 @@ async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review }) } +// 日期计算工具:计算两个日期之间的年月日差,以及相对当前的天数差 +function calcDuration(start: Date, end: Date): string { + let years = end.getFullYear() - start.getFullYear() + let months = end.getMonth() - start.getMonth() + let days = end.getDate() - start.getDate() + if (days < 0) { + months-- + const prevMonth = new Date(end.getFullYear(), end.getMonth(), 0) + days += prevMonth.getDate() + } + if (months < 0) { + years-- + months += 12 + } + const parts: string[] = [] + if (years > 0) parts.push(`${years}年`) + if (months > 0) parts.push(`${months}个月`) + if (days > 0) parts.push(`${days}天`) + return parts.length > 0 ? parts.join('') : '不足1天' +} + +function daysFromNow(date: Date): number { + return Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) +} + async function buildOrgContext(orgId: string): Promise { const [employees, risks] = await Promise.all([ prisma.employee.findMany({ @@ -63,14 +88,13 @@ async function buildOrgContext(orgId: string): Promise { const now = new Date() const empSummary = employees.map((e) => { const contract = e.contracts[0] - const daysToExpire = contract?.endDate - ? Math.floor((new Date(contract.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) - : null + const daysToExpire = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null + const tenure = calcDuration(new Date(e.hireDate), now) 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('/')}` : ''}` + return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}(已工作${tenure}),${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}):${r.description || '无详细描述'}`).join('\n') @@ -199,27 +223,48 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => { const riskType = req.query.riskType as string let orgContext = await buildOrgContext(req.user!.orgId) + let scopeHint = '' if (employeeId) { - const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } }) + const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { createdAt: 'desc' }, take: 1 } } }) if (emp) { const contract = emp.contracts[0] - orgContext = `员工详情: + const term = emp.terminations[0] + const statusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' } + const typeMap: Record = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' } + const reasonMap: Record = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } + const checklist = term?.checklist as any || {} + const handoverItems = (term?.handoverItems as any[]) || [] + const handoverDone = handoverItems.length > 0 ? handoverItems.every((h: any) => h.done) : false + const agreementSigned = !!checklist.agreement_signed + const socialInsEnd = term?.socialInsEndMonth ? `,社保截止${term.socialInsEndMonth}` : '' + const housingFundEnd = term?.housingFundEndMonth ? `,公积金截止${term.housingFundEndMonth}` : '' + const tenure = calcDuration(new Date(emp.hireDate), term ? new Date(term.terminationDate) : new Date()) + const contractDaysLeft = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null + orgContext = `当前日期:${new Date().toISOString().slice(0, 10)} +员工详情: - 姓名:${emp.name} - 部门:${emp.department} -- 入职日期:${emp.hireDate.toISOString().slice(0, 10)} +- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}) - 状态:${emp.status} - 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'} -- 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}` : '未签合同'}\n${orgContext}` +- 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd},${handoverDone ? '工作交接已完成' : '工作交接未完成'},${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}` + scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。` } } else if (department) { const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } }) const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n') - orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}\n\n${orgContext}` + orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}` + scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。` } if (riskType && riskType !== 'all') { - orgContext = `请重点关注【${riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType}】类风险。\n\n${orgContext}` + const riskLabel = riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType + scopeHint += `\n请重点关注【${riskLabel}】类风险,其他类型风险可简要提及。` + } + + if (scopeHint) { + orgContext = `${scopeHint}\n\n${orgContext}` } const result = await predictRisks(orgContext) @@ -229,12 +274,105 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => { } }) +router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const department = req.query.department as string + const employeeId = req.query.employeeId as string + const riskType = req.query.riskType as string + + let orgContext = await buildOrgContext(req.user!.orgId) + let scopeHint = '' + + if (employeeId) { + const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { createdAt: 'desc' }, take: 1 } } }) + if (emp) { + const contract = emp.contracts[0] + const term = emp.terminations[0] + const statusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' } + const typeMap: Record = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' } + const reasonMap: Record = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } + const checklist = term?.checklist as any || {} + const handoverItems = (term?.handoverItems as any[]) || [] + const handoverDone = handoverItems.length > 0 ? handoverItems.every((h: any) => h.done) : false + const agreementSigned = !!checklist.agreement_signed + const socialInsEnd = term?.socialInsEndMonth ? `,社保截止${term.socialInsEndMonth}` : '' + const housingFundEnd = term?.housingFundEndMonth ? `,公积金截止${term.housingFundEndMonth}` : '' + const tenure = calcDuration(new Date(emp.hireDate), term ? new Date(term.terminationDate) : new Date()) + const contractDaysLeft = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null + orgContext = `当前日期:${new Date().toISOString().slice(0, 10)} +员工详情: +- 姓名:${emp.name} +- 部门:${emp.department} +- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}) +- 状态:${emp.status} +- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'} +- 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd},${handoverDone ? '工作交接已完成' : '工作交接未完成'},${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}` + scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。` + } + } else if (department) { + const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } }) + const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n') + orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}` + scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。` + } + + if (riskType && riskType !== 'all') { + const riskLabel = riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType + scopeHint += `\n请重点关注【${riskLabel}】类风险,其他类型风险可简要提及。` + } + + if (scopeHint) { + orgContext = `${scopeHint}\n\n${orgContext}` + } + + await checkUsageLimit(req.user!.orgId, 'chat') + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + let usageRecorded = false + try { + for await (const delta of predictRisksStream(orgContext)) { + res.write(`data: ${JSON.stringify({ delta })}\n\n`) + if (typeof (res as any).flush === 'function') (res as any).flush() + } + res.write('data: [DONE]\n\n') + } catch (streamErr: any) { + res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`) + res.write('data: [DONE]\n\n') + } finally { + if (!usageRecorded) { + await recordUsage(req.user!.orgId, req.user!.id, 'chat') + usageRecorded = true + } + } + res.end() + } catch (err) { + if (!res.headersSent) next(err) + else res.end() + } +}) + // ========== AI 会话历史 ========== router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => { try { + const type = req.query.type as string | undefined + const where: any = { orgId: req.user!.orgId, userId: req.user!.id } + if (type) { + const prefixes: Record = { + chat: 'chat:', + predict: 'predict:', + review: 'review:', + case: 'case:', + } + const prefix = prefixes[type] || type + where.title = { startsWith: prefix } + } const conversations = await prisma.aIConversation.findMany({ - where: { orgId: req.user!.orgId, userId: req.user!.id }, + where, orderBy: { updatedAt: 'desc' }, take: 50, select: { id: true, title: true, createdAt: true, updatedAt: true }, diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index dd423ea..6c11d60 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -105,7 +105,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { contractType: 'UNSIGNED', hireDate: e.hireDate, }) - const isResigned = e.terminations.some((t) => t.terminationDate <= today) + const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) const isPreHire = !isResigned && e.hireDate > todayEnd const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE') return { @@ -179,7 +179,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) = const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee const today = new Date() today.setHours(0, 0, 0, 0) - const dynamicStatus = employee.terminations.some((t) => t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE' + const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE' res.json({ success: true, data: { @@ -220,6 +220,11 @@ router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, const empDept = employee.department const hireDate = employee.hireDate.toISOString().slice(0, 10) + // 风险检测 + const risks: any[] = [] + const today = new Date() + today.setHours(0, 0, 0, 0) + // 1. 劳动关系证据 evidence.push({ category: '劳动关系', @@ -228,17 +233,86 @@ router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`, evidenceType: 'EMPLOYMENT', }) + + // 无合同风险检测 + if (employee.contracts.length === 0) { + const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / (86400000)) + if (daysSinceHire > 30) { + risks.push({ + level: daysSinceHire > 365 ? 'DANGER' : 'HIGH', + category: '劳动关系', + title: '未签订书面劳动合同', + description: `入职已${daysSinceHire}天仍未签订书面劳动合同,超过30天未签合同将面临双倍工资赔偿风险(《劳动合同法》第82条)。${daysSinceHire > 365 ? '已满一年未签合同,视为已订立无固定期限劳动合同。' : ''}`, + }) + } + } + employee.contracts.forEach((c) => { + const contractTypeText = ({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record)[c.contractType] || '未签订' + const isSigned = !!c.signDate evidence.push({ category: '劳动关系', - title: `合同(${({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record)[c.contractType] || '未签订'})`, + title: `合同(${contractTypeText})`, date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10), - description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。`, + description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。${isSigned ? '' : '⚠ 该合同尚未签订。'}`, evidenceType: 'CONTRACT', - signed: !!c.signDate, + signed: isSigned, + riskLevel: !isSigned ? 'HIGH' : undefined, }) + + // 合同未签字风险 + if (!isSigned) { + risks.push({ + level: 'HIGH', + category: '劳动关系', + title: '合同未签字', + description: `合同(${contractTypeText})期限${c.startDate.toISOString().slice(0, 10)}至${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},尚未签订。未签合同在仲裁中无法证明劳动关系约定内容。`, + }) + } + + // 合同过期风险 + if (c.endDate && c.endDate < today) { + const expiredDays = Math.floor((today.getTime() - c.endDate.getTime()) / 86400000) + risks.push({ + level: expiredDays > 30 ? 'HIGH' : 'MEDIUM', + category: '劳动关系', + title: '合同已过期', + description: `合同已于${c.endDate.toISOString().slice(0, 10)}过期,过期${expiredDays}天。过期后继续用工满一个月未续签的,面临双倍工资风险。`, + }) + } else if (c.endDate) { + const daysToExpire = Math.floor((c.endDate.getTime() - today.getTime()) / 86400000) + if (daysToExpire <= 30 && daysToExpire >= 0) { + risks.push({ + level: 'MEDIUM', + category: '劳动关系', + title: '合同即将到期', + description: `合同将于${c.endDate.toISOString().slice(0, 10)}到期,剩余${daysToExpire}天。请及时办理续签或终止手续。`, + }) + } + } + + // 试用期工资为0风险 + if (c.probationMonths > 0 && c.probationSalary === 0) { + risks.push({ + level: 'MEDIUM', + category: '劳动关系', + title: '试用期工资为0', + description: `合同约定试用期${c.probationMonths}个月但试用期工资为0,违反《劳动合同法》第20条(试用期工资不得低于本单位相同岗位最低档工资的80%或劳动合同约定工资的80%)。`, + }) + } }) + // 无工资条风险检测(入职超过1个月但无工资条) + const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000) + if (daysSinceHire > 30 && employee.payslips.length === 0) { + risks.push({ + level: 'MEDIUM', + category: '薪酬发放', + title: '无工资条记录', + description: `入职已${daysSinceHire}天但无任何工资条记录,在仲裁中难以证明已按时足额支付工资。建议尽快创建发薪批次并归档。`, + }) + } + // 2. 薪酬证据 employee.payslips.forEach((p) => { evidence.push({ @@ -317,14 +391,39 @@ router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, // 7. 解聘证据 employee.terminations.forEach((t) => { - const reasonMap: Record = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期' } + const reasonMap: Record = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } + const typeLabel = t.type === 'RESIGNATION' && t.reason === 'NEGOTIATED' + ? '协商一致离职' + : t.type === 'RESIGNATION' ? '员工主动离职' : '公司解聘' + const statusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' } evidence.push({ category: '解聘记录', - title: `${t.terminationDate.toISOString().slice(0, 10)} 解聘记录`, + title: `${t.terminationDate.toISOString().slice(0, 10)} ${typeLabel}记录`, date: t.terminationDate.toISOString().slice(0, 10), - description: `解聘原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。${t.remark || ''}`, + description: `类型:${typeLabel}。原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。流程状态:${statusMap[t.status] || t.status}。${t.resignationReason ? `离职原因:${t.resignationReason}。` : ''}${t.remark || ''}`, evidenceType: 'TERMINATION', + acknowledged: t.status === 'COMPLETED', }) + + // 离职流程未完成风险 + if (t.status !== 'COMPLETED') { + risks.push({ + level: 'HIGH', + category: '解聘记录', + title: '离职流程未完成', + description: `${typeLabel}流程当前状态为「${statusMap[t.status] || t.status}」,尚未完成闭环。离职日期${t.terminationDate.toISOString().slice(0, 10)},若未签署解除协议/离职确认书、未完成工作交接、未结清工资、未办理社保减员,在仲裁中无法证明离职的合法性与完整性,面临继续履行合同或违法解除赔偿(2N)风险。`, + }) + } + + // 协商解除但补偿金为0 + if (t.reason === 'NEGOTIATED' && t.compensation === 0) { + risks.push({ + level: 'MEDIUM', + category: '解聘记录', + title: '协商解除但补偿金为0', + description: `解聘原因为协商解除但经济补偿金为0,若员工事后主张非自愿离职,企业需举证协商一致且员工自愿放弃补偿,否则可能被认定为单方违法解除,面临2N赔偿风险。`, + }) + } }) res.json({ @@ -334,15 +433,17 @@ router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, name: empName, department: empDept, hireDate, - status: employee.terminations.some((t) => t.terminationDate <= new Date()) ? 'RESIGNED' : 'ACTIVE', + status: employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= new Date()) ? 'RESIGNED' : 'ACTIVE', gender: employee.gender, phone: employee.phone, }, evidence, + risks, summary: { total: evidence.length, signed: evidence.filter((e) => e.acknowledged === true).length, unsigned: evidence.filter((e) => e.acknowledged === false).length, + riskCount: risks.length, }, }, }) diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 1809139..88dee0e 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -27,7 +27,7 @@ router.get('/org', async (req: AuthRequest, res, next) => { try { const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, - select: { id: true, name: true, plan: true, maxEmployees: true, createdAt: true }, + select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, createdAt: true }, }) res.json({ success: true, data: org }) } catch (err) { @@ -38,14 +38,17 @@ router.get('/org', async (req: AuthRequest, res, next) => { // 更新企业信息 router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => { try { - const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number } + const { name, payrollFrequency, city, contactName, contactPhone } = req.body as { name?: string; payrollFrequency?: number; city?: string; contactName?: string; contactPhone?: string } const updateData: any = {} if (name) updateData.name = name if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency + if (city !== undefined) updateData.city = city + if (contactName !== undefined) updateData.contactName = contactName + if (contactPhone !== undefined) updateData.contactPhone = contactPhone const org = await prisma.organization.update({ where: { id: req.user!.orgId }, data: updateData, - select: { id: true, name: true, plan: true, maxEmployees: true, payrollFrequency: true }, + select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true }, }) res.json({ success: true, data: org }) } catch (err) { diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index 1180f57..c0b9fe2 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -193,3 +193,43 @@ ${orgContext} return response.choices[0]?.message?.content || '' } + +export async function* predictRisksStream(orgContext: string) { + const prompt = `基于以下企业用工数据,预测未来30天可能出现的合规风险,并给出优先级建议。 + +请使用 Markdown 格式输出,包含以下部分: + +## 未来30天预计风险 + +用表格列出每个风险项: + +| 优先级 | 员工/范围 | 风险描述 | 法律依据 | 建议措施 | 截止日期 | +|--------|-----------|----------|----------|----------|----------| + +## 优先级建议 + +按紧急程度排序,说明先处理什么、再处理什么。 + +## 合规建议总结 + +简要总结企业当前用工合规状况和改进方向。 + +企业数据: +${orgContext}` + + const stream = await client.chat.completions.create({ + model: 'qwen-plus', + messages: [ + { role: 'system', content: '你是劳动用工风险预测专家,能基于企业数据分析未来风险趋势。请使用 Markdown 格式输出,善用表格、加粗、列表等格式。' }, + { role: 'user', content: prompt }, + ], + temperature: 0.5, + max_tokens: 2000, + stream: true, + }) + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content + if (delta) yield delta + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b4b81e0..8edf7bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.26.0", "recharts": "^3.10.0", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "xlsx": "^0.18.5", @@ -2284,6 +2285,18 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2686,6 +2699,64 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmmirror.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -2713,6 +2784,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -2726,6 +2816,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2736,6 +2843,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", @@ -4047,6 +4164,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", @@ -4538,6 +4667,21 @@ "license": "MIT", "optional": true }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -5215,6 +5359,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", @@ -5311,6 +5469,16 @@ } } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8379671..3783eb7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.26.0", "recharts": "^3.10.0", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "xlsx": "^0.18.5", diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx index 6e7f2f6..1d830b0 100644 --- a/frontend/src/pages/AIAssistant.tsx +++ b/frontend/src/pages/AIAssistant.tsx @@ -1,9 +1,10 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react' +import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import rehypeRaw from 'rehype-raw' import api from '../lib/api' import { useAuthStore } from '../store/authStore' import Card from '../components/ui/Card' @@ -13,6 +14,64 @@ import Modal from '../components/ui/Modal' type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' +// 通用 AI 历史记录 hook +function useAIHistory(type: 'predict' | 'review' | 'case') { + const queryClient = useQueryClient() + const queryKey = [`ai-history-${type}`] + + const { data: history } = useQuery({ + queryKey, + queryFn: async () => { + const res = await api.get(`/ai/conversations?type=${type}`) as any + return res.data + }, + }) + + const saveMutation = useMutation({ + mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => { + const res = await api.post('/ai/conversations', { + title: `${type}:${title}`, + messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }], + }) as any + return res.data + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + }) + + const loadHistory = useCallback(async (id: string) => { + const res = await api.get(`/ai/conversations/${id}`) as any + return res.data + }, []) + + return { history, saveMutation, deleteMutation, loadHistory } +} + +// 通用历史记录栏组件 +function HistoryBar({ history, onLoad, onDelete }: { + history: any[] + onLoad: (id: string) => void + onDelete: (id: string) => void +}) { + return ( +
+ {history.length > 0 ? history.map((c: any) => ( +
+ onLoad(c.id)}> + {c.title.replace(/^(predict:|review:|case:)/, '')} + + {new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} + +
+ )) :
暂无历史记录
} +
+ ) +} + interface Message { role: 'user' | 'assistant' content: string @@ -90,7 +149,7 @@ function ChatTab() { const { data: conversations } = useQuery({ queryKey: ['ai-conversations'], queryFn: async () => { - const res = await api.get('/ai/conversations') as any + const res = await api.get('/ai/conversations?type=chat') as any return res.data }, }) @@ -109,7 +168,7 @@ function ChatTab() { if (messages.length <= 1) return if (saveTimerRef.current) clearTimeout(saveTimerRef.current) saveTimerRef.current = setTimeout(async () => { - const title = messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话' + const title = `chat:${messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'}` if (currentConvId) { await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {}) } else { @@ -280,7 +339,7 @@ function ChatTab() {
{conversations && conversations.length > 0 ? conversations.map((c: any) => (
- loadConversation(c.id)}>{c.title} + loadConversation(c.id)}>{c.title.replace(/^chat:/, '')} {new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
@@ -372,6 +431,9 @@ function PredictTab() { const [riskType, setRiskType] = useState('all') const [department, setDepartment] = useState('') const [employeeId, setEmployeeId] = useState('') + const [showHistory, setShowHistory] = useState(false) + const abortRef = useRef(null) + const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('predict') const { data: employees } = useQuery({ queryKey: ['roster-list'], @@ -384,44 +446,143 @@ function PredictTab() { const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))] const fetchPrediction = async () => { + if (loading) return + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + setLoading(true) + setResult('') + try { - const params: Record = {} - if (scope === 'department' && department) params.department = department - if (scope === 'employee' && employeeId) params.employeeId = employeeId - if (riskType !== 'all') params.riskType = riskType - const res = await api.get('/ai/predict', { params }) as any - setResult(res.data.result) + const token = useAuthStore.getState().accessToken + const params = new URLSearchParams() + if (scope === 'department' && department) params.set('department', department) + if (scope === 'employee' && employeeId) params.set('employeeId', employeeId) + if (riskType !== 'all') params.set('riskType', riskType) + + const predictUrl = import.meta.env.DEV + ? `http://localhost:3000/api/v1/ai/predict-stream?${params}` + : `/api/v1/ai/predict-stream?${params}` + + const response = await fetch(predictUrl, { + method: 'GET', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: controller.signal, + }) + + if (!response.ok) { + const errData = await response.json().catch(() => null) + throw new Error(errData?.error?.message || '请求失败') + } + + const reader = response.body?.getReader() + const decoder = new TextDecoder() + let accumulated = '' + let buffer = '' + let rafId: number | null = null + let pendingFlush = false + + const flush = () => { + pendingFlush = false + rafId = null + setResult(accumulated) + } + const scheduleFlush = () => { + if (!pendingFlush) { + pendingFlush = true + rafId = requestAnimationFrame(flush) + } + } + + if (reader) { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim() + if (data === '[DONE]') continue + try { + const parsed = JSON.parse(data) + if (parsed.delta) { + accumulated += parsed.delta + scheduleFlush() + } + if (parsed.error) { + throw new Error(parsed.error) + } + } catch (parseErr: any) { + if (parseErr instanceof SyntaxError) continue + throw parseErr + } + } + } + } + if (rafId) cancelAnimationFrame(rafId) + setResult(accumulated) + // 自动保存到历史 + if (accumulated && !accumulated.startsWith('**出错了**')) { + const scopeLabel = scope === 'all' ? '全部员工' : scope === 'department' ? department : employees?.find((e: any) => e.id === employeeId)?.name || '指定员工' + const riskLabel = riskType === 'all' ? '全部类型' : riskType + saveMutation.mutate({ title: `${scopeLabel}-${riskLabel}`, input: `范围:${scopeLabel} 类型:${riskLabel}`, result: accumulated }) + } + } } catch (err: any) { - setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`) + if (err.name === 'AbortError') return + setResult(`**出错了**:${err.message || '请稍后重试'}`) } finally { setLoading(false) } } - useEffect(() => { - fetchPrediction() - }, []) + const handleLoadHistory = async (id: string) => { + const data = await loadHistory(id) + if (data?.messages) { + const assistantMsg = data.messages.find((m: any) => m.role === 'assistant') + if (assistantMsg) { + setResult(assistantMsg.content) + setShowHistory(false) + } + } + } return (

AI 风险预测

+
+ {showHistory && ( +
+ deleteMutation.mutate(id)} /> +
+ )} + {/* 筛选条件 */} -
-
- +
+
+ +
+
+
-
- +
+
{scope === 'department' && ( -
- +
+ setEmployeeId(e.target.value)}> {(employees || []).map((e: any) => )} @@ -449,16 +610,33 @@ function PredictTab() { )}
- {loading ? ( + {loading && !result && (
- 分析中... + 正在分析企业用工风险... +
+ )} + {result && ( +
+ {result} + {loading && } +
+ )} + {!result && !loading && ( +
+ +

选择筛选条件后点击「开始预测」按钮进行AI风险分析

- ) : ( -
{result}
)} -
- -
) } @@ -469,6 +647,8 @@ function ReviewTab() { const [loading, setLoading] = useState(false) const [showSaveModal, setShowSaveModal] = useState(false) const [saveEmployeeId, setSaveEmployeeId] = useState('') + const [showHistory, setShowHistory] = useState(false) + const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review') const { data: employees } = useQuery({ queryKey: ['roster-list'], @@ -485,6 +665,11 @@ function ReviewTab() { try { const res = await api.post('/ai/review', { contractText }) as any setResult(res.data) + // 自动保存到历史 + if (res.data && !res.data.error) { + const title = contractText.slice(0, 30).replace(/\n/g, ' ') + saveMutation.mutate({ title, input: contractText, result: res.data.text || JSON.stringify(res.data) }) + } } catch (err: any) { setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` }) } finally { @@ -492,6 +677,19 @@ function ReviewTab() { } } + const handleLoadHistory = async (id: string) => { + const data = await loadHistory(id) + if (data?.messages) { + const userMsg = data.messages.find((m: any) => m.role === 'user') + const assistantMsg = data.messages.find((m: any) => m.role === 'assistant') + if (userMsg) setContractText(userMsg.content) + if (assistantMsg) { + try { setResult(JSON.parse(assistantMsg.content)) } catch { setResult({ text: assistantMsg.content }) } + } + setShowHistory(false) + } + } + const handleSave = async () => { if (!saveEmployeeId || !result) return try { @@ -516,7 +714,15 @@ function ReviewTab() {

合同审查

+
+ + {showHistory && ( +
+ deleteMutation.mutate(id)} /> +
+ )} +