476 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|