Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- Billing (13 tests): plan cards, prices, limits, usage bars, payment flow - ScheduledTasks (16 tests): CRUD table, schedule/target types, color tags - Knowledge (12 tests): 4 tabs, items/categories/search/analytics panels - Roles (12 tests): roles + permission templates tabs - ConfigSync (8 tests): sync log viewer with action labels Fix: Knowledge.tsx missing </Select> and </Modal> closing tags (JSX parse error) Fix: tests/setup.ts added ResizeObserver mock for ProTable compatibility
753 lines
26 KiB
TypeScript
753 lines
26 KiB
TypeScript
// ============================================================
|
||
// 知识库管理
|
||
// ============================================================
|
||
|
||
import { useState, useMemo, useEffect } 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,
|
||
Table, Tooltip,
|
||
} from 'antd'
|
||
import {
|
||
PlusOutlined, SearchOutlined, BookOutlined, FolderOutlined,
|
||
DeleteOutlined, EditOutlined, EyeOutlined, BarChartOutlined,
|
||
HistoryOutlined, RollbackOutlined,
|
||
WarningOutlined,
|
||
} 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 [editItem, setEditItem] = useState<CategoryResponse | null>(null)
|
||
const [createForm] = Form.useForm()
|
||
const [editForm] = 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)
|
||
createForm.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 updateMutation = useMutation({
|
||
mutationFn: ({ id, ...data }: { id: string } & Record<string, unknown>) =>
|
||
knowledgeService.updateCategory(id, data),
|
||
onSuccess: () => {
|
||
message.success('分类已更新')
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||
setEditItem(null)
|
||
},
|
||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||
})
|
||
|
||
// 编辑弹窗打开时同步表单值(Ant Design Form initialValues 仅首次挂载生效)
|
||
useEffect(() => {
|
||
if (editItem) {
|
||
editForm.setFieldsValue({
|
||
name: editItem.name,
|
||
description: editItem.description,
|
||
parent_id: editItem.parent_id,
|
||
icon: editItem.icon,
|
||
})
|
||
}
|
||
}, [editItem, editForm])
|
||
|
||
// 获取当前编辑分类及其所有后代的 ID(防止循环引用)
|
||
const getDescendantIds = (id: string, cats: CategoryResponse[]): string[] => {
|
||
const ids: string[] = [id]
|
||
for (const c of cats) {
|
||
if (c.parent_id === id) {
|
||
ids.push(...getDescendantIds(c.id, cats))
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
const treeData = useMemo(
|
||
() => buildTreeData(categories, (id) => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: '删除后无法恢复,请确保分类下没有子分类和条目。',
|
||
okType: 'danger',
|
||
onOk: () => deleteMutation.mutate(id),
|
||
})
|
||
}, (id) => {
|
||
setEditItem(categories.find((c) => c.id === id) || null)
|
||
}),
|
||
[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); createForm.resetFields() }}
|
||
onOk={() => createForm.submit()}
|
||
confirmLoading={createMutation.isPending}
|
||
>
|
||
<Form form={createForm} 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>
|
||
|
||
{/* 编辑分类弹窗 */}
|
||
<Modal
|
||
title="编辑分类"
|
||
open={!!editItem}
|
||
onCancel={() => { setEditItem(null); editForm.resetFields() }}
|
||
onOk={() => editForm.submit()}
|
||
confirmLoading={updateMutation.isPending}
|
||
>
|
||
<Form
|
||
form={editForm}
|
||
layout="vertical"
|
||
initialValues={editItem ? { name: editItem.name, description: editItem.description, parent_id: editItem.parent_id, icon: editItem.icon } : undefined}
|
||
onFinish={(v) => editItem && updateMutation.mutate({ id: editItem.id, ...v })}
|
||
>
|
||
<Form.Item name="name" label="分类名称" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="description" label="描述">
|
||
<TextArea rows={2} />
|
||
</Form.Item>
|
||
<Form.Item name="parent_id" label="父分类">
|
||
<Select placeholder="无(顶级分类)" allowClear>
|
||
{editItem && flattenCategories(categories)
|
||
.filter((c) => !getDescendantIds(editItem.id, categories).includes(c.id))
|
||
.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 [versionModalOpen, setVersionModalOpen] = useState(false)
|
||
const [rollingBackVersion, setRollingBackVersion] = useState<number | 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: detailData, isLoading: detailLoading } = useQuery({
|
||
queryKey: ['knowledge-item-detail', detailItem],
|
||
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
|
||
enabled: !!detailItem,
|
||
})
|
||
|
||
const { data: versions } = useQuery({
|
||
queryKey: ['knowledge-item-versions', detailItem],
|
||
queryFn: ({ signal }) => knowledgeService.getVersions(detailItem!, signal),
|
||
enabled: !!detailItem,
|
||
})
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['knowledge-items', page, pageSize, filters],
|
||
queryFn: ({ signal }) =>
|
||
knowledgeService.listItems({ page, page_size: pageSize, ...filters }, signal),
|
||
})
|
||
|
||
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 rollbackMutation = useMutation({
|
||
mutationFn: ({ itemId, version }: { itemId: string; version: number }) =>
|
||
knowledgeService.rollbackVersion(itemId, version),
|
||
onSuccess: () => {
|
||
message.success('已回滚')
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge-item-detail'] })
|
||
queryClient.invalidateQueries({ queryKey: ['knowledge-item-versions'] })
|
||
setVersionModalOpen(false)
|
||
setRollingBackVersion(null)
|
||
},
|
||
onError: (err: Error) => {
|
||
message.error(err.message || '回滚失败')
|
||
setRollingBackVersion(null)
|
||
},
|
||
})
|
||
|
||
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: 'keyword',
|
||
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: 150,
|
||
search: false,
|
||
render: (_, r) => (
|
||
<Space>
|
||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(r.id)} />
|
||
<Tooltip title="版本历史">
|
||
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => { setDetailItem(r.id); setVersionModalOpen(true) }} />
|
||
</Tooltip>
|
||
<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({}); setPage(1) },
|
||
onSearch: (values) => { setFilters(values); setPage(1) },
|
||
}}
|
||
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); form.resetFields() }}
|
||
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 && !versionModalOpen}
|
||
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>
|
||
|
||
{/* 版本历史弹窗 */}
|
||
<Modal
|
||
title={`版本历史 - ${detailData?.title || ''}`}
|
||
open={versionModalOpen}
|
||
onCancel={() => { setVersionModalOpen(false); setDetailItem(null) }}
|
||
footer={null}
|
||
width={720}
|
||
>
|
||
<Table
|
||
dataSource={versions?.versions || []}
|
||
rowKey="id"
|
||
loading={!versions}
|
||
size="small"
|
||
pagination={{ pageSize: 10 }}
|
||
columns={[
|
||
{ title: '版本', dataIndex: 'version', width: 70 },
|
||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||
{ title: '摘要', dataIndex: 'change_summary', width: 200, ellipsis: true },
|
||
{ title: '创建者', dataIndex: 'created_by', width: 100 },
|
||
{ title: '创建时间', dataIndex: 'created_at', width: 160 },
|
||
{
|
||
title: '操作',
|
||
width: 80,
|
||
render: (_, r) => (
|
||
<Popconfirm
|
||
title={`确认回滚到版本 ${r.version}?`}
|
||
description="回滚将创建新版本,当前版本内容会被替换。"
|
||
onConfirm={() => {
|
||
setRollingBackVersion(r.version)
|
||
rollbackMutation.mutate({ itemId: detailItem!, version: r.version })
|
||
}}
|
||
>
|
||
<Button type="link" size="small" icon={<RollbackOutlined />} loading={rollingBackVersion === r.version}>
|
||
回滚
|
||
</Button>
|
||
</Popconfirm>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// === 搜索面板 ===
|
||
|
||
function SearchPanel() {
|
||
const [query, setQuery] = useState('')
|
||
const [results, setResults] = useState<SearchResult[]>([])
|
||
const [searching, setSearching] = useState(false)
|
||
const [hasSearched, setHasSearched] = useState(false)
|
||
|
||
const handleSearch = async () => {
|
||
if (!query.trim()) return
|
||
setSearching(true)
|
||
try {
|
||
const data = await knowledgeService.search({ query: query.trim(), limit: 10 })
|
||
setResults(data)
|
||
setHasSearched(true)
|
||
} catch {
|
||
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 && !hasSearched && (
|
||
<Empty description="输入关键词搜索知识库" />
|
||
)}
|
||
|
||
{results.length === 0 && !searching && hasSearched && (
|
||
<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: overviewLoading } = useQuery({
|
||
queryKey: ['knowledge-analytics'],
|
||
queryFn: ({ signal }) => knowledgeService.getOverview(signal),
|
||
})
|
||
|
||
const { data: trends } = useQuery({
|
||
queryKey: ['knowledge-trends'],
|
||
queryFn: ({ signal }) => knowledgeService.getTrends(signal),
|
||
})
|
||
|
||
const { data: topItems } = useQuery({
|
||
queryKey: ['knowledge-top-items'],
|
||
queryFn: ({ signal }) => knowledgeService.getTopItems(signal),
|
||
})
|
||
|
||
const { data: quality } = useQuery({
|
||
queryKey: ['knowledge-quality'],
|
||
queryFn: ({ signal }) => knowledgeService.getQuality(signal),
|
||
})
|
||
|
||
const { data: gaps } = useQuery({
|
||
queryKey: ['knowledge-gaps'],
|
||
queryFn: ({ signal }) => knowledgeService.getGaps(signal),
|
||
})
|
||
|
||
if (overviewLoading) 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>
|
||
|
||
{/* 趋势数据表格 */}
|
||
<Card title="检索趋势(近30天)" className="mt-4" size="small">
|
||
<Table
|
||
dataSource={trends?.trends || []}
|
||
rowKey="date"
|
||
loading={!trends}
|
||
size="small"
|
||
pagination={{ pageSize: 10 }}
|
||
columns={[
|
||
{ title: '日期', dataIndex: 'date', width: 120 },
|
||
{ title: '检索次数', dataIndex: 'count', width: 100 },
|
||
{ title: '注入次数', dataIndex: 'injected_count', width: 100 },
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
{/* Top Items 表格 */}
|
||
<Card title="高频引用 Top 20" className="mt-4" size="small">
|
||
<Table
|
||
dataSource={topItems?.items || []}
|
||
rowKey="id"
|
||
loading={!topItems}
|
||
size="small"
|
||
pagination={{ pageSize: 10 }}
|
||
columns={[
|
||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||
{ title: '分类', dataIndex: 'category', width: 120 },
|
||
{ title: '引用次数', dataIndex: 'ref_count', width: 100 },
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 质量指标 */}
|
||
{quality?.categories?.length > 0 && (
|
||
<Card title="分类质量指标" className="mt-4" size="small">
|
||
<Table
|
||
dataSource={quality.categories}
|
||
rowKey="category"
|
||
size="small"
|
||
pagination={false}
|
||
columns={[
|
||
{ title: '分类', dataIndex: 'category', width: 150 },
|
||
{ title: '总条目', dataIndex: 'total', width: 80 },
|
||
{ title: '活跃', dataIndex: 'active', width: 80 },
|
||
{ title: '有关键词', dataIndex: 'with_keywords', width: 100 },
|
||
{ title: '平均优先级', dataIndex: 'avg_priority', width: 100, render: (v: number) => v?.toFixed(1) },
|
||
]}
|
||
/>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 知识缺口 */}
|
||
{gaps?.gaps?.length > 0 && (
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<WarningOutlined style={{ color: '#faad14' }} />
|
||
<span>知识缺口检测</span>
|
||
</Space>
|
||
}
|
||
className="mt-4"
|
||
size="small"
|
||
>
|
||
<Table
|
||
dataSource={gaps.gaps}
|
||
rowKey="query"
|
||
size="small"
|
||
pagination={{ pageSize: 10 }}
|
||
columns={[
|
||
{ title: '查询', dataIndex: 'query', ellipsis: true },
|
||
{ title: '次数', dataIndex: 'count', width: 80 },
|
||
{ title: '平均分', dataIndex: 'avg_score', width: 100, render: (v: number) => v?.toFixed(2) },
|
||
]}
|
||
/>
|
||
</Card>
|
||
)}
|
||
</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, onEdit: (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" icon={<EditOutlined />} onClick={() => onEdit(c.id)} />
|
||
<Button type="link" size="small" danger onClick={() => onDelete(c.id)}>
|
||
<DeleteOutlined />
|
||
</Button>
|
||
</div>
|
||
),
|
||
children: c.children?.length ? buildTreeData(c.children, onDelete, onEdit) : undefined,
|
||
}))
|
||
}
|