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, 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' import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import Modal from '../components/ui/Modal' type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' // 通用 AI 历史记录 hook function useAIHistory(type: 'predict' | 'review' | 'case') { const queryClient = useQueryClient() const queryKey = [`ai-history-${type}`] const { data: history } = useQuery({ queryKey, queryFn: async () => { const res = await api.get(`/ai/conversations?type=${type}`) as any return res.data }, }) const saveMutation = useMutation({ mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => { const res = await api.post('/ai/conversations', { title: `${type}:${title}`, messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }], }) as any return res.data }, onSuccess: () => queryClient.invalidateQueries({ queryKey }), }) const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey }), }) const loadHistory = useCallback(async (id: string) => { const res = await api.get(`/ai/conversations/${id}`) as any return res.data }, []) return { history, saveMutation, deleteMutation, loadHistory } } // 通用历史记录栏组件 function HistoryBar({ history, onLoad, onDelete }: { history: any[] onLoad: (id: string) => void onDelete: (id: string) => void }) { return (
{history.length > 0 ? history.map((c: any) => (
onLoad(c.id)}> {c.title.replace(/^(predict:|review:|case:)/, '')} {new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
)) :
暂无历史记录
}
) } interface Message { role: 'user' | 'assistant' content: string } const QUICK_QUESTIONS = [ '员工入职没签合同怎么办?', '加班费怎么算?', '辞退员工需要赔多少?', '试用期最长可以约定几个月?', ] export default function AIAssistant() { const [tab, setTab] = useState('chat') const tabs: { key: Tab; label: string; icon: typeof Bot }[] = [ { key: 'chat', label: '智能问答', icon: Bot }, { key: 'predict', label: '风险预测', icon: Sparkles }, { key: 'review', label: '合同审查', icon: FileSearch }, { key: 'case', label: '案例匹配', icon: Scale }, { key: 'knowledge', label: '知识库', icon: BookOpen }, ] return (

AI 合规顾问

智能分析用工风险,辅助合同审查与合规决策

{tabs.map((t) => { const Icon = t.icon return ( ) })}
{tab === 'chat' && } {tab === 'predict' && } {tab === 'review' && } {tab === 'case' && } {tab === 'knowledge' && }
) } function ChatTab() { const queryClient = useQueryClient() const [messages, setMessages] = useState([ { role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }, ]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [recording, setRecording] = useState(false) const [showHistory, setShowHistory] = useState(false) const [currentConvId, setCurrentConvId] = useState(null) const scrollRef = useRef(null) const recognitionRef = useRef(null) const saveTimerRef = useRef(null) const { data: conversations } = useQuery({ queryKey: ['ai-conversations'], queryFn: async () => { const res = await api.get('/ai/conversations?type=chat') as any return res.data }, }) const deleteConvMutation = useMutation({ mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }), }) useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight) }, [messages]) // 自动保存会话(debounce) useEffect(() => { if (messages.length <= 1) return if (saveTimerRef.current) clearTimeout(saveTimerRef.current) saveTimerRef.current = setTimeout(async () => { const title = `chat:${messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'}` if (currentConvId) { await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {}) } else { const res = await api.post('/ai/conversations', { title, messages }) as any if (res.data?.id) { setCurrentConvId(res.data.id) queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }) } } }, 2000) return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } }, [messages]) const loadConversation = async (id: string) => { try { const res = await api.get(`/ai/conversations/${id}`) as any if (res.data?.messages) { setMessages(res.data.messages) setCurrentConvId(id) setShowHistory(false) } } catch {} } const newConversation = () => { setMessages([{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }]) setCurrentConvId(null) setShowHistory(false) } const toggleVoice = () => { const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition if (!SpeechRecognition) { toast.error('当前浏览器不支持语音输入,请使用 Chrome 或 Edge') return } if (recording) { recognitionRef.current?.stop() setRecording(false) return } const recognition = new SpeechRecognition() recognition.lang = 'zh-CN' recognition.continuous = false recognition.interimResults = false recognition.onresult = (event: any) => { const transcript = event.results[0]?.[0]?.transcript || '' setInput((prev) => prev + transcript) } recognition.onerror = () => setRecording(false) recognition.onend = () => setRecording(false) recognition.start() recognitionRef.current = recognition setRecording(true) } const send = async (text?: string) => { const content = text || input.trim() if (!content || loading) return const newMessages = [...messages, { role: 'user' as const, content }] setMessages([...newMessages, { role: 'assistant', content: '' }]) setInput('') setLoading(true) try { const token = useAuthStore.getState().accessToken const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 60 * 1000) const chatUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1/ai/chat-stream' : '/api/v1/ai/chat-stream' const response = await fetch(chatUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ messages: newMessages }), signal: controller.signal, }) clearTimeout(timeoutId) 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 // 用 RAF 批量刷新,避免每个 token 触发一次 React 重渲染 const flush = () => { pendingFlush = false rafId = null setMessages([...newMessages, { role: 'assistant', content: 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) { // 只有业务错误(有 message 且不是 SyntaxError)才抛出 if (parseErr instanceof SyntaxError) { // JSON 解析失败,可能是 SSE 分块截断,跳过等下一块 continue } throw parseErr } } } } // 确保最后一批内容被刷新 if (rafId) cancelAnimationFrame(rafId) if (accumulated) { setMessages([...newMessages, { role: 'assistant', content: accumulated }]) } } if (!accumulated) { setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }]) } } catch (err: any) { const isTimeout = err.name === 'AbortError' setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }]) } finally { setLoading(false) } } return (
{/* 顶部操作栏 */}
{conversations && conversations.length > 0 && ( {conversations.length} 条历史 )}
{/* 历史会话列表 */} {showHistory && (
{conversations && conversations.length > 0 ? conversations.map((c: any) => (
loadConversation(c.id)}>{c.title.replace(/^chat:/, '')} {new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
)) :
暂无历史会话
}
)}
{messages.map((msg, i) => (
{msg.role === 'assistant' ? ( msg.content ? (
{msg.content}
) : loading && i === messages.length - 1 ? ( 思考中... ) : null ) : ( msg.content )}
))}
{/* 快捷问题 */} {messages.length <= 1 && (
{QUICK_QUESTIONS.map((q) => ( ))}
)} {/* 输入框 */}
setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="输入问题..." disabled={loading} />
) } /** 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('') const [employeeId, setEmployeeId] = useState('') const [showHistory, setShowHistory] = useState(false) 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?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() const controller = new AbortController() abortRef.current = controller setLoading(true) setResult('') try { 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 || '请求失败') } 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 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 || '请求失败') } 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 || '请稍后重试'}`) } finally { setLoading(false) } } 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) } } } 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 (

AI 风险预测

{showHistory && (
deleteMutation.mutate(id)} />
)} {/* 模式切换 */}
{/* === 通用模式筛选条件 === */} {mode === 'general' && (
{scope === 'department' && (
)} {scope === 'employee' && (
)}
)} {/* === 通用模式 AI 结果 === */} {mode === 'general' && loading && !result && (
正在分析企业用工风险...
)} {mode === 'general' && result && (
{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:争议事实 */}
争议事实