feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
+847
View File
@@ -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>
)
}