Files
lingxi-web/src/pages/knowledge/KnowledgePage.tsx
T
wmq af49d22ca7 feat: release contextual omnichannel customer platform
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.
2026-07-30 02:29:53 +08:00

253 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}