af49d22ca7
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
253 lines
13 KiB
TypeScript
253 lines
13 KiB
TypeScript
import { useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { Plus, Search, Upload, BookOpen, BarChart3, Zap, FileText, Download, RefreshCw } from 'lucide-react'
|
||
import { toast, errorMessage } from '@/app/store/toast'
|
||
import { confirmDialog } from '@/app/store/confirm'
|
||
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
||
import { PageHeader } from '@/components/common/PageHeader'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Card, CardHeader } from '@/components/ui/card'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Input, Textarea, Field } from '@/components/ui/input'
|
||
import { Tabs } from '@/components/ui/tabs'
|
||
import { Dialog } from '@/components/ui/dialog'
|
||
import {
|
||
getKnowledge, createKnowledge, updateKnowledge, deleteKnowledge,
|
||
uploadKnowledgeFiles, knowledgeSearchTest, getKnowledgeStats,
|
||
} from '@/services/api/knowledge'
|
||
import { KNOWLEDGE_CATEGORIES } from '@/config/constants'
|
||
import { formatRelative } from '@/utils/format'
|
||
import { cn } from '@/utils/cn'
|
||
import type { KnowledgeItem } from '@/types'
|
||
|
||
export function KnowledgePage() {
|
||
const queryClient = useQueryClient()
|
||
const [tab, setTab] = useState('list')
|
||
const [category, setCategory] = useState('')
|
||
const [keyword, setKeyword] = useState('')
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [editItem, setEditItem] = useState<KnowledgeItem | null>(null)
|
||
const [searchQ, setSearchQ] = useState('')
|
||
const [createDraft, setCreateDraft] = useState({ title: '', content: '' })
|
||
|
||
const { data, isLoading, isError, refetch } = useQuery({
|
||
queryKey: ['knowledge', category, keyword],
|
||
queryFn: () => getKnowledge({ category, keyword }),
|
||
})
|
||
const { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats })
|
||
const [searchResult, setSearchResult] = useState<any>(null)
|
||
const [searchLoading, setSearchLoading] = useState(false)
|
||
const [uploadFiles, setUploadFiles] = useState<File[]>([])
|
||
const [uploadDialog, setUploadDialog] = useState(false)
|
||
const [uploadLoading, setUploadLoading] = useState(false)
|
||
|
||
const createMut = useMutation({
|
||
mutationFn: (p: any) => createKnowledge(p),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
|
||
toast.success('知识已创建并完成索引')
|
||
setCreateDraft({ title: '', content: '' })
|
||
setCreateOpen(false)
|
||
},
|
||
onError: (e) => toast.error(errorMessage(e)),
|
||
})
|
||
|
||
const toggleMut = useMutation({
|
||
mutationFn: ({ id, enabled }: any) => updateKnowledge(id, { enabled }),
|
||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }) },
|
||
})
|
||
|
||
const deleteMut = useMutation({
|
||
mutationFn: (id: string) => deleteKnowledge(id),
|
||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }); toast.success('已删除') },
|
||
})
|
||
|
||
const list = data?.list ?? []
|
||
|
||
if (isLoading) return <PageLoading />
|
||
if (isError) return <ErrorState onRetry={() => refetch()} />
|
||
|
||
return (
|
||
<div className="p-4 md:p-6">
|
||
<PageHeader
|
||
title="企业知识库"
|
||
description="把产品、价格、物流和售后资料变成可检索、可引用的企业答案"
|
||
actions={
|
||
<>
|
||
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
|
||
<Button size="sm" onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4" />新建知识</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||
{[
|
||
['当前条目', data?.total || 0],
|
||
['已分段索引', list.reduce((sum, item) => sum + Number(item.chunkCount || 0), 0)],
|
||
['真实引用', stats?.totalCitations || 0],
|
||
['待优化内容', (stats?.lowQuality?.length || 0) + (stats?.outdated?.length || 0)],
|
||
].map(([label, value]) => (
|
||
<Card key={String(label)} className="px-4 py-3">
|
||
<p className="text-[11px] font-medium text-slate-400">{label}</p>
|
||
<p className="mt-1 text-xl font-semibold tracking-tight text-slate-900">{value}</p>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<Tabs value={tab} onChange={setTab} className="mb-4" items={[
|
||
{ value: 'list', label: '知识列表' },
|
||
{ value: 'search', label: '检索测试' },
|
||
{ value: 'stats', label: '使用统计' },
|
||
]} />
|
||
|
||
{tab === 'list' && (
|
||
<div className="flex gap-4">
|
||
<div className="w-48 shrink-0 space-y-1 hidden md:block">
|
||
<button onClick={() => setCategory('')} className={cn('block w-full text-start rounded px-2 py-1.5 text-sm', !category ? 'bg-primary-50 font-medium text-primary-700' : 'text-muted-foreground hover:text-foreground')}>全部分类</button>
|
||
{KNOWLEDGE_CATEGORIES.map((c) => (
|
||
<button key={c.value} onClick={() => setCategory(c.value)} className={cn('block w-full text-start rounded px-2 py-1.5 text-sm', category === c.value ? 'bg-primary-50 font-medium text-primary-700' : 'text-muted-foreground hover:text-foreground')}>{c.label}</button>
|
||
))}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<div className="relative flex-1 max-w-xs">
|
||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<input placeholder="搜索知识标题/内容/标签" value={keyword} onChange={(e) => setKeyword(e.target.value)} className="h-9 w-full rounded-md border border-input bg-card pl-8 pr-3 text-sm focus:border-primary focus:outline-none" />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{list.map((item) => (
|
||
<Card key={item.id} className="p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium">{item.title}</span>
|
||
<Badge variant="outline">{KNOWLEDGE_CATEGORIES.find(c => c.value === item.category)?.label}</Badge>
|
||
<span className="text-xs text-muted-foreground">v{item.version}</span>
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{item.content}</p>
|
||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span>引用 {item.citedCount} 次</span>
|
||
<span>·</span>
|
||
<span>{item.chunkCount || 0} 个检索片段</span>
|
||
<span>·</span>
|
||
<span>{Number(item.characterCount || item.content?.length || 0).toLocaleString('zh-CN')} 字符</span>
|
||
<span>·</span>
|
||
<span>{formatRelative(item.updatedAt)}</span>
|
||
{item.status !== 'ready' && <><span>·</span><span>{item.status === 'parsing' ? '解析中…' : '解析失败'}</span></>}
|
||
</div>
|
||
{item.parseMessage && <p className="mt-1 text-[11px] text-emerald-600">{item.parseMessage}</p>}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Switch checked={item.enabled} onCheckedChange={(v) => toggleMut.mutate({ id: item.id, enabled: v })} />
|
||
<Button variant="ghost" size="icon-sm" onClick={() => confirmDialog({ title: '删除知识', description: `确认删除「${item.title}」?`, danger: true, onConfirm: () => deleteMut.mutate(item.id) })}><span className="font-bold text-red-500">×</span></Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{tab === 'search' && (
|
||
<Card className="p-4">
|
||
<div className="flex gap-2">
|
||
<Input placeholder="输入问题,测试知识库检索和答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
|
||
<Button loading={searchLoading} onClick={async () => {
|
||
if (!searchQ.trim()) return
|
||
setSearchLoading(true)
|
||
try {
|
||
const res = await knowledgeSearchTest(searchQ)
|
||
setSearchResult(res)
|
||
} catch (e) { toast.error(errorMessage(e)) } finally { setSearchLoading(false) }
|
||
}}><Search className="h-4 w-4" />检索</Button>
|
||
</div>
|
||
{searchResult && (
|
||
<div className="mt-4 space-y-3">
|
||
<Card className="p-3 bg-primary-50/30">
|
||
<p className="text-xs font-semibold text-muted-foreground mb-1">参考答案</p>
|
||
<p className="text-sm">{searchResult.answer}</p>
|
||
</Card>
|
||
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
|
||
{searchResult.hits.map((h: any) => (
|
||
<div key={h.item.id} className="rounded-md border border-border p-3">
|
||
<div className="flex items-center justify-between"><span className="text-sm font-medium">{h.item.title}</span><Badge variant="primary">相关度 {(h.score * 100).toFixed(0)}%</Badge></div>
|
||
<p className="mt-1 text-xs text-muted-foreground">{h.snippet}</p>
|
||
<p className="mt-1 text-[10px] text-slate-400">命中片段 #{Number(h.chunkIndex || 0) + 1}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
{tab === 'stats' && stats && (
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<Card className="p-4">
|
||
<h3 className="text-sm font-semibold mb-3">知识引用概况</h3>
|
||
<p className="text-2xl font-semibold">{stats.totalCitations} <span className="text-sm text-muted-foreground">次总引用</span></p>
|
||
<p className="text-sm text-muted-foreground mt-1">AI回复引用率:{(stats.aiReplyCitationRate * 100).toFixed(1)}%</p>
|
||
</Card>
|
||
<Card className="p-4">
|
||
<h3 className="text-sm font-semibold mb-3">最常引用</h3>
|
||
{stats.topUsed.map((k) => (
|
||
<div key={k.id} className="flex items-center justify-between text-sm py-1"><span>{k.title}</span><span className="text-muted-foreground">{k.citedCount}次</span></div>
|
||
))}
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={createOpen} onOpenChange={setCreateOpen} title="新建知识条目" footer={
|
||
<Button onClick={() => {
|
||
if (!createDraft.title.trim()) { toast.warning('请输入标题'); return }
|
||
if (createDraft.content.trim().length < 20) { toast.warning('知识内容至少需要 20 个字符'); return }
|
||
createMut.mutate({ ...createDraft, category: category || 'faq' })
|
||
}} loading={createMut.isPending}>创建并索引</Button>
|
||
}>
|
||
<Field label="标题" required><Input value={createDraft.title} onChange={(event) => setCreateDraft((value) => ({ ...value, title: event.target.value }))} placeholder="例如:欧洲地区退换货政策" /></Field>
|
||
<Field label="内容" className="mt-3"><Textarea value={createDraft.content} onChange={(event) => setCreateDraft((value) => ({ ...value, content: event.target.value }))} rows={8} placeholder="写明适用范围、条件、时效、例外和处理方式。系统会自动分段并用于回复建议。" /></Field>
|
||
</Dialog>
|
||
|
||
<Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={
|
||
<Button disabled={!uploadFiles.length} loading={uploadLoading} onClick={async () => {
|
||
setUploadLoading(true)
|
||
try {
|
||
const payload = await Promise.all(uploadFiles.map(async (file) => ({
|
||
name: file.name,
|
||
type: file.type,
|
||
base64: await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onerror = () => reject(reader.error)
|
||
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
|
||
reader.readAsDataURL(file)
|
||
}),
|
||
})))
|
||
const result = await uploadKnowledgeFiles(payload)
|
||
toast.success(`${result.items.length} 个文件解析并建立检索索引`)
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
|
||
setUploadFiles([])
|
||
setUploadDialog(false)
|
||
} catch (error) {
|
||
toast.error(errorMessage(error))
|
||
} finally {
|
||
setUploadLoading(false)
|
||
}
|
||
}}>上传、解析并索引</Button>
|
||
}>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
accept=".pdf,.docx,.txt,.md,.csv,.json,.html"
|
||
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
|
||
className="block w-full rounded-md border border-input p-2 text-sm"
|
||
/>
|
||
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,上传后自动解析并写入知识库。</p>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|