2968484d2d
- 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
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|