优化: 大文件拆分+代码分割+按需加载+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
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Scale, Loader2, Plus, Trash2, Save, History } from 'lucide-react'
|
||||
import { aiApi, employeeApi } from '../../lib/api-services'
|
||||
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'
|
||||
|
||||
|
||||
|
||||
/** 解析 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 CaseTab() {
|
||||
const [scenario, setScenario] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showTodoModal, setShowTodoModal] = useState(false)
|
||||
const [todoEmployeeId, setTodoEmployeeId] = useState('')
|
||||
const [todoTitle, setTodoTitle] = useState('')
|
||||
const [todoLevel, setTodoLevel] = useState('MEDIUM')
|
||||
const [todoType, setTodoType] = useState('TERMINATION')
|
||||
const [creatingTodo, setCreatingTodo] = useState(false)
|
||||
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('case')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['employee-list'],
|
||||
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
||||
})
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!scenario.trim()) return
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
try {
|
||||
const res = await aiApi.matchCase(scenario) as any
|
||||
setResult(res.result)
|
||||
// 自动保存到历史
|
||||
if (res?.result && !res.result.startsWith('出错了')) {
|
||||
const title = scenario.slice(0, 30).replace(/\n/g, ' ')
|
||||
saveMutation.mutate({ title, input: scenario, result: res.data.result })
|
||||
}
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadHistory = async (id: string) => {
|
||||
const data = await loadHistory(id)
|
||||
if (data?.messages) {
|
||||
const userMsg = data.messages.find((m: any) => m.role === 'user')
|
||||
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
|
||||
if (userMsg) setScenario(userMsg.content)
|
||||
if (assistantMsg) setResult(assistantMsg.content)
|
||||
setShowHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await aiApi.reviewSave({ employeeId: saveEmployeeId, type: 'CASE', input: scenario, result })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
toast.success('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTodo = async () => {
|
||||
if (!todoEmployeeId || !todoTitle) return
|
||||
setCreatingTodo(true)
|
||||
try {
|
||||
await aiApi.caseToTodo({
|
||||
employeeId: todoEmployeeId,
|
||||
title: todoTitle,
|
||||
description: result.slice(0, 500),
|
||||
level: todoLevel,
|
||||
type: todoType,
|
||||
})
|
||||
setShowTodoModal(false)
|
||||
setTodoEmployeeId('')
|
||||
setTodoTitle('')
|
||||
toast.success('已创建待办风险项')
|
||||
} catch (err: any) {
|
||||
toast.error('创建失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
} finally {
|
||||
setCreatingTodo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Scale className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-sm font-medium">案例匹配</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 mb-3">
|
||||
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Label>描述你的争议情形</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-[150px] resize-y"
|
||||
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
|
||||
value={scenario}
|
||||
onChange={(e) => setScenario(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Button onClick={handleMatch} disabled={loading || !scenario.trim()}>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />分析中...</> : '分析'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">分析结果</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowTodoModal(true)}><Plus className="w-4 h-4 mr-1" />转待办</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showSaveModal && (
|
||||
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">保存到员工档案</h3>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showTodoModal && (
|
||||
<Modal open onClose={() => setShowTodoModal(false)}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">转为待办风险项</h3>
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={todoEmployeeId} onChange={(e) => setTodoEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>待办标题</Label>
|
||||
<Input value={todoTitle} onChange={(e) => setTodoTitle(e.target.value)} placeholder="如:未签合同风险处理" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>风险等级</Label>
|
||||
<Select value={todoLevel} onChange={(e) => setTodoLevel(e.target.value)}>
|
||||
<option value="HIGH">高</option>
|
||||
<option value="MEDIUM">中</option>
|
||||
<option value="LOW">低</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>风险类型</Label>
|
||||
<Select value={todoType} onChange={(e) => setTodoType(e.target.value)}>
|
||||
<option value="CONTRACT">合同</option>
|
||||
<option value="SALARY">薪酬</option>
|
||||
<option value="TERMINATION">解聘</option>
|
||||
<option value="MONTHLY">月度</option>
|
||||
<option value="ONBOARDING">入职</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">分析结果将作为待办描述自动填入</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowTodoModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleCreateTodo} disabled={!todoEmployeeId || !todoTitle || creatingTodo}>
|
||||
{creatingTodo ? '创建中...' : '创建待办'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Send, Loader2, Mic, Plus, MessageSquare, Trash2, UserCheck } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { aiApi } from '../../lib/api-services'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label, Select } from '../../components/ui/Input'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
|
||||
|
||||
|
||||
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||
|
||||
|
||||
// 通用 AI 历史记录 hook
|
||||
|
||||
// 通用历史记录栏组件
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
const QUICK_QUESTIONS = [
|
||||
'员工入职没签合同怎么办?',
|
||||
'加班费怎么算?',
|
||||
'辞退员工需要赔多少?',
|
||||
'试用期最长可以约定几个月?',
|
||||
]
|
||||
|
||||
export function ChatTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ 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<string | null>(null)
|
||||
const [showConsultModal, setShowConsultModal] = useState(false)
|
||||
const [consultForm, setConsultForm] = useState({ type: 'LEGAL' as string, title: '', description: '', contactName: '', contactPhone: '', remark: '' })
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const recognitionRef = useRef<any>(null)
|
||||
const saveTimerRef = useRef<any>(null)
|
||||
|
||||
const { data: conversations } = useQuery<any[]>({
|
||||
queryKey: ['ai-conversations'],
|
||||
queryFn: async () => {
|
||||
return await aiApi.conversations('chat')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteConvMutation = useMutation({
|
||||
mutationFn: (id: string) => aiApi.removeConversation(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
|
||||
})
|
||||
|
||||
const consultMutation = useMutation({
|
||||
mutationFn: async (data: typeof consultForm) => {
|
||||
return await aiApi.consult(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('已提交咨询请求,专业律师将尽快与您联系')
|
||||
setShowConsultModal(false)
|
||||
setConsultForm({ type: 'LEGAL', title: '', description: '', contactName: '', contactPhone: '', remark: '' })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message || '提交失败,请稍后重试')
|
||||
},
|
||||
})
|
||||
|
||||
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 aiApi.updateConversation(currentConvId, { messages }).catch(() => {})
|
||||
} else {
|
||||
const res = await aiApi.createConversation({ title, messages }) as any
|
||||
if (res?.id) {
|
||||
setCurrentConvId(res.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['ai-conversations'] })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [messages])
|
||||
|
||||
const loadConversation = async (id: string) => {
|
||||
try {
|
||||
const res = await aiApi.conversation(id) as any
|
||||
if (res?.messages) {
|
||||
setMessages(res.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 (
|
||||
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" />新对话</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowHistory(!showHistory)}><MessageSquare className="w-4 h-4 mr-1" />历史会话</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowConsultModal(true)}><UserCheck className="w-4 h-4 mr-1" />联系专业律师</Button>
|
||||
{conversations && conversations.length > 0 && (
|
||||
<span className="text-xs text-gray-400">{conversations.length} 条历史</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 历史会话列表 */}
|
||||
{showHistory && (
|
||||
<div className="border-b pb-2 max-h-40 overflow-y-auto">
|
||||
{conversations && conversations.length > 0 ? conversations.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={() => loadConversation(c.id)}>{c.title.replace(/^chat:/, '')}</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(); deleteConvMutation.mutate(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>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] px-4 py-3 rounded-lg text-sm leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-primary text-white whitespace-pre-wrap' : 'bg-white border border-gray-200 text-gray-800 shadow-sm'
|
||||
}`}>
|
||||
{msg.role === 'assistant' ? (
|
||||
msg.content ? (
|
||||
<div className="prose prose-sm max-w-none
|
||||
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-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
|
||||
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
|
||||
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]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : loading && i === messages.length - 1 ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-500">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
思考中...
|
||||
</span>
|
||||
) : null
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 快捷问题 */}
|
||||
{messages.length <= 1 && (
|
||||
<div className="flex flex-wrap gap-2 pb-3">
|
||||
{QUICK_QUESTIONS.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => send(q)}
|
||||
className="px-3 py-1.5 text-xs rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
placeholder="输入问题..."
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button variant="secondary" onClick={toggleVoice} disabled={loading} className={recording ? 'text-danger' : ''}>
|
||||
<Mic className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={() => send()} disabled={loading || !input.trim()}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 转人工咨询 Modal */}
|
||||
{showConsultModal && (
|
||||
<Modal open={true} title="联系专业律师" onClose={() => setShowConsultModal(false)}>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md bg-blue-50 border border-blue-200 p-3 text-xs text-blue-700">
|
||||
<p className="font-medium mb-1">服务说明</p>
|
||||
<p>· <strong>法律咨询</strong>:专业律师在线解答劳动法问题</p>
|
||||
<p>· <strong>仲裁代理</strong>:律师代理劳动仲裁案件(付费服务)</p>
|
||||
<p>· <strong>出庭服务</strong>:律师代理法院诉讼(付费服务)</p>
|
||||
<p className="mt-1">提交后律师将在 24 小时内与您联系。</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>服务类型</Label>
|
||||
<Select value={consultForm.type} onChange={(e) => setConsultForm({ ...consultForm, type: e.target.value })}>
|
||||
<option value="LEGAL">法律咨询</option>
|
||||
<option value="ARBITRATION">仲裁代理(付费)</option>
|
||||
<option value="COURT">出庭服务(付费)</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>问题标题</Label>
|
||||
<Input value={consultForm.title} onChange={(e) => setConsultForm({ ...consultForm, title: e.target.value })} placeholder="简要描述您的问题" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>详细描述</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-sm min-h-[80px] resize-y"
|
||||
value={consultForm.description}
|
||||
onChange={(e) => setConsultForm({ ...consultForm, description: e.target.value })}
|
||||
placeholder="请详细描述您遇到的法律问题、涉及的员工情况等"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>联系人姓名</Label>
|
||||
<Input value={consultForm.contactName} onChange={(e) => setConsultForm({ ...consultForm, contactName: e.target.value })} placeholder="您的姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系电话</Label>
|
||||
<Input value={consultForm.contactPhone} onChange={(e) => setConsultForm({ ...consultForm, contactPhone: e.target.value })} placeholder="手机号码" maxLength={11} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注(可选)</Label>
|
||||
<Input value={consultForm.remark} onChange={(e) => setConsultForm({ ...consultForm, remark: e.target.value })} placeholder="其他需要说明的信息" />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowConsultModal(false)}>取消</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => consultMutation.mutate(consultForm)}
|
||||
disabled={consultMutation.isPending || !consultForm.title || !consultForm.description || !consultForm.contactName || !consultForm.contactPhone}
|
||||
>
|
||||
{consultMutation.isPending ? '提交中...' : '提交咨询'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Sparkles, Loader2, Download, TrendingUp } 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 { useAuthStore } from '../../store/authStore'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
|
||||
|
||||
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||
function 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 })]
|
||||
}
|
||||
|
||||
/** 导出 Markdown 文本为 Word 文档 */
|
||||
async function exportMarkdownToWord(markdown: string, fileName: string) {
|
||||
const lines = markdown.split('\n')
|
||||
const children: (Paragraph | Table)[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (!line.trim()) { i++; continue }
|
||||
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 {
|
||||
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)
|
||||
saveAs(blob, fileName)
|
||||
}
|
||||
|
||||
// 通用 AI 历史记录 hook
|
||||
|
||||
// 通用历史记录栏组件
|
||||
|
||||
export function HRReportTab() {
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (loading) return
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const url = import.meta.env.DEV
|
||||
? `http://localhost:3000/api/v1/ai/hr-report-stream`
|
||||
: `/api/v1/ai/hr-report-stream`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
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 = ''
|
||||
|
||||
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
|
||||
setResult(accumulated)
|
||||
}
|
||||
if (parsed.error) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
} catch (parseErr: any) {
|
||||
if (parseErr instanceof SyntaxError) continue
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setResult(accumulated)
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
toast.error(err.message || '生成报告失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!result) return
|
||||
try {
|
||||
await exportMarkdownToWord(result, `人力分析报告_${new Date().toISOString().slice(0, 10)}.docx`)
|
||||
toast.success('Word 文档已导出')
|
||||
} catch {
|
||||
toast.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5">
|
||||
<TrendingUp className="w-4 h-4 text-primary" />
|
||||
AI 人力分析报告
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">基于企业实际数据自动生成:人力概况、风险提示、成本分析、合规建议、改进方向</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{result && !loading && (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
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>
|
||||
)}
|
||||
<Button size="sm" onClick={handleGenerate} disabled={loading}>
|
||||
{loading ? (
|
||||
<><Loader2 className="w-4 h-4 mr-1 animate-spin" />生成中...</>
|
||||
) : (
|
||||
<><Sparkles className="w-4 h-4 mr-1" />生成报告</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!result && !loading && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<TrendingUp className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-sm">点击"生成报告",AI 将基于企业当前数据自动生成结构化人力分析报告</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !result && (
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="w-8 h-8 mx-auto mb-3 text-primary animate-spin" />
|
||||
<p className="text-sm text-gray-500">AI 正在分析企业数据并生成报告...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="prose prose-sm max-w-none
|
||||
prose-headings:text-gray-800 prose-headings:font-semibold
|
||||
prose-h1:text-lg prose-h1:border-b prose-h1:pb-2 prose-h1:border-gray-200
|
||||
prose-h2:text-base prose-h2:mt-4
|
||||
prose-h3:text-sm prose-h3:mt-3
|
||||
prose-p:text-gray-600 prose-p:leading-relaxed
|
||||
prose-li:text-gray-600 prose-li:leading-relaxed
|
||||
prose-strong:text-gray-800
|
||||
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-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
|
||||
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
|
||||
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||
">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{result}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { aiApi } from '../../lib/api-services'
|
||||
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'
|
||||
|
||||
|
||||
|
||||
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
|
||||
|
||||
|
||||
// 通用 AI 历史记录 hook
|
||||
|
||||
// 通用历史记录栏组件
|
||||
|
||||
export function KnowledgeTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newItem, setNewItem] = useState({ title: '', content: '', source: '自定义', category: '其他' })
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const { data: knowledgeList, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['rag-knowledge'],
|
||||
queryFn: async () => {
|
||||
return await aiApi.ragList()
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: typeof newItem) => {
|
||||
return await aiApi.ragAdd(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] })
|
||||
setShowAdd(false)
|
||||
setNewItem({ title: '', content: '', source: '自定义', category: '其他' })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => aiApi.ragRemove(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||
})
|
||||
|
||||
const seedMutation = useMutation({
|
||||
mutationFn: () => aiApi.ragSeed(),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||
})
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newItem.title || !newItem.content) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await addMutation.mutateAsync(newItem)
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-xs text-blue-700 space-y-1">
|
||||
<div className="font-medium">📖 知识库说明</div>
|
||||
<div>· 法律法规知识库由研发方定期更新维护,确保政策时效性</div>
|
||||
<div>· 您可点击「添加知识」上传企业内部制度、操作规范等,AI 问答将同时检索法律法规和企业制度</div>
|
||||
<div>· 如发现法律内容过时,请联系研发方更新</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">共 {knowledgeList?.length || 0} 条知识</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
|
||||
{seedMutation.isPending ? '初始化中...' : '初始化知识库'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加知识
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !knowledgeList || knowledgeList.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">知识库为空,请点击「初始化知识库」</div></Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{knowledgeList.map((item: any) => (
|
||||
<Card key={item.id}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{item.category}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">{item.content}</p>
|
||||
<div className="text-xs text-gray-400 mt-1">来源:{item.source}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
className="text-gray-400 hover:text-danger flex-shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<Modal open onClose={() => setShowAdd(false)}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">添加知识条目</h3>
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Input value={newItem.title} onChange={(e) => setNewItem({ ...newItem, title: e.target.value })} placeholder="如:劳动合同法第十条" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>内容</Label>
|
||||
<textarea
|
||||
value={newItem.content}
|
||||
onChange={(e) => setNewItem({ ...newItem, content: e.target.value })}
|
||||
placeholder="法律条文或知识内容"
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>来源</Label>
|
||||
<Input value={newItem.source} onChange={(e) => setNewItem({ ...newItem, source: e.target.value })} placeholder="如:劳动合同法" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>分类</Label>
|
||||
<Select value={newItem.category} onChange={(e) => setNewItem({ ...newItem, category: e.target.value })}>
|
||||
<option value="其他">其他</option>
|
||||
<option value="法律法规">法律法规</option>
|
||||
<option value="司法解释">司法解释</option>
|
||||
<option value="地方性法规">地方性法规</option>
|
||||
<option value="案例分析">案例分析</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAdd(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleAdd} disabled={!newItem.title || !newItem.content || adding}>
|
||||
{adding ? '添加中...' : '添加'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { REVIEW_DOC_TYPES } from './shared'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { FileSearch, Loader2, Trash2, Save, History, FileText } from 'lucide-react'
|
||||
import { aiApi, employeeApi } from '../../lib/api-services'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Label, Select } from '../../components/ui/Input'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
|
||||
|
||||
|
||||
/** 解析 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 ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [docType, setDocType] = useState('labor_contract')
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review')
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const ext = file.name.toLowerCase().split('.').pop()
|
||||
if (ext !== 'docx' && ext !== 'doc') {
|
||||
toast.error('仅支持 .docx 格式文件')
|
||||
return
|
||||
}
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error('文件大小不能超过 100MB')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await aiApi.reviewUpload(formData) as any
|
||||
if (res?.text) {
|
||||
setContractText(res.data.text)
|
||||
setFileName(file.name)
|
||||
toast.success(`已提取文件内容(${res.data.text.length} 字)`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error?.message || '文件上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['employee-list'],
|
||||
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
||||
})
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!contractText.trim()) return
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await aiApi.review(contractText) as any
|
||||
setResult(res)
|
||||
// 自动保存到历史
|
||||
if (res && !res.error) {
|
||||
const title = contractText.slice(0, 30).replace(/\n/g, ' ')
|
||||
saveMutation.mutate({ title, input: contractText, result: res.data.text || JSON.stringify(res.data) })
|
||||
}
|
||||
} catch (err: any) {
|
||||
setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadHistory = async (id: string) => {
|
||||
const data = await loadHistory(id)
|
||||
if (data?.messages) {
|
||||
const userMsg = data.messages.find((m: any) => m.role === 'user')
|
||||
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
|
||||
if (userMsg) setContractText(userMsg.content)
|
||||
if (assistantMsg) {
|
||||
try { setResult(JSON.parse(assistantMsg.content)) } catch { setResult({ text: assistantMsg.content }) }
|
||||
}
|
||||
setShowHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await aiApi.reviewSave({ employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result: result.text || JSON.stringify(result) })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
toast.success('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
const levelConfig: Record<string, { color: string; bg: string; label: string }> = {
|
||||
RED: { color: 'text-red-600', bg: 'bg-red-50', label: '高风险' },
|
||||
YELLOW: { color: 'text-yellow-600', bg: 'bg-yellow-50', label: '中风险' },
|
||||
GREEN: { color: 'text-green-600', bg: 'bg-green-50', label: '低风险' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileSearch className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-sm font-medium">合同审查</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 mb-3">
|
||||
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* 文件上传区 */}
|
||||
<div>
|
||||
<Label>上传文件审查(可选)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={docType} onChange={(e) => setDocType(e.target.value)} className="w-40">
|
||||
{REVIEW_DOC_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</Select>
|
||||
<input ref={fileInputRef} type="file" accept=".docx,.txt,.pdf" onChange={handleFileUpload} className="hidden" />
|
||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? (<><Loader2 className="w-4 h-4 animate-spin mr-1" />提取中...</>) : (<><FileText className="w-4 h-4 mr-1" />上传文件</>)}
|
||||
</Button>
|
||||
{fileName && <span className="text-xs text-gray-500 truncate max-w-[200px]">{fileName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label>粘贴或上传合同条款文本</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-[200px] resize-y"
|
||||
placeholder="粘贴劳动合同文本..."
|
||||
value={contractText}
|
||||
onChange={(e) => setContractText(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Button onClick={handleReview} disabled={loading || !contractText.trim()}>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />审查中...</> : '开始审查'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">审查结果</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
{result.error ? (
|
||||
<div className="text-xs text-danger">{result.error}</div>
|
||||
) : result.structured ? (
|
||||
<div className="space-y-3">
|
||||
{/* 合规评分 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">合规评分</span>
|
||||
<span className={`text-lg font-bold ${result.structured.score >= 80 ? 'text-safe' : result.structured.score >= 60 ? 'text-warning' : 'text-danger'}`}>
|
||||
{result.structured.score}/100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 风险项列表 */}
|
||||
{result.structured.riskItems.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium">风险项({result.structured.riskItems.length})</h4>
|
||||
{result.structured.riskItems.map((item: any, i: number) => {
|
||||
const cfg = levelConfig[item.level] || levelConfig.YELLOW
|
||||
return (
|
||||
<div key={i} className={`rounded-md p-3 ${cfg.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mb-1">{item.description}</div>
|
||||
<div className="text-xs text-gray-500">建议:{item.suggestion}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 总体建议 */}
|
||||
{result.structured.summary && (
|
||||
<div className="border-t pt-2">
|
||||
<h4 className="text-xs font-medium mb-1">总体建议</h4>
|
||||
<p className="text-xs text-gray-600">{result.structured.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原始文本可展开 */}
|
||||
<details className="border-t pt-2">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">查看原始文本</summary>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap mt-2">{result.text}</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result.text || JSON.stringify(result)}</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showSaveModal && (
|
||||
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">保存到员工档案</h3>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 12 类争议场景 */
|
||||
export 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: '社保公积金争议' },
|
||||
]
|
||||
|
||||
export const REVIEW_DOC_TYPES = [
|
||||
{ value: 'labor_contract', label: '劳动合同' },
|
||||
{ value: 'rescission', label: '协商解除协议' },
|
||||
{ value: 'labor_service', label: '劳务协议' },
|
||||
{ value: 'internship', label: '实习协议' },
|
||||
{ value: 'nda', label: '保密协议' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
Reference in New Issue
Block a user