Files
BossAi/web2/src/pages/KnowledgeBase.tsx
T
selfrelease c1cf41edea
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled
feat: 知识库支持催收文书和合同风险库的结构化预览,新增分类筛选
2026-09-02 09:29:14 +08:00

476 lines
21 KiB
TypeScript

/** 知识库管理页:7 类分类,录入、检索、删除,要素表模板预览 */
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
BookOpen,
Search,
Plus,
Trash2,
Loader2,
FileText,
Scale,
FileEdit,
MapPin,
Calendar,
TrendingUp,
Factory,
Heart,
Coffee,
} from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState'
import { ErrorState } from '@/components/common/ErrorState'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import type { KnowledgeDoc } from '@/types'
/** 知识库文档类型配置 */
const DOC_TYPES = [
{ value: 'law', label: '法规', icon: Scale },
{ value: 'case', label: '案例', icon: FileText },
{ value: 'template', label: '模板', icon: FileEdit },
{ value: 'guide', label: '经营指南', icon: TrendingUp },
{ value: 'industry', label: '行业知识', icon: Factory },
{ value: 'emotion', label: '情绪疏导', icon: Heart },
{ value: 'life', label: '生活常识', icon: Coffee },
{ value: 'element_template', label: '要素表模板', icon: FileText },
{ value: 'debt_collection_template', label: '催收文书', icon: FileText },
{ value: 'contract_risk_clause', label: '合同风险库', icon: FileText },
]
/** 来源英文 → 中文映射 */
const SOURCE_LABELS: Record<string, string> = {
seed: '系统内置',
manual: '手动录入',
import: '导入',
api: 'API 录入',
}
/** 法规知识库管理:7 类分类,录入、检索、删除 */
export function KnowledgeBase() {
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
const [counts, setCounts] = useState<Record<string, number>>({})
const [totalCount, setTotalCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<KnowledgeDoc[] | null>(null)
const [searching, setSearching] = useState(false)
const [filterType, setFilterType] = useState<string>('all')
const [addOpen, setAddOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<KnowledgeDoc | null>(null)
// 新增表单
const [form, setForm] = useState({
title: '',
content: '',
doc_type: 'law',
region: '',
effective_date: '',
keywords: '',
})
const [saving, setSaving] = useState(false)
const loadDocs = async () => {
setLoading(true)
setError(null)
try {
const res = await api.listKnowledge(filterType !== 'all' ? filterType : undefined)
setDocs(res.results)
setCounts(res.counts || {})
setTotalCount(res.total || 0)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadDocs()
}, [filterType])
const handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults(null)
return
}
setSearching(true)
try {
const res = await api.searchKnowledge(searchQuery.trim())
setSearchResults(res.results)
} catch (e) {
toast.error(e instanceof Error ? e.message : '检索失败')
} finally {
setSearching(false)
}
}
const handleAdd = async () => {
if (!form.title.trim() || !form.content.trim()) {
toast.error('标题和内容不能为空')
return
}
setSaving(true)
try {
await api.addKnowledge({
title: form.title.trim(),
content: form.content.trim(),
doc_type: form.doc_type,
region: form.region.trim() || null,
effective_date: form.effective_date.trim() || null,
keywords: form.keywords.trim() || null,
})
toast.success('文档已添加')
setAddOpen(false)
setForm({ title: '', content: '', doc_type: 'law', region: '', effective_date: '', keywords: '' })
loadDocs()
} catch (e) {
toast.error(e instanceof Error ? e.message : '添加失败')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget?.id) return
try {
await api.deleteKnowledge(deleteTarget.id)
toast.success('文档已删除')
setDeleteTarget(null)
loadDocs()
} catch (e) {
toast.error(e instanceof Error ? e.message : '删除失败')
}
}
const displayDocs = searchResults ?? docs
return (
<PageLayout
title="知识库"
description="7 类知识库:法规、案例、模板、经营指南、行业知识、情绪疏导、生活常识。提问时按场景自动注入相关知识。"
actions={
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-3.5 w-3.5" /> 新增文档
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-w-[95vw]">
<DialogHeader>
<DialogTitle>新增知识文档</DialogTitle>
<DialogDescription>录入后将进入 RAG 检索库,提问时按场景自动引用相关知识。</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="kb-title">标题</Label>
<Input id="kb-title" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:劳动合同法-试用期解除" />
</div>
<div>
<Label htmlFor="kb-content">内容</Label>
<Textarea id="kb-content" value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} placeholder="法规条文、案例摘要或模板正文" className="min-h-[120px]" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label htmlFor="kb-type">类型</Label>
<Select value={form.doc_type} onValueChange={(v) => setForm({ ...form, doc_type: v })}>
<SelectTrigger id="kb-type"><SelectValue /></SelectTrigger>
<SelectContent>
{DOC_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="kb-region">地区</Label>
<Input id="kb-region" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="如:全国 / 上海" />
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label htmlFor="kb-date">生效日期</Label>
<Input id="kb-date" type="date" value={form.effective_date} onChange={(e) => setForm({ ...form, effective_date: e.target.value })} />
</div>
<div>
<Label htmlFor="kb-keywords">关键词</Label>
<Input id="kb-keywords" value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="逗号分隔,如:试用期,解除" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}>取消</Button>
<Button onClick={handleAdd} disabled={saving}>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />} 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}
>
{/* 检索栏 */}
<Card>
<CardContent className="py-4">
<div className="flex flex-wrap gap-2">
<div className="relative min-w-0 flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="检索法规、案例、经营指南、行业知识…"
className="pl-9"
/>
</div>
<Button onClick={handleSearch} disabled={searching}>
{searching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
检索
</Button>
{searchResults && (
<Button variant="outline" onClick={() => { setSearchResults(null); setSearchQuery('') }}>
清除
</Button>
)}
</div>
{/* 类型筛选 */}
{!searchResults && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant={filterType === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilterType('all')}
>
全部 <span className="ml-1 text-xs opacity-70">{totalCount}</span>
</Button>
{DOC_TYPES.map((t) => (
<Button
key={t.value}
variant={filterType === t.value ? 'default' : 'outline'}
size="sm"
onClick={() => setFilterType(t.value)}
>
<t.icon className="h-3.5 w-3.5" /> {t.label}
<span className="ml-1 text-xs opacity-70">{counts[t.value] || 0}</span>
</Button>
))}
</div>
)}
</CardContent>
</Card>
{/* 文档列表 */}
{loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error} onRetry={loadDocs} />
) : displayDocs.length === 0 ? (
<EmptyState
title={searchResults ? '未找到匹配文档' : '知识库为空'}
description={searchResults ? '尝试更换关键词' : '点击右上角新增文档,录入法规、案例、经营指南等知识'}
icon={BookOpen}
/>
) : (
<div className="space-y-3">
{searchResults && (
<p className="text-sm text-muted-foreground">找到 {searchResults.length} 条匹配结果</p>
)}
{displayDocs.map((doc) => {
const typeInfo = DOC_TYPES.find((t) => t.value === doc.doc_type)
return (
<Card key={doc.id} className={cn(searchResults && doc.score && 'border-l-4 border-l-primary')}>
<CardContent className="py-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2">
{typeInfo && <typeInfo.icon className="h-4 w-4 text-muted-foreground" aria-hidden />}
<h3 className="truncate font-medium">{doc.title}</h3>
{doc.doc_type && (
<Badge variant="secondary">{typeInfo?.label || doc.doc_type}</Badge>
)}
{searchResults && doc.score && (
<Badge variant="outline">相关度 {doc.score}</Badge>
)}
</div>
{doc.doc_type !== 'element_template' && doc.doc_type !== 'debt_collection_template' && doc.doc_type !== 'contract_risk_clause' && (
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
)}
{(() => {
/** 结构化模板预览:element_template / debt_collection_template / contract_risk_clause */
const STRUCTURED_TYPES = ['element_template', 'debt_collection_template', 'contract_risk_clause']
if (!STRUCTURED_TYPES.includes(doc.doc_type)) return null
try {
const tmpl = JSON.parse(doc.content)
const TYPE_LABELS: Record<string, string> = {
text: '单行文本',
textarea: '多行文本',
select: '下拉选择',
radio: '单选',
checkbox: '多选',
date: '日期',
number: '数字',
}
// 文书模板(element_template / debt_collection_template)
if (doc.doc_type === 'element_template' || doc.doc_type === 'debt_collection_template') {
const titleField = tmpl.cause_of_action || tmpl.name || tmpl.title
const stageLabel = tmpl.stage ? ` · 阶段${tmpl.stage}:${tmpl.stage_name || ''}` : ''
return (
<div className="mt-2 rounded border bg-muted/30 p-3 text-xs">
<p className="font-medium">{doc.doc_type === 'debt_collection_template' ? '文书' : '案由'}:{titleField}{stageLabel}</p>
{tmpl.applicable && <p className="mt-0.5 text-muted-foreground">适用:{tmpl.applicable}</p>}
{tmpl.elements?.length > 0 && (
<>
<p className="mt-2 font-medium">要素清单({tmpl.elements.length}项)</p>
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{tmpl.elements.map((e: Record<string, unknown>, i: number) => (
<div key={i} className="flex items-center justify-between">
<span className="text-muted-foreground">{e.label}</span>
<span className="font-medium">
{TYPE_LABELS[String(e.type)] || String(e.type)}
{e.required === true || e.required === '是' ? ' ·必填' : ''}
</span>
</div>
))}
</div>
</>
)}
{tmpl.claims?.length > 0 && (
<>
<p className="mt-2 font-medium">诉讼请求</p>
<div className="mt-1 flex flex-wrap gap-1">
{tmpl.claims.map((c: string, i: number) => (
<Badge key={i} variant="outline" className="text-[10px]">{c}</Badge>
))}
</div>
</>
)}
</div>
)
}
// 合同风险条款库(contract_risk_clause)
if (doc.doc_type === 'contract_risk_clause') {
return (
<div className="mt-2 rounded border bg-muted/30 p-3 text-xs space-y-2">
<p className="font-medium">合同类型:{tmpl.contract_type}</p>
{tmpl.high_risk_clauses?.length > 0 && (
<div>
<p className="font-medium text-red-600">高风险条款({tmpl.high_risk_clauses.length}项)</p>
<div className="mt-1 space-y-1">
{tmpl.high_risk_clauses.map((c: Record<string, string>, i: number) => (
<div key={i} className="rounded bg-red-50 px-2 py-1">
<span className="font-medium">{c.risk}</span>
<span className="text-muted-foreground"> - {c.description}</span>
</div>
))}
</div>
</div>
)}
{tmpl.medium_risk_clauses?.length > 0 && (
<div>
<p className="font-medium text-amber-600">中风险条款({tmpl.medium_risk_clauses.length}项)</p>
<div className="mt-1 space-y-1">
{tmpl.medium_risk_clauses.map((c: Record<string, string>, i: number) => (
<div key={i} className="rounded bg-amber-50 px-2 py-1">
<span className="font-medium">{c.risk}</span>
<span className="text-muted-foreground"> - {c.description}</span>
</div>
))}
</div>
</div>
)}
{tmpl.missing_clauses?.length > 0 && (
<div>
<p className="font-medium">缺失条款提醒</p>
<div className="mt-1 flex flex-wrap gap-1">
{tmpl.missing_clauses.map((c: string, i: number) => (
<Badge key={i} variant="outline" className="text-[10px] text-muted-foreground">{c}</Badge>
))}
</div>
</div>
)}
</div>
)
}
return null
} catch { return null }
})()}
<div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-3 w-3" /> 来源:{SOURCE_LABELS[doc.source] || doc.source}
</span>
{doc.region && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" /> {doc.region}
</span>
)}
{doc.effective_date && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> {doc.effective_date}
</span>
)}
{doc.keywords && (
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" /> {doc.keywords}
</span>
)}
</div>
</div>
{!searchResults && doc.id && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-rose-600"
onClick={() => setDeleteTarget(doc)}
aria-label="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* 删除确认 */}
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>确认删除?</DialogTitle>
<DialogDescription>将永久删除「{deleteTarget?.title}」,此操作不可恢复。</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
<Button variant="destructive" onClick={handleDelete}>确认删除</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
)
}