feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,847 @@
|
||||
import { useState, useRef, useEffect } 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 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
const QUICK_QUESTIONS = [
|
||||
'员工入职没签合同怎么办?',
|
||||
'加班费怎么算?',
|
||||
'辞退员工需要赔多少?',
|
||||
'试用期最长可以约定几个月?',
|
||||
]
|
||||
|
||||
export default function AIAssistant() {
|
||||
const [tab, setTab] = useState<Tab>('chat')
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: typeof Bot }[] = [
|
||||
{ key: 'chat', label: '智能问答', icon: Bot },
|
||||
{ key: 'predict', label: '风险预测', icon: Sparkles },
|
||||
{ key: 'review', label: '合同审查', icon: FileSearch },
|
||||
{ key: 'case', label: '案例匹配', icon: Scale },
|
||||
{ key: 'knowledge', label: '知识库', icon: BookOpen },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xs font-medium">AI 合规顾问</h1>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === 'chat' && <ChatTab />}
|
||||
{tab === 'predict' && <PredictTab />}
|
||||
{tab === 'review' && <ReviewTab />}
|
||||
{tab === 'case' && <CaseTab />}
|
||||
{tab === 'knowledge' && <KnowledgeTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 scrollRef = useRef<HTMLDivElement>(null)
|
||||
const recognitionRef = useRef<any>(null)
|
||||
const saveTimerRef = useRef<any>(null)
|
||||
|
||||
const { data: conversations } = useQuery<any[]>({
|
||||
queryKey: ['ai-conversations'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/ai/conversations') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const deleteConvMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}, [messages])
|
||||
|
||||
// 自动保存会话(debounce)
|
||||
useEffect(() => {
|
||||
if (messages.length <= 1) return
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
const title = messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'
|
||||
if (currentConvId) {
|
||||
await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {})
|
||||
} else {
|
||||
const res = await api.post('/ai/conversations', { title, messages }) as any
|
||||
if (res.data?.id) {
|
||||
setCurrentConvId(res.data.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['ai-conversations'] })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [messages])
|
||||
|
||||
const loadConversation = async (id: string) => {
|
||||
try {
|
||||
const res = await api.get(`/ai/conversations/${id}`) as any
|
||||
if (res.data?.messages) {
|
||||
setMessages(res.data.messages)
|
||||
setCurrentConvId(id)
|
||||
setShowHistory(false)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const newConversation = () => {
|
||||
setMessages([{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }])
|
||||
setCurrentConvId(null)
|
||||
setShowHistory(false)
|
||||
}
|
||||
|
||||
const toggleVoice = () => {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
if (!SpeechRecognition) {
|
||||
toast.error('当前浏览器不支持语音输入,请使用 Chrome 或 Edge')
|
||||
return
|
||||
}
|
||||
if (recording) {
|
||||
recognitionRef.current?.stop()
|
||||
setRecording(false)
|
||||
return
|
||||
}
|
||||
const recognition = new SpeechRecognition()
|
||||
recognition.lang = 'zh-CN'
|
||||
recognition.continuous = false
|
||||
recognition.interimResults = false
|
||||
recognition.onresult = (event: any) => {
|
||||
const transcript = event.results[0]?.[0]?.transcript || ''
|
||||
setInput((prev) => prev + transcript)
|
||||
}
|
||||
recognition.onerror = () => setRecording(false)
|
||||
recognition.onend = () => setRecording(false)
|
||||
recognition.start()
|
||||
recognitionRef.current = recognition
|
||||
setRecording(true)
|
||||
}
|
||||
|
||||
const send = async (text?: string) => {
|
||||
const content = text || input.trim()
|
||||
if (!content || loading) return
|
||||
|
||||
const newMessages = [...messages, { role: 'user' as const, content }]
|
||||
setMessages([...newMessages, { role: 'assistant', content: '' }])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 35 * 1000)
|
||||
const response = await fetch('/api/v1/ai/chat-stream', {
|
||||
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 = ''
|
||||
|
||||
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
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||
{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}</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-[80%] px-4 py-3 rounded-lg text-xs whitespace-pre-wrap ${
|
||||
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictTab() {
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [scope, setScope] = useState('all')
|
||||
const [riskType, setRiskType] = useState('all')
|
||||
const [department, setDepartment] = useState('')
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
|
||||
|
||||
const fetchPrediction = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (scope === 'department' && department) params.department = department
|
||||
if (scope === 'employee' && employeeId) params.employeeId = employeeId
|
||||
if (riskType !== 'all') params.riskType = riskType
|
||||
const res = await api.get('/ai/predict', { params }) as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrediction()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">AI 风险预测</h2>
|
||||
</div>
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
<div>
|
||||
<Label>预测范围</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>
|
||||
<Label>风险类型</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>
|
||||
<Label>部门</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>
|
||||
<Label>员工</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>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button variant="secondary" size="sm" onClick={fetchPrediction} disabled={loading}>刷新预测</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!contractText.trim()) return
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await api.post('/ai/review', { contractText }) as any
|
||||
setResult(res.data)
|
||||
} catch (err: any) {
|
||||
setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { 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="font-medium">合同审查</h2>
|
||||
</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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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 [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 { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!scenario.trim()) return
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
try {
|
||||
const res = await api.post('/ai/match-case', { scenario }) as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { 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 api.post('/ai/case-to-todo', {
|
||||
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="font-medium">案例匹配</h2>
|
||||
</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-[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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const res = await api.get('/ai/rag/list') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: typeof newItem) => {
|
||||
return await api.post('/ai/rag/add', data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] })
|
||||
setShowAdd(false)
|
||||
setNewItem({ title: '', content: '', source: '自定义', category: '其他' })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/rag/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||
})
|
||||
|
||||
const seedMutation = useMutation({
|
||||
mutationFn: () => api.post('/ai/rag/seed'),
|
||||
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="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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user