feat(admin-v2): add Knowledge base management page
- 4 tabs: Items (CRUD + ProTable), Categories (tree management), Search, Analytics - Knowledge service with full API integration - Nav item + breadcrumb + route registration - Analytics overview with 8 KPI statistics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
503
admin-v2/src/pages/Knowledge.tsx
Normal file
503
admin-v2/src/pages/Knowledge.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
// ============================================================
|
||||
// 知识库管理
|
||||
// ============================================================
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
|
||||
Card, Statistic, Row, Col, Tabs, Tree, Typography, Empty, Spin, InputNumber,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, SearchOutlined, BookOutlined, FolderOutlined,
|
||||
DeleteOutlined, EditOutlined, EyeOutlined, BarChartOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
import type { CategoryResponse, KnowledgeItem, SearchResult } from '@/services/knowledge'
|
||||
|
||||
const { TextArea } = Input
|
||||
const { Text, Title } = Typography
|
||||
|
||||
// === 分类树 + 条目列表 Tab ===
|
||||
|
||||
function CategoriesPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['knowledge-categories'],
|
||||
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof knowledgeService.createCategory>[0]) =>
|
||||
knowledgeService.createCategory(data),
|
||||
onSuccess: () => {
|
||||
message.success('分类已创建')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => knowledgeService.deleteCategory(id),
|
||||
onSuccess: () => {
|
||||
message.success('分类已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||
})
|
||||
|
||||
const treeData = useMemo(
|
||||
() => buildTreeData(categories, (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除后无法恢复,请确保分类下没有子分类和条目。',
|
||||
okType: 'danger',
|
||||
onOk: () => deleteMutation.mutate(id),
|
||||
})
|
||||
}),
|
||||
[categories, deleteMutation],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title level={5} style={{ margin: 0 }}>分类管理</Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
新建分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spin /></div>
|
||||
) : categories.length === 0 ? (
|
||||
<Empty description="暂无分类,请新建一个" />
|
||||
) : (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
defaultExpandAll
|
||||
showLine={{ showLeafIcon: false }}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="新建分类"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }]}>
|
||||
<Input placeholder="例如:产品知识、技术文档" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={2} placeholder="可选描述" />
|
||||
</Form.Item>
|
||||
<Form.Item name="parent_id" label="父分类">
|
||||
<Select placeholder="无(顶级分类)" allowClear>
|
||||
{flattenCategories(categories).map((c) => (
|
||||
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="icon" label="图标">
|
||||
<Input placeholder="可选,如 📚" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === 条目列表 ===
|
||||
|
||||
function ItemsPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [detailItem, setDetailItem] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [filters, setFilters] = useState<{ category_id?: string; status?: string; keyword?: string }>({})
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['knowledge-categories'],
|
||||
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
|
||||
})
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['knowledge-items', page, pageSize, filters],
|
||||
queryFn: ({ signal }) =>
|
||||
knowledgeService.listItems({ page, page_size: pageSize, ...filters }, signal),
|
||||
})
|
||||
|
||||
const { data: detailData } = useQuery({
|
||||
queryKey: ['knowledge-item-detail', detailItem],
|
||||
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
|
||||
enabled: !!detailItem,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Parameters<typeof knowledgeService.createItem>[0]) =>
|
||||
knowledgeService.createItem(data),
|
||||
onSuccess: () => {
|
||||
message.success('条目已创建')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => knowledgeService.deleteItem(id),
|
||||
onSuccess: () => {
|
||||
message.success('已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||
})
|
||||
|
||||
const statusColors: Record<string, string> = { active: 'green', draft: 'orange', archived: 'default' }
|
||||
const statusLabels: Record<string, string> = { active: '活跃', draft: '草稿', archived: '已归档' }
|
||||
|
||||
const columns: ProColumns<KnowledgeItem>[] = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
width: 250,
|
||||
render: (_, r) => (
|
||||
<Button type="link" size="small" onClick={() => setDetailItem(r.id)}>
|
||||
{r.title}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, valueEnum: Object.fromEntries(Object.entries(statusLabels).map(([k, v]) => [k, { text: v, status: statusColors[k] === 'green' ? 'Success' : statusColors[k] === 'orange' ? 'Warning' : 'Default' }])) },
|
||||
{ title: '版本', dataIndex: 'version', width: 60, search: false },
|
||||
{ title: '优先级', dataIndex: 'priority', width: 70, search: false },
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tags',
|
||||
width: 200,
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{r.tags?.map((t) => <Tag key={t}>{t}</Tag>)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '更新时间', dataIndex: 'updated_at', width: 160, valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(r.id)} />
|
||||
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ProTable<KnowledgeItem>
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={{
|
||||
onReset: () => setFilters({}),
|
||||
onSearch: (values) => setFilters(values),
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
新建条目
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||
}}
|
||||
options={{ density: false, fullScreen: false, reload: () => queryClient.invalidateQueries({ queryKey: ['knowledge-items'] }) }}
|
||||
/>
|
||||
|
||||
{/* 创建弹窗 */}
|
||||
<Modal
|
||||
title="新建知识条目"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||
<Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
|
||||
<Select placeholder="选择分类">
|
||||
{flattenCategories(categories).map((c) => (
|
||||
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input placeholder="知识条目标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
|
||||
<TextArea rows={8} placeholder="支持 Markdown 格式" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="keywords" label="关键词">
|
||||
<Select mode="tags" placeholder="输入后回车添加" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select mode="tags" placeholder="输入后回车添加" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="priority" label="优先级" initialValue={0}>
|
||||
<InputNumber min={0} max={100} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={detailData?.title || '条目详情'}
|
||||
open={!!detailItem}
|
||||
onCancel={() => setDetailItem(null)}
|
||||
footer={null}
|
||||
width={720}
|
||||
>
|
||||
{detailData && (
|
||||
<div>
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Tag color={statusColors[detailData.status]}>{statusLabels[detailData.status] || detailData.status}</Tag>
|
||||
<Tag>版本 {detailData.version}</Tag>
|
||||
<Tag>优先级 {detailData.priority}</Tag>
|
||||
</div>
|
||||
<div className="mb-4 whitespace-pre-wrap bg-neutral-50 dark:bg-neutral-900 p-4 rounded-lg max-h-96 overflow-y-auto text-sm">
|
||||
{detailData.content}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{detailData.tags?.map((t) => <Tag key={t} color="blue">{t}</Tag>)}
|
||||
{detailData.keywords?.map((k) => <Tag key={k} color="cyan">{k}</Tag>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === 搜索面板 ===
|
||||
|
||||
function SearchPanel() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await knowledgeService.search({ query: query.trim(), limit: 10 })
|
||||
setResults(data)
|
||||
} catch (err) {
|
||||
message.error('搜索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={5}>语义搜索</Title>
|
||||
<Space.Compact className="w-full mb-4">
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="输入搜索关键词..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Button size="large" type="primary" loading={searching} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{results.length === 0 && !searching && query === '' && (
|
||||
<Empty description="输入关键词搜索知识库" />
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.map((r) => (
|
||||
<Card key={r.chunk_id} size="small" hoverable>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<Text strong>{r.item_title}</Text>
|
||||
<Tag>{r.category_name}</Tag>
|
||||
</div>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-3 mb-2">
|
||||
{r.content}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{r.keywords?.slice(0, 5).map((k) => (
|
||||
<Tag key={k} color="cyan" style={{ fontSize: 11 }}>{k}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === 分析看板 ===
|
||||
|
||||
function AnalyticsPanel() {
|
||||
const { data: overview, isLoading } = useQuery({
|
||||
queryKey: ['knowledge-analytics'],
|
||||
queryFn: ({ signal }) => knowledgeService.getOverview(signal),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="flex justify-center py-8"><Spin /></div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={5} className="mb-4">知识库概览</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="总条目数" value={overview?.total_items || 0} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="活跃条目" value={overview?.active_items || 0} valueStyle={{ color: '#52c41a' }} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="分类数" value={overview?.total_categories || 0} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="本周新增" value={overview?.weekly_new_items || 0} valueStyle={{ color: '#1890ff' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} className="mt-4">
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="总引用次数" value={overview?.total_references || 0} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="注入率"
|
||||
value={((overview?.injection_rate || 0) * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="正面反馈率"
|
||||
value={((overview?.positive_feedback_rate || 0) * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="过期条目" value={overview?.stale_items_count || 0} valueStyle={{ color: '#faad14' }} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === 主页面 ===
|
||||
|
||||
export default function Knowledge() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Tabs
|
||||
defaultActiveKey="items"
|
||||
items={[
|
||||
{
|
||||
key: 'items',
|
||||
label: '知识条目',
|
||||
icon: <BookOutlined />,
|
||||
children: <ItemsPanel />,
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: '分类管理',
|
||||
icon: <FolderOutlined />,
|
||||
children: <CategoriesPanel />,
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
label: '搜索',
|
||||
icon: <SearchOutlined />,
|
||||
children: <SearchPanel />,
|
||||
},
|
||||
{
|
||||
key: 'analytics',
|
||||
label: '分析看板',
|
||||
icon: <BarChartOutlined />,
|
||||
children: <AnalyticsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === 辅助函数 ===
|
||||
|
||||
function flattenCategories(cats: CategoryResponse[]): { id: string; name: string }[] {
|
||||
const result: { id: string; name: string }[] = []
|
||||
for (const c of cats) {
|
||||
result.push({ id: c.id, name: c.name })
|
||||
if (c.children?.length) {
|
||||
result.push(...flattenCategories(c.children))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
key: string
|
||||
title: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void): TreeNode[] {
|
||||
return cats.map((c) => ({
|
||||
key: c.id,
|
||||
title: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{c.icon || '📁'} {c.name}</span>
|
||||
<Tag>{c.item_count}</Tag>
|
||||
<Button type="link" size="small" danger onClick={() => onDelete(c.id)}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
children: c.children?.length ? buildTreeData(c.children, onDelete) : undefined,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user