feat: AI历史记录、企业信息seed、员工状态自动刷新、风险预测数据准确性优化
- 智能问答/风险预测/合同审查/案例匹配 均支持历史记录保存、加载、删除 - 后端 predict/predict-stream 补充离职记录详情和预计算工龄/天数,避免AI计算错误 - Organization 增加 contactName/contactPhone 字段,seed 企业信息 - Settings 页面修复 orgData/usersData 取值路径错误 - Roster/Termination/Contracts mutation 增加 roster cache invalidation - 员工档案页面 tab 固定、内容区域滚动到窗口底部 - 解聘证据链显示逻辑修复(协商一致离职 vs 员工主动离职)
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -13,6 +14,64 @@ import Modal from '../components/ui/Modal'
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge'
|
||||
|
||||
// 通用 AI 历史记录 hook
|
||||
function useAIHistory(type: 'predict' | 'review' | 'case') {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = [`ai-history-${type}`]
|
||||
|
||||
const { data: history } = useQuery<any[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/ai/conversations?type=${type}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
|
||||
const res = await api.post('/ai/conversations', {
|
||||
title: `${type}:${title}`,
|
||||
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
|
||||
}) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
})
|
||||
|
||||
const loadHistory = useCallback(async (id: string) => {
|
||||
const res = await api.get(`/ai/conversations/${id}`) as any
|
||||
return res.data
|
||||
}, [])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
@@ -90,7 +149,7 @@ function ChatTab() {
|
||||
const { data: conversations } = useQuery<any[]>({
|
||||
queryKey: ['ai-conversations'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/ai/conversations') as any
|
||||
const res = await api.get('/ai/conversations?type=chat') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
@@ -109,7 +168,7 @@ function ChatTab() {
|
||||
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) || '新对话'
|
||||
const title = `chat:${messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'}`
|
||||
if (currentConvId) {
|
||||
await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {})
|
||||
} else {
|
||||
@@ -280,7 +339,7 @@ function ChatTab() {
|
||||
<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="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>
|
||||
@@ -372,6 +431,9 @@ function PredictTab() {
|
||||
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 { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
@@ -384,44 +446,143 @@ function PredictTab() {
|
||||
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
|
||||
|
||||
const fetchPrediction = async () => {
|
||||
if (loading) return
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
|
||||
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)
|
||||
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 || '请求失败')
|
||||
}
|
||||
|
||||
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('**出错了**')) {
|
||||
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) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
if (err.name === 'AbortError') return
|
||||
setResult(`**出错了**:${err.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrediction()
|
||||
}, [])
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
<div>
|
||||
<Label>预测范围</Label>
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap mt-3">
|
||||
<div className="min-w-[120px]">
|
||||
<Button size="sm" onClick={fetchPrediction} disabled={loading}>
|
||||
{loading ? '分析中...' : result ? '重新预测' : '开始预测'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">预测范围</Label>
|
||||
<Select value={scope} onChange={(e) => setScope(e.target.value)}>
|
||||
<option value="all">全部员工</option>
|
||||
<option value="department">按部门</option>
|
||||
<option value="employee">指定员工</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>风险类型</Label>
|
||||
<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>
|
||||
@@ -430,8 +591,8 @@ function PredictTab() {
|
||||
</Select>
|
||||
</div>
|
||||
{scope === 'department' && (
|
||||
<div>
|
||||
<Label>部门</Label>
|
||||
<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>)}
|
||||
@@ -439,8 +600,8 @@ function PredictTab() {
|
||||
</div>
|
||||
)}
|
||||
{scope === 'employee' && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<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>)}
|
||||
@@ -449,16 +610,33 @@ function PredictTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{loading && !result && (
|
||||
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 正在分析企业用工风险...
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="prose prose-sm max-w-none mt-4 overflow-x-auto
|
||||
[&_table]:border-collapse [&_table]:w-full [&_table]:text-xs [&_table]:min-w-[600px]
|
||||
[&_th]:border [&_th]:border-gray-300 [&_th]:px-2 [&_th]:py-1 [&_th]:bg-gray-50 [&_th]:font-medium [&_th]:whitespace-nowrap
|
||||
[&_td]:border [&_td]:border-gray-300 [&_td]:px-2 [&_td]:py-1 [&_td]:align-top
|
||||
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2
|
||||
[&_h3]:text-xs [&_h3]:font-medium [&_h3]:mt-3 [&_h3]:mb-1
|
||||
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:text-xs
|
||||
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:text-xs
|
||||
[&_strong]:font-semibold
|
||||
[&_p]:text-xs [&_p]:leading-relaxed
|
||||
[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-3 [&_blockquote]:text-gray-600 [&_blockquote]:text-xs [&_blockquote]:my-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{result}</ReactMarkdown>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
|
||||
</div>
|
||||
)}
|
||||
{!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>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -469,6 +647,8 @@ function ReviewTab() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
@@ -485,6 +665,11 @@ function ReviewTab() {
|
||||
try {
|
||||
const res = await api.post('/ai/review', { contractText }) as any
|
||||
setResult(res.data)
|
||||
// 自动保存到历史
|
||||
if (res.data && !res.data.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 {
|
||||
@@ -492,6 +677,19 @@ function ReviewTab() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -516,7 +714,15 @@ function ReviewTab() {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileSearch className="w-5 h-5 text-primary" />
|
||||
<h2 className="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-[200px] resize-y"
|
||||
@@ -529,6 +735,7 @@ function ReviewTab() {
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />审查中...</> : '开始审查'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
@@ -615,12 +822,14 @@ function CaseTab() {
|
||||
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: ['roster-list'],
|
||||
@@ -637,6 +846,11 @@ function CaseTab() {
|
||||
try {
|
||||
const res = await api.post('/ai/match-case', { scenario }) as any
|
||||
setResult(res.data.result)
|
||||
// 自动保存到历史
|
||||
if (res.data?.result && !res.data.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 {
|
||||
@@ -644,6 +858,17 @@ function CaseTab() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -684,7 +909,15 @@ function CaseTab() {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Scale className="w-5 h-5 text-primary" />
|
||||
<h2 className="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"
|
||||
@@ -697,6 +930,7 @@ function CaseTab() {
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />分析中...</> : '分析'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
|
||||
Reference in New Issue
Block a user