feat: AI判赔预测表单UI优化 - 卡片布局/左右分栏/Word导出

- 结构化判赔预测表单改为卡片式布局(员工信息/争议事实/制度合规)
- 左右分栏:左栏表单(2/5),右栏AI结果(3/5),结果区固定高度可滚动
- 系统带出字段用Database图标替代文字标注
- 移除违纪记录摘要重复内容
- 三期信息整合到员工信息卡片内
- 新增AI分析结果导出Word文档功能(docx库)
- 后端roster API返回三期字段,修复特殊状态拼接逻辑
- 修复markdown渲染样式冲突(移除index.css中h1/h2/h3覆盖)
This commit is contained in:
selfrelease
2026-07-26 23:03:25 +08:00
parent d79e3baa34
commit 71e2bb2412
10 changed files with 1771 additions and 121 deletions
+104 -4
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream } from '../services/ai.service'
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
@@ -248,7 +248,7 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
- 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}
- 状态:${emp.status}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
- 合同:${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}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
}
@@ -306,7 +306,7 @@ router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next
- 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}
- 状态:${emp.status}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
- 合同:${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}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
}
@@ -356,6 +356,106 @@ router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next
}
})
// ========== 结构化判赔预测 ==========
router.post('/predict-structured', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { scenarioType, keyFacts } = req.body as {
scenarioType: string
keyFacts: {
employeeName?: string
violationFact?: string
region?: string
monthlySalary?: string
democracyStatus?: string
disciplinaryRecord?: string
extraInfo?: string
}
}
if (!scenarioType) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议场景类型' } })
}
// 构建企业上下文:如果指定了员工则获取员工详情,否则用全量上下文
let orgContext = await buildOrgContext(req.user!.orgId)
if (keyFacts.employeeName) {
const emp = await prisma.employee.findFirst({
where: { orgId: req.user!.orgId, name: keyFacts.employeeName, status: 'ACTIVE' },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
disciplinaryRecords: { orderBy: { violationDate: 'desc' }, take: 10 },
},
})
if (emp) {
const contract = emp.contracts[0]
const tenure = calcDuration(new Date(emp.hireDate), new Date())
const contractDaysLeft = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null
// 自动补全 keyFacts:系统已有数据优先,HR 手动填写覆盖
let salary = 0
try { salary = Number(decrypt(emp.monthlySalary)) || 0 } catch { salary = Number(emp.monthlySalary) || 0 }
if (!keyFacts.monthlySalary && salary > 0) {
keyFacts.monthlySalary = String(salary)
}
if (!keyFacts.region && emp.city) {
keyFacts.region = emp.city
}
// 特殊状态已在 orgContext 中展示,不再重复追加到 extraInfo
// 自动补充违纪记录
if (emp.disciplinaryRecords.length > 0) {
const recordText = emp.disciplinaryRecords.map((r: any) => {
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' }
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '辞退' }
return `${r.violationDate.toISOString().slice(0, 10)} ${typeMap[r.violationType] || r.violationType}${severityMap[r.severity] || r.severity}),处理:${actionMap[r.action] || r.action}${r.employeeAck ? ',员工已签字确认' : ',员工未签字'}`
}).join('')
const discText = `系统违纪记录(${emp.disciplinaryRecords.length}条):${recordText}`
keyFacts.extraInfo = keyFacts.extraInfo ? `${keyFacts.extraInfo}${discText}` : discText
}
orgContext = `员工详情:
- 姓名:${emp.name}
- 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}
- 状态:${emp.status}
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
- 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}
- 违纪记录:${emp.disciplinaryRecords.length}${emp.disciplinaryRecords.length > 0 ? `(最近:${emp.disciplinaryRecords[0].violationDate.toISOString().slice(0, 10)}` : ''}\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 predictStructuredStream(scenarioType, keyFacts, 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) => {
@@ -633,7 +733,7 @@ router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res,
- 合同到期日:${endDate.toISOString().slice(0, 10)}(距今${daysToExpiry}天)
- 月薪:¥${salary.toFixed(2)}
- 工龄:${years}${remainingMonths}月(经济补偿月数 N=${n}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
三选项成本对比:
1. 续签:成本 ¥0
+4 -1
View File
@@ -23,7 +23,7 @@ function safeDecrypt(encrypted: string): number {
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100)
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 999)
const search = req.query.search as string
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
@@ -124,6 +124,9 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
gender: e.gender,
phone: e.phone,
monthlySalary: safeDecrypt(e.monthlySalary),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
isWorkInjured: e.isWorkInjured,
latestContract,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
+114 -1
View File
@@ -224,7 +224,120 @@ ${orgContext}`
{ role: 'user', content: prompt },
],
temperature: 0.5,
max_tokens: 2000,
max_tokens: 8000,
stream: true,
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content
if (delta) yield delta
}
}
/** 12 类争议场景中文映射 */
const SCENARIO_LABELS: Record<string, string> = {
discipline: '违纪解除',
incompetence: '不胜任解除',
probation: '试用期解除',
layoff: '经济性裁员',
expiry: '合同到期不续签',
negotiated: '协商解除',
transfer: '调岗调薪争议',
overtime: '加班费争议',
injury: '工伤待遇争议',
noncompete: '竞业限制争议',
confidentiality: '保密协议争议',
social_insurance: '社保公积金争议',
}
/**
* 结构化判赔预测流式输出
* @param scenarioType 争议场景类型
* @param keyFacts HR 填写的关键事实
* @param orgContext 企业上下文(员工信息等)
*/
export async function* predictStructuredStream(
scenarioType: string,
keyFacts: {
employeeName?: string
violationFact?: string
region?: string
monthlySalary?: string
democracyStatus?: string
disciplinaryRecord?: string
extraInfo?: string
},
orgContext: string,
) {
const scenarioLabel = SCENARIO_LABELS[scenarioType] || scenarioType
const factsText = [
keyFacts.employeeName ? `- 涉及员工:${keyFacts.employeeName}` : '',
keyFacts.violationFact ? `- 违纪/争议事实:${keyFacts.violationFact}` : '',
keyFacts.region ? `- 所在地区:${keyFacts.region}` : '',
keyFacts.monthlySalary ? `- 员工月薪:${keyFacts.monthlySalary}` : '',
keyFacts.democracyStatus ? `- 制度民主公示状态:${keyFacts.democracyStatus}` : '',
keyFacts.disciplinaryRecord ? `- 违纪记录留痕情况:${keyFacts.disciplinaryRecord}` : '',
keyFacts.extraInfo ? `- 补充信息:${keyFacts.extraInfo}` : '',
].filter(Boolean).join('\n')
const prompt = `你是一位资深劳动法律师,请基于以下争议场景和企业数据,进行专业的判赔风险分析。
## 争议场景
${scenarioLabel}
## 关键事实(由 HR 提供)
${factsText}
## 企业数据
${orgContext}
请使用 Markdown 格式输出,包含以下部分:
## 风险分析
分析企业在当前场景下的主要法律风险点,引用具体法律条文。
## 预估赔偿范围
根据员工月薪、工龄、地区裁判口径,给出经济补偿/赔偿金的预估范围(注意:此为参考值,实际以仲裁裁决为准):
| 项目 | 金额范围(元) | 计算依据 |
|------|--------------|----------|
## 建议方案
给出 2-3 个可行方案,按推荐度排序:
### 方案一(推荐)
- **操作步骤**...
- **预估成本**...
- **风险等级**:低/中/高
- **法律依据**...
### 方案二
- **操作步骤**...
- **预估成本**...
- **风险等级**:低/中/高
- **法律依据**...
## 关键注意事项
列出操作中必须注意的法律细节和证据要求。
> ⚠️ 以上分析基于当前提供的信息,仅供参考。实际仲裁结果受具体事实、证据、地区裁判口径等因素影响,建议在操作前咨询专业律师。`
const stream = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{
role: 'system',
content: '你是劳动用工法律风险评估专家,精通中国劳动法、劳动合同法及相关司法解释。请基于企业提供的事实和数据,给出专业、客观的风险分析和方案建议。请使用 Markdown 格式输出,善用表格、加粗、列表等格式。所有金额预估必须注明"仅供参考"。',
},
{ role: 'user', content: prompt },
],
temperature: 0.5,
max_tokens: 8000,
stream: true,
})
+1
View File
@@ -141,6 +141,7 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
isPregnant: emp.isPregnant,
isInMedicalPeriod: emp.isInMedicalPeriod,
isWorkInjured: emp.isWorkInjured,
city: emp.city,
}
})