feat: AI历史记录、企业信息seed、员工状态自动刷新、风险预测数据准确性优化
- 智能问答/风险预测/合同审查/案例匹配 均支持历史记录保存、加载、删除 - 后端 predict/predict-stream 补充离职记录详情和预计算工龄/天数,避免AI计算错误 - Organization 增加 contactName/contactPhone 字段,seed 企业信息 - Settings 页面修复 orgData/usersData 取值路径错误 - Roster/Termination/Contracts mutation 增加 roster cache invalidation - 员工档案页面 tab 固定、内容区域滚动到窗口底部 - 解聘证据链显示逻辑修复(协商一致离职 vs 员工主动离职)
This commit is contained in:
@@ -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
|
||||
|
||||
+150
-12
@@ -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<string> {
|
||||
const [employees, risks] = await Promise.all([
|
||||
prisma.employee.findMany({
|
||||
@@ -63,14 +88,13 @@ async function buildOrgContext(orgId: string): Promise<string> {
|
||||
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<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' }
|
||||
const typeMap: Record<string, string> = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' }
|
||||
const reasonMap: Record<string, string> = { 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<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' }
|
||||
const typeMap: Record<string, string> = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' }
|
||||
const reasonMap: Record<string, string> = { 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<string, string> = {
|
||||
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 },
|
||||
|
||||
@@ -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<string, string>)[c.contractType] || '未签订'
|
||||
const isSigned = !!c.signDate
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: `合同(${({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record<string, string>)[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<string, string> = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期' }
|
||||
const reasonMap: Record<string, string> = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' }
|
||||
const typeLabel = t.type === 'RESIGNATION' && t.reason === 'NEGOTIATED'
|
||||
? '协商一致离职'
|
||||
: t.type === 'RESIGNATION' ? '员工主动离职' : '公司解聘'
|
||||
const statusMap: Record<string, string> = { 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+168
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<any[]>({
|
||||
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 (
|
||||
<div className="border-b pb-2 max-h-40 overflow-y-auto">
|
||||
{history.length > 0 ? history.map((c: any) => (
|
||||
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
|
||||
<span className="flex-1 truncate" onClick={() => onLoad(c.id)}>
|
||||
{c.title.replace(/^(predict:|review:|case:)/, '')}
|
||||
</span>
|
||||
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)) : <div className="text-xs text-gray-400 py-2 text-center">暂无历史记录</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
@@ -90,7 +149,7 @@ function ChatTab() {
|
||||
const { data: conversations } = useQuery<any[]>({
|
||||
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() {
|
||||
<div className="border-b pb-2 max-h-40 overflow-y-auto">
|
||||
{conversations && conversations.length > 0 ? conversations.map((c: any) => (
|
||||
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
|
||||
<span className="flex-1 truncate" onClick={() => loadConversation(c.id)}>{c.title}</span>
|
||||
<span className="flex-1 truncate" onClick={() => loadConversation(c.id)}>{c.title.replace(/^chat:/, '')}</span>
|
||||
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteConvMutation.mutate(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
|
||||
</div>
|
||||
@@ -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<AbortController | null>(null)
|
||||
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('predict')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
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<string, string> = {}
|
||||
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 (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">AI 风险预测</h2>
|
||||
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||
</div>
|
||||
|
||||
{showHistory && (
|
||||
<div className="mt-2">
|
||||
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
<div>
|
||||
<Label>预测范围</Label>
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap mt-3">
|
||||
<div className="min-w-[120px]">
|
||||
<Button size="sm" onClick={fetchPrediction} disabled={loading}>
|
||||
{loading ? '分析中...' : result ? '重新预测' : '开始预测'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">预测范围</Label>
|
||||
<Select value={scope} onChange={(e) => setScope(e.target.value)}>
|
||||
<option value="all">全部员工</option>
|
||||
<option value="department">按部门</option>
|
||||
<option value="employee">指定员工</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>风险类型</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">风险类型</Label>
|
||||
<Select value={riskType} onChange={(e) => setRiskType(e.target.value)}>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="contract">合同风险</option>
|
||||
@@ -430,8 +591,8 @@ function PredictTab() {
|
||||
</Select>
|
||||
</div>
|
||||
{scope === 'department' && (
|
||||
<div>
|
||||
<Label>部门</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">部门</Label>
|
||||
<Select value={department} onChange={(e) => setDepartment(e.target.value)}>
|
||||
<option value="">选择部门</option>
|
||||
{departments.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
@@ -439,8 +600,8 @@ function PredictTab() {
|
||||
</div>
|
||||
)}
|
||||
{scope === 'employee' && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">员工</Label>
|
||||
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||||
@@ -449,16 +610,33 @@ function PredictTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{loading && !result && (
|
||||
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 正在分析企业用工风险...
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="prose prose-sm max-w-none mt-4 overflow-x-auto
|
||||
[&_table]:border-collapse [&_table]:w-full [&_table]:text-xs [&_table]:min-w-[600px]
|
||||
[&_th]:border [&_th]:border-gray-300 [&_th]:px-2 [&_th]:py-1 [&_th]:bg-gray-50 [&_th]:font-medium [&_th]:whitespace-nowrap
|
||||
[&_td]:border [&_td]:border-gray-300 [&_td]:px-2 [&_td]:py-1 [&_td]:align-top
|
||||
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2
|
||||
[&_h3]:text-xs [&_h3]:font-medium [&_h3]:mt-3 [&_h3]:mb-1
|
||||
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:text-xs
|
||||
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:text-xs
|
||||
[&_strong]:font-semibold
|
||||
[&_p]:text-xs [&_p]:leading-relaxed
|
||||
[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-3 [&_blockquote]:text-gray-600 [&_blockquote]:text-xs [&_blockquote]:my-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{result}</ReactMarkdown>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
|
||||
</div>
|
||||
)}
|
||||
{!result && !loading && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-xs">选择筛选条件后点击「开始预测」按钮进行AI风险分析</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button variant="secondary" size="sm" onClick={fetchPrediction} disabled={loading}>刷新预测</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<any[]>({
|
||||
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() {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileSearch className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">合同审查</h2>
|
||||
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||
</div>
|
||||
|
||||
{showHistory && (
|
||||
<div className="mt-2 mb-3">
|
||||
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Label>粘贴合同条款文本</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[200px] resize-y"
|
||||
@@ -529,6 +735,7 @@ function ReviewTab() {
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />审查中...</> : '开始审查'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
@@ -615,12 +822,14 @@ function CaseTab() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
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 { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('case')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
@@ -637,6 +846,11 @@ function CaseTab() {
|
||||
try {
|
||||
const res = await api.post('/ai/match-case', { scenario }) as any
|
||||
setResult(res.data.result)
|
||||
// 自动保存到历史
|
||||
if (res.data?.result && !res.data.result.startsWith('出错了')) {
|
||||
const title = scenario.slice(0, 30).replace(/\n/g, ' ')
|
||||
saveMutation.mutate({ title, input: scenario, result: res.data.result })
|
||||
}
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
@@ -644,6 +858,17 @@ function CaseTab() {
|
||||
}
|
||||
}
|
||||
|
||||
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) setScenario(userMsg.content)
|
||||
if (assistantMsg) setResult(assistantMsg.content)
|
||||
setShowHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
@@ -684,7 +909,15 @@ function CaseTab() {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Scale className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">案例匹配</h2>
|
||||
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||
</div>
|
||||
|
||||
{showHistory && (
|
||||
<div className="mt-2 mb-3">
|
||||
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Label>描述你的争议情形</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[150px] resize-y"
|
||||
@@ -697,6 +930,7 @@ function CaseTab() {
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />分析中...</> : '分析'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function Contracts() {
|
||||
mutationFn: (data: any) => api.post('/employees', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setShowAddModal(false)
|
||||
},
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function Roster() {
|
||||
mutationFn: (data: any) => api.post('/termination/draft', {
|
||||
employeeId: data.employeeId,
|
||||
type: 'RESIGNATION',
|
||||
reason: 'NEGOTIATED',
|
||||
reason: 'RESIGNATION',
|
||||
terminationDate: data.terminationDate,
|
||||
resignationReason: data.resignationReason,
|
||||
remark: data.remark,
|
||||
@@ -792,8 +792,8 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
if (!profile) return <div className="text-center py-8 text-gray-400">员工不存在</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col h-[calc(100vh-120px)]">
|
||||
<div className="flex items-center gap-3 shrink-0 pb-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -803,7 +803,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
@@ -821,6 +821,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pt-3">
|
||||
{tab === 'basic' && <BasicInfo profile={profile} />}
|
||||
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||||
{tab === 'payslip' && <PayslipInfo payslips={profile.payslips} />}
|
||||
@@ -832,6 +833,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||||
{tab === 'attachment' && <AttachmentInfo employeeId={employeeId} attachments={profile.attachments} />}
|
||||
{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -903,6 +905,13 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
{ label: '开户行', value: profile.bankName || '未填写' },
|
||||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||||
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
|
||||
? [{ label: '离职日期', value: profile.terminations
|
||||
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.reverse()[0] || '未记录' }]
|
||||
: []),
|
||||
]
|
||||
const special = [
|
||||
{ label: '孕期', value: profile.isPregnant },
|
||||
@@ -2808,6 +2817,17 @@ function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const riskStyle: Record<string, string> = {
|
||||
DANGER: 'bg-red-50 border-red-300 text-red-700',
|
||||
HIGH: 'bg-orange-50 border-orange-300 text-orange-700',
|
||||
MEDIUM: 'bg-amber-50 border-amber-300 text-amber-700',
|
||||
}
|
||||
const riskIcon: Record<string, string> = {
|
||||
DANGER: '🔴',
|
||||
HIGH: '🟠',
|
||||
MEDIUM: '🟡',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
@@ -2831,14 +2851,41 @@ function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
<div className="text-gray-500">未签字</div>
|
||||
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
|
||||
</div>
|
||||
{data.summary.riskCount > 0 && (
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">风险项</div>
|
||||
<div className="text-xl font-bold text-danger">{data.summary.riskCount}</div>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleExport}>导出证据链</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{data.risks && data.risks.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
风险提醒({data.risks.length}项)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.risks.map((r: any, i: number) => (
|
||||
<div key={i} className={`border rounded-lg p-3 ${riskStyle[r.level] || 'bg-gray-50 border-gray-200 text-gray-600'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{riskIcon[r.level] || '⚠'}</span>
|
||||
<span className="font-medium text-xs">{r.title}</span>
|
||||
<span className="text-xs opacity-70">{r.category}</span>
|
||||
</div>
|
||||
<div className="text-xs mt-1 opacity-90">{r.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.evidence.map((e: any, i: number) => (
|
||||
<Card key={i}>
|
||||
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-2 py-0.5 rounded border text-xs shrink-0 ${categoryColor[e.category] || 'bg-gray-50 text-gray-600 border-gray-200'}`}>
|
||||
{e.category}
|
||||
@@ -2849,6 +2896,7 @@ function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
<span className="text-xs text-gray-400">{e.date}</span>
|
||||
{e.acknowledged === true && <span className="text-xs text-safe">✓ 已签字</span>}
|
||||
{e.acknowledged === false && <span className="text-xs text-warning">⚠ 未签字</span>}
|
||||
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger">⚠ 高风险</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{e.description}</div>
|
||||
</div>
|
||||
|
||||
@@ -97,12 +97,12 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (orgData?.data) {
|
||||
if (orgData) {
|
||||
setForm({
|
||||
name: orgData.data.name || '',
|
||||
contactName: orgData.data.contactName || '',
|
||||
contactPhone: orgData.data.contactPhone || '',
|
||||
payrollFrequency: orgData.data.payrollFrequency || 1,
|
||||
name: orgData.name || '',
|
||||
contactName: orgData.contactName || '',
|
||||
contactPhone: orgData.contactPhone || '',
|
||||
payrollFrequency: orgData.payrollFrequency || 1,
|
||||
})
|
||||
}
|
||||
}, [orgData])
|
||||
@@ -146,7 +146,7 @@ function UserSettings({ usersData }: { usersData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<any>(null)
|
||||
const users = usersData?.data || []
|
||||
const users = usersData || []
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/settings/users/${id}`, data),
|
||||
@@ -411,7 +411,7 @@ function ExportSettings() {
|
||||
|
||||
function PlanSettings({ orgData }: { orgData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const plan = orgData?.data?.plan || 'FREE'
|
||||
const plan = orgData?.plan || 'FREE'
|
||||
|
||||
const { data: usageData } = useQuery<any>({
|
||||
queryKey: ['usage'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban } from 'lucide-react'
|
||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
|
||||
import jsPDF from 'jspdf'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -229,6 +229,9 @@ export default function Termination() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['evidence-chain'] })
|
||||
setStep(5)
|
||||
},
|
||||
})
|
||||
@@ -308,7 +311,9 @@ export default function Termination() {
|
||||
toast.success('解聘已执行')
|
||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['evidence-chain'] })
|
||||
setView('list')
|
||||
resetWizard()
|
||||
},
|
||||
@@ -321,6 +326,8 @@ export default function Termination() {
|
||||
onSuccess: () => {
|
||||
toast.success('已撤销')
|
||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setView('list')
|
||||
},
|
||||
onError: () => toast.error('撤销失败'),
|
||||
@@ -410,7 +417,7 @@ export default function Termination() {
|
||||
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
|
||||
|
||||
const canProceed = () => {
|
||||
if (step === 0) return !!employeeId && !selectedEmployee?.hasTermination
|
||||
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination)
|
||||
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
||||
if (step === 2) return true
|
||||
if (step === 3) return true
|
||||
@@ -673,6 +680,21 @@ export default function Termination() {
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'DRAFT' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。`)) {
|
||||
setDraftId(item.id)
|
||||
executeMutation.mutate()
|
||||
}
|
||||
}}
|
||||
className="p-1 text-safe hover:opacity-70"
|
||||
aria-label="确定"
|
||||
title="确定执行"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user