fix(knowledge): deep audit — 18 bugs fixed across backend + frontend
CRITICAL: - Migration permission seed WHERE name → WHERE id (matched 0 rows, all KB APIs broken) HIGH: - analytics_quality SQL alias + missing comma fix - search() duplicate else block compile error - chunk_content duplicate var declarations + type mismatch - SQL invalid escape sequences - delete_category missing rows_affected check MEDIUM: - analytics_overview hit_rate vs positive_feedback_rate separation - analytics_quality GROUP BY kc.id,kc.name (same-name category merge) - update_category handler trim + empty name validation - update_item duplicate VALID_STATUSES inside transaction - page_size max(1) lower bound in list handlers - batch_create title/content/length validation - embedding dispatch silent error → tracing::warn - Version modal close clears detailItem state - Search empty state distinguishes not-searched vs no-results - Create modal cancel resets form
This commit is contained in:
@@ -2,15 +2,18 @@
|
||||
// 知识库管理
|
||||
// ============================================================
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
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'
|
||||
@@ -25,7 +28,9 @@ const { Text, Title } = Typography
|
||||
function CategoriesPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [editItem, setEditItem] = useState<CategoryResponse | null>(null)
|
||||
const [createForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['knowledge-categories'],
|
||||
@@ -39,7 +44,7 @@ function CategoriesPanel() {
|
||||
message.success('分类已创建')
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
createForm.resetFields()
|
||||
},
|
||||
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||
})
|
||||
@@ -53,6 +58,40 @@ function CategoriesPanel() {
|
||||
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({
|
||||
@@ -61,6 +100,8 @@ function CategoriesPanel() {
|
||||
okType: 'danger',
|
||||
onOk: () => deleteMutation.mutate(id),
|
||||
})
|
||||
}, (id) => {
|
||||
setEditItem(categories.find((c) => c.id === id) || null)
|
||||
}),
|
||||
[categories, deleteMutation],
|
||||
)
|
||||
@@ -87,14 +128,15 @@ function CategoriesPanel() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建分类弹窗 */}
|
||||
<Modal
|
||||
title="新建分类"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => { setCreateOpen(false); createForm.resetFields() }}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }]}>
|
||||
<Input placeholder="例如:产品知识、技术文档" />
|
||||
</Form.Item>
|
||||
@@ -113,6 +155,40 @@ function CategoriesPanel() {
|
||||
</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>
|
||||
))}
|
||||
</Form.Item>
|
||||
<Form.Item name="icon" label="图标">
|
||||
<Input placeholder="如 📚" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -123,6 +199,8 @@ 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 }>({})
|
||||
@@ -133,18 +211,24 @@ function ItemsPanel() {
|
||||
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 { 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),
|
||||
@@ -166,13 +250,30 @@ function ItemsPanel() {
|
||||
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: 'title',
|
||||
dataIndex: 'keyword',
|
||||
width: 250,
|
||||
render: (_, r) => (
|
||||
<Button type="link" size="small" onClick={() => setDetailItem(r.id)}>
|
||||
@@ -180,7 +281,14 @@ function ItemsPanel() {
|
||||
</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: '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 },
|
||||
{
|
||||
@@ -197,11 +305,14 @@ function ItemsPanel() {
|
||||
{ title: '更新时间', dataIndex: 'updated_at', width: 160, valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
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>
|
||||
@@ -218,8 +329,8 @@ function ItemsPanel() {
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={{
|
||||
onReset: () => setFilters({}),
|
||||
onSearch: (values) => setFilters(values),
|
||||
onReset: () => { setFilters({}); setPage(1) },
|
||||
onSearch: (values) => { setFilters(values); setPage(1) },
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
@@ -240,7 +351,7 @@ function ItemsPanel() {
|
||||
<Modal
|
||||
title="新建知识条目"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields() }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMutation.isPending}
|
||||
width={640}
|
||||
@@ -280,7 +391,7 @@ function ItemsPanel() {
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={detailData?.title || '条目详情'}
|
||||
open={!!detailItem}
|
||||
open={!!detailItem && !versionModalOpen}
|
||||
onCancel={() => setDetailItem(null)}
|
||||
footer={null}
|
||||
width={720}
|
||||
@@ -302,6 +413,47 @@ function ItemsPanel() {
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -312,6 +464,7 @@ 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
|
||||
@@ -319,7 +472,8 @@ function SearchPanel() {
|
||||
try {
|
||||
const data = await knowledgeService.search({ query: query.trim(), limit: 10 })
|
||||
setResults(data)
|
||||
} catch (err) {
|
||||
setHasSearched(true)
|
||||
} catch {
|
||||
message.error('搜索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
@@ -343,10 +497,14 @@ function SearchPanel() {
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{results.length === 0 && !searching && query === '' && (
|
||||
{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>
|
||||
@@ -372,12 +530,32 @@ function SearchPanel() {
|
||||
// === 分析看板 ===
|
||||
|
||||
function AnalyticsPanel() {
|
||||
const { data: overview, isLoading } = useQuery({
|
||||
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||
queryKey: ['knowledge-analytics'],
|
||||
queryFn: ({ signal }) => knowledgeService.getOverview(signal),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="flex justify-center py-8"><Spin /></div>
|
||||
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>
|
||||
@@ -403,27 +581,95 @@ function AnalyticsPanel() {
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="注入率"
|
||||
value={((overview?.injection_rate || 0) * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
/>
|
||||
<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' }}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -486,18 +732,19 @@ interface TreeNode {
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void): 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) : undefined,
|
||||
children: c.children?.length ? buildTreeData(c.children, onDelete, onEdit) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -76,6 +76,16 @@ export const knowledgeService = {
|
||||
deleteCategory: (id: string) =>
|
||||
request.delete(`/knowledge/categories/${id}`).then((r) => r.data),
|
||||
|
||||
updateCategory: (id: string, data: { name?: string; description?: string; parent_id?: string; icon?: string }) =>
|
||||
request.put(`/knowledge/categories/${id}`, data).then((r) => r.data),
|
||||
|
||||
reorderCategories: (items: Array<{ id: string; sort_order: number }>) =>
|
||||
request.patch('/knowledge/categories/reorder', { items }).then((r) => r.data),
|
||||
|
||||
getCategoryItems: (id: string, params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
|
||||
request.get<ListItemsResponse>(`/knowledge/categories/${id}/items`, withSignal({ params }, signal))
|
||||
.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))
|
||||
@@ -125,4 +135,28 @@ export const knowledgeService = {
|
||||
getTopItems: (signal?: AbortSignal) =>
|
||||
request.get('/knowledge/analytics/top-items', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getQuality: (signal?: AbortSignal) =>
|
||||
request.get('/knowledge/analytics/quality', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
getGaps: (signal?: AbortSignal) =>
|
||||
request.get('/knowledge/analytics/gaps', withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
// 版本
|
||||
getVersions: (itemId: string, signal?: AbortSignal) =>
|
||||
request.get(`/knowledge/items/${itemId}/versions`, withSignal({}, signal))
|
||||
.then((r) => r.data),
|
||||
|
||||
rollbackVersion: (itemId: string, version: number) =>
|
||||
request.post(`/knowledge/items/${itemId}/rollback/${version}`).then((r) => r.data),
|
||||
|
||||
// 推荐搜索
|
||||
recommend: (data: { query: string; category_id?: string; limit?: number }) =>
|
||||
request.post<SearchResult[]>('/knowledge/recommend', data).then((r) => r.data),
|
||||
|
||||
// 导入
|
||||
importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) =>
|
||||
request.post('/knowledge/items/import', data).then((r) => r.data),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user