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

- 结构化判赔预测表单改为卡片式布局(员工信息/争议事实/制度合规)
- 左右分栏:左栏表单(2/5),右栏AI结果(3/5),结果区固定高度可滚动
- 系统带出字段用Database图标替代文字标注
- 移除违纪记录摘要重复内容
- 三期信息整合到员工信息卡片内
- 新增AI分析结果导出Word文档功能(docx库)
- 后端roster API返回三期字段,修复特殊状态拼接逻辑
- 修复markdown渲染样式冲突(移除index.css中h1/h2/h3覆盖)
This commit is contained in:
selfrelease
2026-07-26 23:03:25 +08:00
parent d79e3baa34
commit 71e2bb2412
10 changed files with 1771 additions and 121 deletions
+6 -4
View File
@@ -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; }
+636 -110
View File
@@ -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<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 [disciplinarySummary, setDisciplinarySummary] = useState('')
const { data: employees } = useQuery<any[]>({
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<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' }
const actionMap: Record<string, string> = { 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 (
<Card>
<div className="flex items-center gap-2 mb-4">
@@ -566,77 +877,292 @@ function PredictTab() {
</div>
)}
{/* 筛选条件 */}
<div className="flex items-center gap-2 mb-4 flex-wrap mt-3">
<div className="min-w-[120px]">
<Button size="sm" onClick={fetchPrediction} 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 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>
{loading && !result && (
{/* === 通用模式筛选条件 === */}
{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>
)}
{result && (
{mode === 'general' && result && (
<div className="prose prose-sm max-w-none mt-4 overflow-x-auto
[&_table]:border-collapse [&_table]:w-full [&_table]:text-xs [&_table]:min-w-[600px]
[&_th]:border [&_th]:border-gray-300 [&_th]:px-2 [&_th]:py-1 [&_th]:bg-gray-50 [&_th]:font-medium [&_th]:whitespace-nowrap
[&_td]:border [&_td]:border-gray-300 [&_td]:px-2 [&_td]:py-1 [&_td]:align-top
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2
[&_h3]:text-xs [&_h3]:font-medium [&_h3]:mt-3 [&_h3]:mb-1
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:text-xs
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:text-xs
[&_strong]:font-semibold
[&_p]:text-xs [&_p]:leading-relaxed
[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-3 [&_blockquote]:text-gray-600 [&_blockquote]:text-xs [&_blockquote]:my-2">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{result}</ReactMarkdown>
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 && (
{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>
)
}