import { useState, useRef, useCallback } from 'react' import { SCENARIO_TYPES } from './shared' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Sparkles, Loader2, Trash2, History, Database, User, AlertTriangle, FileText, Shield, Download } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx' import { saveAs } from 'file-saver' import { aiApi, rosterApi, employeeApi } from '../../lib/api-services' 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' /** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */ // 通用 AI 历史记录 hook function useAIHistory(type: 'predict' | 'review' | 'case') { const queryClient = useQueryClient() const queryKey = [`ai-history-${type}`] const { data: history } = useQuery({ queryKey, queryFn: async () => { return await aiApi.conversations(type) }, }) const saveMutation = useMutation({ mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => { const res = await aiApi.createConversation({ title: `${type}:${title}`, messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }], }) as any return res }, onSuccess: () => queryClient.invalidateQueries({ queryKey }), }) const deleteMutation = useMutation({ mutationFn: (id: string) => aiApi.removeConversation(id), onSuccess: () => queryClient.invalidateQueries({ queryKey }), }) const loadHistory = useCallback(async (id: string) => { return await aiApi.conversation(id) }, []) 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' })}
)) :
暂无历史记录
}
) } export 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 { data: employees } = useQuery({ queryKey: ['employee-list'], queryFn: () => employeeApi.list({ status: 'ACTIVE' }), }) const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))] /** 选择员工后自动带出系统已有数据 */ const handleStructEmployeeChange = async (employeeName: string) => { setStructEmployeeName(employeeName) // 清空之前带出的数据 setAutoFilledFields({}) setEmployeeSpecialStatus('') 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 rosterApi.disciplinary(emp.id) as any const records = 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 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 { // 无违纪记录 } } catch (err) { toast.error('获取违纪记录失败') } } /** 通用 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) { 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:争议事实 */}
争议事实