feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
- 新增 web2/ 前端:任务卡看板、风险雷达、决策日志、我的团队、快捷指令 - ChatPanel 增强:风险卡置顶、案卡接入、起诉状向导、语音输入 - 事项看板:点击查看详情弹窗、断点续接(恢复历史对话+matterId) - 保密模式恢复:密码验证后重建内存 session,保留多轮上下文 - 多轮对话:ask/ask-stream/ask-analyze 注入 session 历史,AI 有上下文 - 追问优化:紧扣用户问题,不再偏离主题(社保问社保,工伤问工伤) - 对话区可滚动,输入区固定可见 - 状态机放宽:允许从任意非终态转到 pending_expert - session 存活检查端点,后端重启后自动重建 session - DB migration:风险推送表、案卡表 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
/** 知识库管理页: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 },
|
||||
]
|
||||
|
||||
/** 来源英文 → 中文映射 */
|
||||
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 items-center gap-2">
|
||||
{typeInfo && <typeInfo.icon className="h-4 w-4 text-muted-foreground" aria-hidden />}
|
||||
<h3 className="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' && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
|
||||
)}
|
||||
{doc.doc_type === 'element_template' && (() => {
|
||||
try {
|
||||
const tmpl = JSON.parse(doc.content)
|
||||
return (
|
||||
<div className="mt-2 rounded border bg-muted/30 p-3 text-xs">
|
||||
<p className="font-medium">案由:{tmpl.cause_of_action}</p>
|
||||
<p className="mt-0.5 text-muted-foreground">适用:{tmpl.applicable}</p>
|
||||
<p className="mt-2 font-medium">要素清单({tmpl.elements?.length || 0}项)</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, string>, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{e.label}</span>
|
||||
<span className="font-medium">{e.type}{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>
|
||||
)
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user