Files
TurboHR/frontend/src/pages/ai-assistant/CaseTab.tsx
T
freedakgmail 2968484d2d 优化: 大文件拆分+代码分割+按需加载+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
2026-08-04 07:53:37 +08:00

264 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}