diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index 0fa7acc..6c23e69 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, 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 = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' } + const severityMap: Record = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' } + const actionMap: Record = { 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 diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 7d3155f..0cd0119 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -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, diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index ba00d39..ec6570e 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -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 = { + 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, }) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index bb02df5..357ccca 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -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, } }) diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..5a586b3 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.lint.unknownAtRules": "ignore" +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8edf7bb..f019acf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,8 @@ "@tanstack/react-query": "^5.51.0", "axios": "^1.7.0", "clsx": "^2.1.0", + "docx": "^9.7.1", + "file-saver": "^2.0.5", "jspdf": "^4.2.1", "lucide-react": "^0.428.0", "qrcode.react": "^4.0.1", @@ -30,6 +32,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", + "@types/file-saver": "^2.0.7", "@types/node": "^26.1.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", @@ -1461,6 +1464,13 @@ "@types/estree": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.5.tgz", @@ -2011,6 +2021,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", @@ -2254,6 +2270,56 @@ "dev": true, "license": "MIT" }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmmirror.com/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.12", "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz", @@ -2492,6 +2558,12 @@ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", @@ -2687,6 +2759,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", @@ -2880,6 +2962,12 @@ "node": ">= 6" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "11.1.15", "resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.15.tgz", @@ -2890,6 +2978,12 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -3029,6 +3123,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", @@ -3088,6 +3188,33 @@ "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4046,6 +4173,12 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -4393,6 +4526,12 @@ "dev": true, "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", @@ -4602,6 +4741,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", @@ -4866,6 +5020,21 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", @@ -4885,6 +5054,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", @@ -4937,6 +5112,15 @@ "node": ">=0.1.14" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5332,7 +5516,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utrie": { @@ -5518,6 +5701,24 @@ "node": ">=0.8" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3783eb7..5fa0fe1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "@tanstack/react-query": "^5.51.0", "axios": "^1.7.0", "clsx": "^2.1.0", + "docx": "^9.7.1", + "file-saver": "^2.0.5", "jspdf": "^4.2.1", "lucide-react": "^0.428.0", "qrcode.react": "^4.0.1", @@ -31,6 +33,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", + "@types/file-saver": "^2.0.7", "@types/node": "^26.1.1", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/frontend/src/index.css b/frontend/src/index.css index 4f79392..2d6b30b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -13,10 +13,6 @@ * { @apply box-border; } - - h1 { @apply text-lg font-semibold; } - h2 { @apply text-base font-semibold; } - h3 { @apply text-sm font-medium; } } @layer components { @@ -50,3 +46,9 @@ body { background: white !important; } a { color: inherit !important; text-decoration: none !important; } } + +/* Markdown 渲染美化增强(在 prose 基础上补充表格斑马纹和代码高亮) */ +.prose tbody tr:nth-child(even) { background-color: rgb(249 250 251); } +.prose pre { @apply bg-gray-900 text-gray-100 rounded-md p-3 my-3 overflow-x-auto text-xs; } +.prose pre code { @apply bg-transparent text-gray-100 p-0; } +.prose code { @apply bg-gray-100 text-primary px-1 py-0.5 rounded text-xs font-mono; } diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx index 8106e1c..308aae7 100644 --- a/frontend/src/pages/AIAssistant.tsx +++ b/frontend/src/pages/AIAssistant.tsx @@ -1,10 +1,12 @@ 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, History } from 'lucide-react' +import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' +import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx' +import { saveAs } from 'file-saver' import api from '../lib/api' import { useAuthStore } from '../store/authStore' import Card from '../components/ui/Card' @@ -424,9 +426,26 @@ function ChatTab() { ) } +/** 12 类争议场景 */ +const SCENARIO_TYPES = [ + { value: 'discipline', label: '违纪解除' }, + { value: 'incompetence', label: '不胜任解除' }, + { value: 'probation', label: '试用期解除' }, + { value: 'layoff', label: '经济性裁员' }, + { value: 'expiry', label: '合同到期不续签' }, + { value: 'negotiated', label: '协商解除' }, + { value: 'transfer', label: '调岗调薪争议' }, + { value: 'overtime', label: '加班费争议' }, + { value: 'injury', label: '工伤待遇争议' }, + { value: 'noncompete', label: '竞业限制争议' }, + { value: 'confidentiality', label: '保密协议争议' }, + { value: 'social_insurance', label: '社保公积金争议' }, +] + function PredictTab() { const [result, setResult] = useState('') const [loading, setLoading] = useState(false) + const [mode, setMode] = useState<'general' | 'structured'>('general') const [scope, setScope] = useState('all') const [riskType, setRiskType] = useState('all') const [department, setDepartment] = useState('') @@ -435,16 +454,164 @@ function PredictTab() { const abortRef = useRef(null) const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('predict') + // 结构化表单状态 + const [scenarioType, setScenarioType] = useState('discipline') + const [structEmployeeName, setStructEmployeeName] = useState('') + const [violationFact, setViolationFact] = useState('') + const [region, setRegion] = useState('') + const [monthlySalary, setMonthlySalary] = useState('') + const [democracyStatus, setDemocracyStatus] = useState('') + const [disciplinaryRecord, setDisciplinaryRecord] = useState('') + const [extraInfo, setExtraInfo] = useState('') + // 系统带出字段标记(区分自动填充 vs HR 手动修改) + const [autoFilledFields, setAutoFilledFields] = useState<{ salary?: boolean; region?: boolean; disciplinary?: boolean; violationFact?: boolean; extraInfo?: boolean }>({}) + // 员工特殊状态提示 + const [employeeSpecialStatus, setEmployeeSpecialStatus] = useState('') + // 员工违纪记录摘要(系统带出) + const [disciplinarySummary, setDisciplinarySummary] = useState('') + const { data: employees } = useQuery({ queryKey: ['roster-list'], queryFn: async () => { - const res = await api.get('/roster') as any + const res = await api.get('/roster?pageSize=999') as any return res.data?.items || res.data || [] }, }) const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))] + /** 选择员工后自动带出系统已有数据 */ + const handleStructEmployeeChange = async (employeeName: string) => { + setStructEmployeeName(employeeName) + // 清空之前带出的数据 + setAutoFilledFields({}) + setEmployeeSpecialStatus('') + setDisciplinarySummary('') + setExtraInfo('') + if (!employeeName) return + + // 从已加载的员工列表中查找(列表数据已含 monthlySalary/isPregnant/city 等字段) + const emp = (employees || []).find((e: any) => e.name === employeeName) + if (!emp) return + + // 1. 直接从列表数据带出月薪(已解密) + if (emp.monthlySalary && Number(emp.monthlySalary) > 0) { + setMonthlySalary(String(emp.monthlySalary)) + setAutoFilledFields((prev) => ({ ...prev, salary: true })) + } + // 2. 直接从列表数据带出地区 + if (emp.city) { + setRegion(emp.city) + setAutoFilledFields((prev) => ({ ...prev, region: true })) + } + // 3. 直接从列表数据带出特殊状态 + const statusParts: string[] = [] + if (emp.isPregnant) statusParts.push('孕期/哺乳期') + if (emp.isInMedicalPeriod) statusParts.push('医疗期') + if (emp.isWorkInjured) statusParts.push('工伤') + const specialStatus = statusParts.join('、') + setEmployeeSpecialStatus(specialStatus) + // 自动将三期/特殊状态填入补充信息 + if (specialStatus) { + setExtraInfo(`员工特殊状态:${specialStatus}`) + setAutoFilledFields((prev) => ({ ...prev, extraInfo: true })) + } + + // 4. 获取违纪记录(列表接口未含明细,需调用专用接口) + try { + const res = await api.get(`/roster/${emp.id}/disciplinary`) as any + const records = res?.data || res || [] + if (Array.isArray(records) && records.length > 0) { + const typeMap: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' } + const actionMap: Record = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '辞退' } + const summary = records.map((r: any) => + `${r.violationDate?.slice(0, 10) || ''} ${typeMap[r.violationType] || r.violationType}:${r.description || ''}(处理:${actionMap[r.action] || r.action}${r.employeeAck ? ',已签字' : ',未签字'})` + ).join(';') + setDisciplinarySummary(`系统已有 ${records.length} 条违纪记录:${summary}`) + // 自动填充"违纪/争议事实"文本框 + const factText = `【系统已有违纪记录】\n${records.map((r: any) => + `- ${r.violationDate?.slice(0, 10) || ''} ${typeMap[r.violationType] || r.violationType}:${r.description || ''}(处理:${actionMap[r.action] || r.action}${r.employeeAck ? ',已签字' : ',未签字'})` + ).join('\n')}\n\n【本次争议事实】请在此描述当前拟处理的具体情况...` + setViolationFact(factText) + setAutoFilledFields((prev) => ({ ...prev, violationFact: true })) + // 自动填充"违纪记录留痕情况"下拉 + const hasWrittenAck = records.some((r: any) => r.action === 'WRITTEN_WARNING' && r.employeeAck) + const hasWrittenNoAck = records.some((r: any) => r.action === 'WRITTEN_WARNING' && !r.employeeAck) + const hasOralOnly = records.every((r: any) => r.action === 'ORAL_WARNING') + if (hasWrittenAck) { + setDisciplinaryRecord('有书面警告信且员工签收') + setAutoFilledFields((prev) => ({ ...prev, disciplinary: true })) + } else if (hasWrittenNoAck) { + setDisciplinaryRecord('有书面记录但未签收') + setAutoFilledFields((prev) => ({ ...prev, disciplinary: true })) + } else if (hasOralOnly) { + setDisciplinaryRecord('仅有口头警告') + setAutoFilledFields((prev) => ({ ...prev, disciplinary: true })) + } + } else { + setDisciplinarySummary('系统无违纪记录') + } + } catch (err) { + console.error('[PredictTab] 获取违纪记录失败:', err) + } + } + + /** 通用 SSE 流读取(复用于两种模式) */ + const streamSSE = async (response: Response, onDone: (accumulated: string) => void) => { + 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('**出错了**')) { + onDone(accumulated) + } + } + } + const fetchPrediction = async () => { if (loading) return abortRef.current?.abort() @@ -478,61 +645,69 @@ function PredictTab() { 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 + await streamSSE(response, (accumulated) => { + 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) { + if (err.name === 'AbortError') return + setResult(`**出错了**:${err.message || '请稍后重试'}`) + } finally { + setLoading(false) + } + } - const flush = () => { - pendingFlush = false - rafId = null - setResult(accumulated) - } - const scheduleFlush = () => { - if (!pendingFlush) { - pendingFlush = true - rafId = requestAnimationFrame(flush) - } + /** 结构化判赔预测 */ + const fetchStructuredPrediction = async () => { + if (loading) return + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + setLoading(true) + setResult('') + + try { + const token = useAuthStore.getState().accessToken + const predictUrl = import.meta.env.DEV + ? `http://localhost:3000/api/v1/ai/predict-structured` + : `/api/v1/ai/predict-structured` + + const response = await fetch(predictUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + scenarioType, + keyFacts: { + employeeName: structEmployeeName || undefined, + violationFact: violationFact || undefined, + region: region || undefined, + monthlySalary: monthlySalary || undefined, + democracyStatus: democracyStatus || undefined, + disciplinaryRecord: disciplinaryRecord || undefined, + extraInfo: extraInfo || undefined, + }, + }), + signal: controller.signal, + }) + + if (!response.ok) { + const errData = await response.json().catch(() => null) + throw new Error(errData?.error?.message || '请求失败') } - 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 }) - } - } + const scenarioLabel = SCENARIO_TYPES.find((s) => s.value === scenarioType)?.label || scenarioType + await streamSSE(response, (accumulated) => { + saveMutation.mutate({ + title: `判赔-${scenarioLabel}${structEmployeeName ? '-' + structEmployeeName : ''}`, + input: `场景:${scenarioLabel} 员工:${structEmployeeName || '未指定'}`, + result: accumulated, + }) + }) } catch (err: any) { if (err.name === 'AbortError') return setResult(`**出错了**:${err.message || '请稍后重试'}`) @@ -552,6 +727,142 @@ function PredictTab() { } } + const handlePredict = () => { + if (mode === 'structured') { + fetchStructuredPrediction() + } else { + fetchPrediction() + } + } + + /** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */ + const parseInlineBold = (text: string): TextRun[] => { + const runs: TextRun[] = [] + const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g + let lastIndex = 0 + let match + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + runs.push(new TextRun({ text: text.slice(lastIndex, match.index) })) + } + if (match[2]) { + runs.push(new TextRun({ text: match[2], bold: true })) + } else if (match[3]) { + runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 })) + } + lastIndex = regex.lastIndex + } + if (lastIndex < text.length) { + runs.push(new TextRun({ text: text.slice(lastIndex) })) + } + return runs.length ? runs : [new TextRun({ text })] + } + + /** 导出 AI 分析结果为 Word 文档 */ + const handleExportWord = async () => { + if (!result) return + try { + const lines = result.split('\n') + const children: (Paragraph | Table)[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i] + + // 跳过空行 + if (!line.trim()) { i++; continue } + + // 表格(markdown GFM 表格语法) + if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) { + const headerCells = line.split('|').map(c => c.trim()).filter(Boolean) + i += 2 // 跳过分隔行 + const rows: TableRow[] = [] + // 表头 + rows.push(new TableRow({ + children: headerCells.map(text => new TableCell({ + children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })], + shading: { fill: 'F3F4F6' }, + })), + })) + // 数据行 + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { + const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean) + rows.push(new TableRow({ + children: cells.map(text => new TableCell({ + children: [new Paragraph({ children: [new TextRun({ text })] })], + })), + })) + i++ + } + children.push(new Table({ + rows, + width: { size: 100, type: WidthType.PERCENTAGE }, + })) + continue + } + + // 标题 + if (line.startsWith('### ')) { + children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] })) + } else if (line.startsWith('## ')) { + children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] })) + } else if (line.startsWith('# ')) { + children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] })) + } else if (line.startsWith('> ')) { + // 引用块 + children.push(new Paragraph({ + children: [new TextRun({ text: line.slice(2), italics: true })], + indent: { left: 720 }, + })) + } else if (line.startsWith('- ') || line.startsWith('* ')) { + // 无序列表 + children.push(new Paragraph({ + children: parseInlineBold(line.slice(2)), + bullet: { level: 0 }, + })) + } else if (/^\d+\.\s/.test(line)) { + // 有序列表 + children.push(new Paragraph({ + children: parseInlineBold(line.replace(/^\d+\.\s/, '')), + numbering: { reference: 'default-numbering', level: 0 }, + })) + } else if (line === '---' || line === '***') { + // 分隔线 + children.push(new Paragraph({ + children: [], + border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } }, + })) + } else { + // 普通段落(支持 **bold** 和 `code`) + children.push(new Paragraph({ + children: parseInlineBold(line), + })) + } + i++ + } + + const doc = new Document({ + numbering: { + config: [{ + reference: 'default-numbering', + levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }], + }], + }, + sections: [{ children }], + }) + + const blob = await Packer.toBlob(doc) + const fileName = mode === 'structured' + ? `判赔预测报告_${structEmployeeName || '未指定员工'}_${new Date().toISOString().slice(0, 10)}.docx` + : `风险预测报告_${new Date().toISOString().slice(0, 10)}.docx` + saveAs(blob, fileName) + toast.success('Word 文档已导出') + } catch (err) { + console.error('导出 Word 失败:', err) + toast.error('导出失败,请重试') + } + } + return (
@@ -566,77 +877,292 @@ function PredictTab() {
)} - {/* 筛选条件 */} -
-
- -
-
- - -
-
- - -
- {scope === 'department' && ( -
- - -
- )} - {scope === 'employee' && ( -
- - -
- )} + {/* 模式切换 */} +
+ +
- {loading && !result && ( + {/* === 通用模式筛选条件 === */} + {mode === 'general' && ( +
+
+ +
+
+ + +
+
+ + +
+ {scope === 'department' && ( +
+ + +
+ )} + {scope === 'employee' && ( +
+ + +
+ )} +
+ )} + + {/* === 通用模式 AI 结果 === */} + {mode === 'general' && loading && !result && (
正在分析企业用工风险...
)} - {result && ( + {mode === 'general' && result && (
- {result} + prose-headings:text-gray-900 prose-headings:font-semibold + prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2 + prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2 + prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1 + prose-p:my-2 prose-p:leading-relaxed + prose-li:my-0.5 prose-li:leading-relaxed + prose-ul:my-2 prose-ol:my-2 + prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none + prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3 + prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic + prose-table:text-xs prose-table:border-collapse + prose-th:bg-gray-100 prose-th:px-2 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-300 prose-th:whitespace-nowrap + prose-td:px-2 prose-td:py-1.5 prose-td:border prose-td:border-gray-300 prose-td:align-top + prose-a:text-primary prose-a:no-underline hover:prose-a:underline + prose-strong:text-gray-900 + prose-hr:border-gray-200 prose-hr:my-4"> + {result} {loading && }
)} - {!result && !loading && ( + {mode === 'general' && !result && !loading && (

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

)} + + {/* === 结构化判赔预测:左右两栏布局 === */} + {mode === 'structured' && ( +
+ {/* 左栏:表单(占 2/5) */} +
+ {/* 卡片1:员工信息 */} +
+
+ + 员工信息 +
+
+
+ + +
+
+ + + {employeeSpecialStatus && ( +
+ + 该员工处于:{employeeSpecialStatus} +
+ )} +
+
+
+
+ + { setRegion(e.target.value); setAutoFilledFields((prev) => ({ ...prev, region: false })) }} + /> +
+
+ + { setMonthlySalary(e.target.value); setAutoFilledFields((prev) => ({ ...prev, salary: false })) }} + /> +
+
+
+ + {/* 卡片2:争议事实 */} +
+
+ + 争议事实 +
+ +