Files
TurboHR/frontend/src/pages/ai-assistant/PredictTab.tsx
T
freedakgmail 2968484d2d 优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割
- AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割
- xlsx改为动态导入, OvertimeTab从345KB降至12.7KB
- api-services.ts: 请求参数 any→Record<string,unknown>
- 移除前端3处console.log残留
- 后端console替换为pino logger
- 前后端未使用import/变量清理
- Zod schema验证: termination/platform/special-status/work-process
- 新增 leave.routes.ts, acceptance-test.routes.ts
- UI组件: PageGuide, QueryError, Stepper
2026-08-04 07:53:37 +08:00

790 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<any[]>({
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 (
<div className="border-b pb-2 max-h-40 overflow-y-auto">
{history.length > 0 ? history.map((c: any) => (
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
<span className="flex-1 truncate" onClick={() => onLoad(c.id)}>
{c.title.replace(/^(predict:|review:|case:)/, '')}
</span>
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
<button onClick={(e) => { e.stopPropagation(); onDelete(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
</div>
)) : <div className="text-xs text-gray-400 py-2 text-center"></div>}
</div>
)
}
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<AbortController | null>(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<any[]>({
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<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' }
const actionMap: Record<string, string> = { 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 (
<Card>
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5 text-primary" />
<h2 className="text-sm font-medium">AI </h2>
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" /></Button>
</div>
{showHistory && (
<div className="mt-2">
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
</div>
)}
{/* 模式切换 */}
<div className="flex gap-1 mb-4 mt-3 border-b pb-2">
<button
onClick={() => { setMode('general'); setResult('') }}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
mode === 'general' ? 'bg-primary text-white' : 'text-gray-500 hover:bg-gray-50'
}`}
>
</button>
<button
onClick={() => { setMode('structured'); setResult('') }}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
mode === 'structured' ? 'bg-primary text-white' : 'text-gray-500 hover:bg-gray-50'
}`}
>
</button>
</div>
{/* === 通用模式筛选条件 === */}
{mode === 'general' && (
<div className="flex items-center gap-2 mb-4 flex-wrap">
<div className="min-w-[120px]">
<Button size="sm" onClick={handlePredict} disabled={loading}>
{loading ? '分析中...' : result ? '重新预测' : '开始预测'}
</Button>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={scope} onChange={(e) => setScope(e.target.value)}>
<option value="all"></option>
<option value="department"></option>
<option value="employee"></option>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={riskType} onChange={(e) => setRiskType(e.target.value)}>
<option value="all"></option>
<option value="contract"></option>
<option value="salary"></option>
<option value="termination"></option>
</Select>
</div>
{scope === 'department' && (
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={department} onChange={(e) => setDepartment(e.target.value)}>
<option value=""></option>
{departments.map((d: string) => <option key={d} value={d}>{d}</option>)}
</Select>
</div>
)}
{scope === 'employee' && (
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}</option>)}
</Select>
</div>
)}
</div>
)}
{/* === 通用模式 AI 结果 === */}
{mode === 'general' && loading && !result && (
<div className="flex items-center gap-2 text-gray-400 py-8">
<Loader2 className="w-5 h-5 animate-spin" /> ...
</div>
)}
{mode === 'general' && result && (
<div className="prose prose-sm max-w-none mt-4 overflow-x-auto
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">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result}</ReactMarkdown>
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
</div>
)}
{mode === 'general' && !result && !loading && (
<div className="text-center py-8 text-gray-400">
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-xs">AI风险分析</p>
</div>
)}
{/* === 结构化判赔预测:左右两栏布局 === */}
{mode === 'structured' && (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4 mb-4">
{/* 左栏:表单(占 2/5 */}
<div className="space-y-3 lg:col-span-2">
{/* 卡片1:员工信息 */}
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
<User className="w-3.5 h-3.5 text-primary" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label> <span className="text-danger">*</span></Label>
<Select value={scenarioType} onChange={(e) => setScenarioType(e.target.value)}>
{SCENARIO_TYPES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</Select>
</div>
<div>
<Label></Label>
<Select value={structEmployeeName} onChange={(e) => handleStructEmployeeChange(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.name}>{e.name}{e.department}</option>)}
</Select>
{employeeSpecialStatus && (
<div className="mt-1 flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 px-2 py-1 rounded">
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
<strong>{employeeSpecialStatus}</strong>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div>
<Label className="flex items-center gap-1">
{autoFilledFields.region && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
</Label>
<Input
placeholder="如:北京"
value={region}
onChange={(e) => { setRegion(e.target.value); setAutoFilledFields((prev) => ({ ...prev, region: false })) }}
/>
</div>
<div>
<Label className="flex items-center gap-1">
{autoFilledFields.salary && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
</Label>
<Input
type="number"
placeholder="如:8000"
value={monthlySalary}
onChange={(e) => { setMonthlySalary(e.target.value); setAutoFilledFields((prev) => ({ ...prev, salary: false })) }}
/>
</div>
</div>
</div>
{/* 卡片2:争议事实 */}
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
<FileText className="w-3.5 h-3.5 text-primary" />
</div>
<Label className="flex items-center gap-1">
/
{autoFilledFields.violationFact && <span title="系统带出,请补充本次争议事实"><Database className="w-3 h-3 text-primary" /></span>}
</Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[80px] resize-y"
placeholder="描述具体的违纪事实或争议情况,例如:员工连续旷工3天,公司拟以严重违纪为由解除劳动合同..."
value={violationFact}
onChange={(e) => { setViolationFact(e.target.value); setAutoFilledFields((prev) => ({ ...prev, violationFact: false })) }}
/>
<div className="mt-2">
<Label>
{autoFilledFields.extraInfo && <span title="系统带出"><Database className="w-3 h-3 text-primary inline" /></span>}
</Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[50px] resize-y"
placeholder="其他需要说明的情况,如是否有工会等..."
value={extraInfo}
onChange={(e) => { setExtraInfo(e.target.value); setAutoFilledFields((prev) => ({ ...prev, extraInfo: false })) }}
/>
</div>
</div>
{/* 卡片3:制度合规 */}
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
<Shield className="w-3.5 h-3.5 text-primary" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={democracyStatus} onChange={(e) => setDemocracyStatus(e.target.value)}>
<option value=""></option>
<option value="已履行民主程序并公示"></option>
<option value="已公示但未履行民主程序"></option>
<option value="未公示未履行民主程序"></option>
<option value="不确定"></option>
</Select>
</div>
<div>
<Label className="flex items-center gap-1">
{autoFilledFields.disciplinary && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
</Label>
<Select value={disciplinaryRecord} onChange={(e) => { setDisciplinaryRecord(e.target.value); setAutoFilledFields((prev) => ({ ...prev, disciplinary: false })) }}>
<option value=""></option>
<option value="有书面警告信且员工签收"></option>
<option value="有书面记录但未签收"></option>
<option value="仅有口头警告"></option>
<option value="无任何记录"></option>
</Select>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button size="sm" onClick={handlePredict} disabled={loading}>
{loading ? '分析中...' : result ? '重新预测' : '开始判赔预测'}
</Button>
<span className="text-xs text-gray-400">AI </span>
</div>
</div>
{/* 右栏:AI 结果(占 3/5 */}
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50 flex flex-col lg:col-span-3" style={{ height: 'calc(100vh - 320px)', maxHeight: 'calc(100vh - 320px)' }}>
<div className="flex items-center justify-between mb-2.5 flex-shrink-0">
<div className="flex items-center gap-1.5 text-xs font-semibold text-gray-700">
<Sparkles className="w-3.5 h-3.5 text-primary" />
AI
</div>
{result && !loading && (
<button
onClick={handleExportWord}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Download className="w-3.5 h-3.5" />
Word
</button>
)}
</div>
<div className="flex-1 overflow-y-auto">
{loading && !result && (
<div className="flex items-center gap-2 text-gray-400 py-8">
<Loader2 className="w-5 h-5 animate-spin" /> ...
</div>
)}
{result && (
<div className="prose prose-sm max-w-none overflow-x-auto
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">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result}</ReactMarkdown>
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
</div>
)}
{!result && !loading && (
<div className="text-center py-12 text-gray-400">
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-xs"></p>
</div>
)}
</div>
</div>
</div>
)}
</Card>
)
}