- 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>
504 lines
17 KiB
TypeScript
504 lines
17 KiB
TypeScript
// ============================================================
|
|
// 知识库管理
|
|
// ============================================================
|
|
|
|
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,
|
|
}))
|
|
}
|