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:
@@ -16,6 +16,7 @@ import {
|
|||||||
SunOutlined,
|
SunOutlined,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
|
BookOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
@@ -42,6 +43,7 @@ const navItems: NavItem[] = [
|
|||||||
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
||||||
|
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read', group: '资源管理' },
|
||||||
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
||||||
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
||||||
@@ -204,6 +206,7 @@ const breadcrumbMap: Record<string, string> = {
|
|||||||
'/agent-templates': 'Agent 模板',
|
'/agent-templates': 'Agent 模板',
|
||||||
'/usage': '用量统计',
|
'/usage': '用量统计',
|
||||||
'/relay': '中转任务',
|
'/relay': '中转任务',
|
||||||
|
'/knowledge': '知识库',
|
||||||
'/config': '系统配置',
|
'/config': '系统配置',
|
||||||
'/prompts': '提示词管理',
|
'/prompts': '提示词管理',
|
||||||
'/logs': '操作日志',
|
'/logs': '操作日志',
|
||||||
|
|||||||
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'knowledge', lazy: () => import('@/pages/Knowledge').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
|
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
|
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
|
||||||
|
|||||||
128
admin-v2/src/services/knowledge.ts
Normal file
128
admin-v2/src/services/knowledge.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface CategoryResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
parent_id: string | null
|
||||||
|
icon: string | null
|
||||||
|
sort_order: number
|
||||||
|
item_count: number
|
||||||
|
children: CategoryResponse[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeItem {
|
||||||
|
id: string
|
||||||
|
category_id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
keywords: string[]
|
||||||
|
related_questions: string[]
|
||||||
|
priority: number
|
||||||
|
status: string
|
||||||
|
version: number
|
||||||
|
source: string
|
||||||
|
tags: string[]
|
||||||
|
created_by: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
chunk_id: string
|
||||||
|
item_id: string
|
||||||
|
item_title: string
|
||||||
|
category_name: string
|
||||||
|
content: string
|
||||||
|
score: number
|
||||||
|
keywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverview {
|
||||||
|
total_items: number
|
||||||
|
active_items: number
|
||||||
|
total_categories: number
|
||||||
|
weekly_new_items: number
|
||||||
|
total_references: number
|
||||||
|
avg_reference_per_item: number
|
||||||
|
hit_rate: number
|
||||||
|
injection_rate: number
|
||||||
|
positive_feedback_rate: number
|
||||||
|
stale_items_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItemsResponse {
|
||||||
|
items: KnowledgeItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Service ===
|
||||||
|
|
||||||
|
export const knowledgeService = {
|
||||||
|
// 分类
|
||||||
|
listCategories: (signal?: AbortSignal) =>
|
||||||
|
request.get<CategoryResponse[]>('/knowledge/categories', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
createCategory: (data: { name: string; description?: string; parent_id?: string; icon?: string }) =>
|
||||||
|
request.post('/knowledge/categories', data).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteCategory: (id: string) =>
|
||||||
|
request.delete(`/knowledge/categories/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
// 条目
|
||||||
|
listItems: (params: { page?: number; page_size?: number; category_id?: string; status?: string; keyword?: string }, signal?: AbortSignal) =>
|
||||||
|
request.get<ListItemsResponse>('/knowledge/items', withSignal({ params }, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getItem: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<KnowledgeItem>(`/knowledge/items/${id}`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
createItem: (data: {
|
||||||
|
category_id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
keywords?: string[]
|
||||||
|
related_questions?: string[]
|
||||||
|
priority?: number
|
||||||
|
tags?: string[]
|
||||||
|
}) => request.post('/knowledge/items', data).then((r) => r.data),
|
||||||
|
|
||||||
|
updateItem: (id: string, data: Record<string, unknown>) =>
|
||||||
|
request.put(`/knowledge/items/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteItem: (id: string) =>
|
||||||
|
request.delete(`/knowledge/items/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
batchCreate: (items: Array<{
|
||||||
|
category_id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
keywords?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
}>) => request.post('/knowledge/items/batch', items).then((r) => r.data),
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
search: (data: { query: string; category_id?: string; limit?: number }) =>
|
||||||
|
request.post<SearchResult[]>('/knowledge/search', data).then((r) => r.data),
|
||||||
|
|
||||||
|
// 分析
|
||||||
|
getOverview: (signal?: AbortSignal) =>
|
||||||
|
request.get<AnalyticsOverview>('/knowledge/analytics/overview', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getTrends: (signal?: AbortSignal) =>
|
||||||
|
request.get('/knowledge/analytics/trends', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getTopItems: (signal?: AbortSignal) =>
|
||||||
|
request.get('/knowledge/analytics/top-items', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user