实现知识库两级分类树与完整 CRUD

支持父子分类、展开侧栏与改删校验;选中父分类可筛子级条目;对齐左右顶栏并更新种子树形数据。
This commit is contained in:
yml2213
2026-07-15 13:23:23 +08:00
parent 2cfdacacb3
commit 4c298fc38d
5 changed files with 560 additions and 91 deletions
+315 -64
View File
@@ -5,14 +5,17 @@ import {
import {
SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FolderAddOutlined,
CopyOutlined, FileTextOutlined, AppstoreOutlined, ThunderboltOutlined,
RightOutlined, DownOutlined, PlusCircleOutlined,
} from '@ant-design/icons'
import {
createKnowledgeCategory, createKnowledgeEntry, deleteKnowledgeEntry,
getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeEntry,
createKnowledgeCategory, createKnowledgeEntry, deleteKnowledgeCategory, deleteKnowledgeEntry,
getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeCategory, updateKnowledgeEntry,
type KnowledgeCategory, type KnowledgeEntry,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
type CatNode = KnowledgeCategory & { children: KnowledgeCategory[] }
const statusStyle = {
published: { label: '已发布', bg: '#f0fdf4', color: '#16a34a', dot: '#16a34a' },
draft: { label: '草稿', bg: '#f1f5f9', color: '#64748b', dot: '#94a3b8' },
@@ -59,9 +62,13 @@ const Knowledge = () => {
const [sortBy, setSortBy] = useState<'usage' | 'updated' | 'title'>('usage')
const [modalOpen, setModalOpen] = useState(false)
const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<KnowledgeCategory | null>(null)
const [categoryParentId, setCategoryParentId] = useState<number | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set())
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
const [detailEntry, setDetailEntry] = useState<KnowledgeEntry | null>(null)
const [saving, setSaving] = useState(false)
const [savingCat, setSavingCat] = useState(false)
const [form] = Form.useForm()
const [categoryForm] = Form.useForm()
const [total, setTotal] = useState(0)
@@ -79,13 +86,29 @@ const Knowledge = () => {
const loadCategories = async () => {
try {
const res = await getKnowledgeCategories()
const data = res.data
const list = Array.isArray(data?.list) ? data.list : []
const data = res.data as { list?: KnowledgeCategory[]; total_entries?: number } | KnowledgeCategory[] | null
// 兼容 { list, total_entries } 与旧版直接返回数组
const list = Array.isArray(data)
? data
: (Array.isArray(data?.list) ? data.list : [])
setCategories(list)
setTotalEntries(data?.total_entries ?? list.reduce((s, c) => s + (c.entry_count || 0), 0))
const fromApi = !Array.isArray(data) ? data?.total_entries : undefined
setTotalEntries(
typeof fromApi === 'number'
? fromApi
: list.reduce((s, c) => s + (c.total_count || c.entry_count || 0), 0),
)
// 有子节点的一级分类默认展开(对齐设计稿)
const parentsWithKids = new Set<number>()
list.forEach(c => {
if (c.parent_id) parentsWithKids.add(c.parent_id)
})
setExpandedIds(prev => {
if (prev.size > 0) return prev
return parentsWithKids
})
} catch {
setCategories([])
setTotalEntries(0)
}
}
@@ -102,6 +125,10 @@ const Knowledge = () => {
})
setEntries(res.list || [])
setTotal(res.total || 0)
// 未选分类时用列表 total 同步侧栏「全部」计数
if (!selectedCategory && !search && !statusFilter) {
setTotalEntries(res.total || 0)
}
} catch {
setEntries([])
setTotal(0)
@@ -110,11 +137,49 @@ const Knowledge = () => {
}
}
const filteredCategories = useMemo(() => {
const categoryTree = useMemo(() => {
const roots: CatNode[] = []
const map = new Map<number, CatNode>()
categories.forEach(c => map.set(c.id, { ...c, children: [] }))
map.forEach(node => {
if (node.parent_id && map.has(node.parent_id)) {
map.get(node.parent_id)!.children.push(node)
} else if (!node.parent_id) {
roots.push(node)
} else {
// 父级缺失时当根展示
roots.push(node)
}
})
return roots
}, [categories])
const filteredTree = useMemo(() => {
const q = catSearch.trim().toLowerCase()
if (!q) return categories
return categories.filter(c => c.name.toLowerCase().includes(q))
}, [categories, catSearch])
if (!q) return categoryTree
const match = (c: KnowledgeCategory) => c.name.toLowerCase().includes(q)
return categoryTree
.map(root => {
const children = root.children.filter(match)
if (match(root) || children.length > 0) {
return { ...root, children: match(root) && children.length === 0 ? root.children : children }
}
return null
})
.filter(Boolean) as CatNode[]
}, [categoryTree, catSearch])
// 搜索时自动展开命中父级
useEffect(() => {
if (!catSearch.trim()) return
setExpandedIds(prev => {
const next = new Set(prev)
filteredTree.forEach(r => {
if (r.children.length > 0) next.add(r.id)
})
return next
})
}, [catSearch, filteredTree])
const maxUsage = useMemo(
() => Math.max(1, ...entries.map(e => e.usage_count || 0), 10),
@@ -123,6 +188,98 @@ const Knowledge = () => {
const categoryName = (id: number) => categories.find(c => c.id === id)?.name || '未分类'
/** 条目表单:可选分类(叶子优先,含一级) */
const categorySelectOptions = useMemo(() => {
const opts: { value: number; label: string }[] = []
categoryTree.forEach(root => {
if (root.children.length === 0) {
opts.push({ value: root.id, label: root.name })
} else {
root.children.forEach(ch => {
opts.push({ value: ch.id, label: `${root.name} / ${ch.name}` })
})
opts.push({ value: root.id, label: `${root.name}(本级)` })
}
})
return opts
}, [categoryTree])
const rootCategoryOptions = useMemo(
() => categories.filter(c => !c.parent_id).map(c => ({ value: c.id, label: c.name })),
[categories],
)
const toggleExpand = (id: number, e: { stopPropagation: () => void }) => {
e.stopPropagation()
setExpandedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const openCreateCategory = (parentId: number | null = null) => {
setEditingCategory(null)
setCategoryParentId(parentId)
categoryForm.resetFields()
categoryForm.setFieldsValue({ name: '', parent_id: parentId })
setCategoryModalOpen(true)
}
const openEditCategory = (cat: KnowledgeCategory, e?: { stopPropagation: () => void }) => {
e?.stopPropagation()
setEditingCategory(cat)
setCategoryParentId(cat.parent_id)
categoryForm.setFieldsValue({ name: cat.name, parent_id: cat.parent_id })
setCategoryModalOpen(true)
}
const handleDeleteCategory = async (cat: KnowledgeCategory) => {
try {
await deleteKnowledgeCategory(cat.id)
message.success('分类已删除')
if (selectedCategory === String(cat.id)) {
setSelectedCategory('')
setPage(1)
}
await loadCategories()
await loadEntries()
} catch (err) {
message.error(err instanceof Error ? err.message : '删除分类失败')
}
}
const handleSaveCategory = async (values: { name: string; parent_id?: number | null }) => {
setSavingCat(true)
try {
const parent_id = values.parent_id ?? null
if (editingCategory) {
await updateKnowledgeCategory(editingCategory.id, {
name: values.name.trim(),
parent_id,
})
message.success('分类已更新')
} else {
await createKnowledgeCategory({
name: values.name.trim(),
parent_id: parent_id || null,
})
message.success('分类已创建')
if (parent_id) {
setExpandedIds(prev => new Set(prev).add(parent_id))
}
}
setCategoryModalOpen(false)
categoryForm.resetFields()
await loadCategories()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存分类失败')
} finally {
setSavingCat(false)
}
}
const openCreate = () => {
setEditingEntry(null)
form.resetFields()
@@ -183,18 +340,6 @@ const Knowledge = () => {
}
}
const handleCreateCategory = async (values: { name: string }) => {
try {
await createKnowledgeCategory({ name: values.name.trim() })
message.success('分类已创建')
setCategoryModalOpen(false)
categoryForm.resetFields()
await loadCategories()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建分类失败')
}
}
const handleSearch = () => {
setPage(1)
setSearch(searchInput.trim())
@@ -203,17 +348,62 @@ const Knowledge = () => {
const catIcon = (name: string) => {
if (name.includes('快捷')) return <ThunderboltOutlined className="text-sm" />
if (name.includes('政策') || name.includes('条款')) return <FileTextOutlined className="text-sm" />
if (name.includes('售后')) return <FileTextOutlined className="text-sm" />
if (name.includes('技术')) return <AppstoreOutlined className="text-sm" />
return <AppstoreOutlined className="text-sm" />
}
const renderCatActions = (cat: KnowledgeCategory, isRoot: boolean) => {
if (!canManage) return null
return (
<span className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={e => e.stopPropagation()}>
{isRoot && (
<button
type="button"
title="添加子分类"
className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={e => { e.stopPropagation(); openCreateCategory(cat.id) }}
>
<PlusCircleOutlined className="text-xs" />
</button>
)}
<button
type="button"
title="编辑"
className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
onClick={e => openEditCategory(cat, e)}
>
<EditOutlined className="text-xs" />
</button>
<Popconfirm
title="确认删除该分类?"
description="需无子分类且无条目"
onConfirm={() => handleDeleteCategory(cat)}
>
<button
type="button"
title="删除"
className="w-6 h-6 rounded flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer"
onClick={e => e.stopPropagation()}
>
<DeleteOutlined className="text-xs" />
</button>
</Popconfirm>
</span>
)
}
return (
<div className="h-full flex min-h-0 overflow-hidden bg-neutral-50">
{/* 左侧分类 280px */}
{/* 左侧分类 280px — 顶栏固定 56px 与右侧对齐 */}
<aside className="w-[280px] shrink-0 flex flex-col bg-white border-r border-neutral-200">
<div className="shrink-0 p-4 border-b border-neutral-200">
<div className="text-[15px] font-semibold text-neutral-900 mb-3"></div>
<div className="h-8 flex items-center gap-2 px-3 rounded-md bg-neutral-50 border border-neutral-200">
<SearchOutlined className="text-neutral-400 text-xs shrink-0" />
<div
className="shrink-0 px-3 flex items-center gap-2 border-b border-neutral-200 bg-white"
style={{ height: 'var(--header-height)' }}
>
<span className="text-sm font-semibold text-neutral-900 whitespace-nowrap shrink-0"></span>
<div className="flex items-center flex-1 min-w-0 h-8 px-2.5 rounded-md bg-neutral-50 border border-neutral-200">
<SearchOutlined className="text-neutral-400 text-xs mr-1.5 shrink-0" />
<input
type="text"
placeholder="搜索分类..."
@@ -243,29 +433,72 @@ const Knowledge = () => {
</span>
</button>
{filteredCategories.map(cat => {
const active = selectedCategory === String(cat.id)
{filteredTree.map(root => {
const hasChildren = root.children.length > 0
const expanded = expandedIds.has(root.id) || (!!catSearch.trim() && hasChildren)
const active = selectedCategory === String(root.id)
const count = root.total_count ?? root.entry_count ?? 0
return (
<button
key={cat.id}
type="button"
onClick={() => { setSelectedCategory(String(cat.id)); setPage(1) }}
className={`w-full flex items-center gap-2 px-4 py-2 text-sm border-0 cursor-pointer text-left ${
active
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
: 'bg-transparent text-neutral-700 hover:bg-neutral-50'
}`}
>
<span className="shrink-0 text-neutral-400">{catIcon(cat.name)}</span>
<span className="flex-1 truncate">{cat.name}</span>
<span className={`text-xs ${active ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
{cat.entry_count ?? 0}
</span>
</button>
<div key={root.id}>
<div
role="button"
tabIndex={0}
onClick={() => { setSelectedCategory(String(root.id)); setPage(1) }}
onKeyDown={e => { if (e.key === 'Enter') { setSelectedCategory(String(root.id)); setPage(1) } }}
className={`group w-full flex items-center gap-1.5 px-3 py-2 text-sm cursor-pointer ${
active
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
: 'text-neutral-700 hover:bg-neutral-50'
}`}
>
<button
type="button"
className="w-4 h-4 flex items-center justify-center shrink-0 border-0 bg-transparent p-0 cursor-pointer text-neutral-400"
onClick={e => {
if (hasChildren) toggleExpand(root.id, e)
else e.stopPropagation()
}}
>
{hasChildren
? (expanded ? <DownOutlined className="text-[10px]" /> : <RightOutlined className="text-[10px]" />)
: <span className="w-2" />}
</button>
<span className="shrink-0 text-neutral-400">{catIcon(root.name)}</span>
<span className="flex-1 truncate text-left">{root.name}</span>
{renderCatActions(root, true)}
<span className={`text-xs tabular-nums ${active ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
{count}
</span>
</div>
{hasChildren && expanded && root.children.map(child => {
const childActive = selectedCategory === String(child.id)
return (
<div
key={child.id}
role="button"
tabIndex={0}
onClick={() => { setSelectedCategory(String(child.id)); setPage(1) }}
onKeyDown={e => { if (e.key === 'Enter') { setSelectedCategory(String(child.id)); setPage(1) } }}
className={`group w-full flex items-center gap-1.5 pl-9 pr-3 py-1.5 text-xs cursor-pointer ${
childActive
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
: 'text-neutral-600 hover:bg-neutral-50'
}`}
>
<span className="shrink-0 w-3.5 text-neutral-300">·</span>
<span className="flex-1 truncate text-left">{child.name}</span>
{renderCatActions(child, false)}
<span className={`tabular-nums ${childActive ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
{child.entry_count ?? 0}
</span>
</div>
)
})}
</div>
)
})}
{filteredCategories.length === 0 && (
{filteredTree.length === 0 && (
<div className="text-center text-xs text-neutral-400 py-8"></div>
)}
</div>
@@ -274,7 +507,7 @@ const Knowledge = () => {
<div className="shrink-0 p-3 border-t border-neutral-200">
<button
type="button"
onClick={() => setCategoryModalOpen(true)}
onClick={() => openCreateCategory(null)}
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white text-xs text-neutral-600 hover:bg-neutral-50 cursor-pointer"
>
<FolderAddOutlined />
@@ -286,9 +519,12 @@ const Knowledge = () => {
{/* 右侧列表 */}
<section className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* 工具栏 */}
<div className="shrink-0 px-6 py-4 border-b border-neutral-200 bg-white flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-1 max-w-md h-8 px-3 rounded-md border border-neutral-200 bg-white">
{/* 工具栏 — 与左侧顶栏同高 56px */}
<div
className="shrink-0 px-5 flex items-center gap-3 border-b border-neutral-200 bg-white"
style={{ height: 'var(--header-height)' }}
>
<div className="flex items-center gap-2 flex-1 max-w-md h-8 px-3 rounded-md border border-neutral-200 bg-white min-w-0">
<SearchOutlined className="text-neutral-400 text-xs shrink-0" />
<input
type="text"
@@ -299,7 +535,7 @@ const Knowledge = () => {
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400"
/>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-neutral-500 whitespace-nowrap">:</span>
<Select
allowClear
@@ -314,7 +550,7 @@ const Knowledge = () => {
]}
/>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-neutral-500 whitespace-nowrap">:</span>
<Select
value={sortBy}
@@ -328,17 +564,17 @@ const Knowledge = () => {
]}
/>
</div>
<div className="flex-1" />
<span className="text-xs text-neutral-400 whitespace-nowrap"> {total} </span>
<div className="flex-1 min-w-2" />
<span className="text-xs text-neutral-400 whitespace-nowrap shrink-0"> {total} </span>
{canManage && (
<Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm" onClick={openCreate}>
<Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm shrink-0" onClick={openCreate}>
</Button>
)}
</div>
{/* 表头 */}
<div className="shrink-0 flex items-center px-6 py-2 bg-neutral-100 border-b border-neutral-200 text-[11px] font-medium text-neutral-500">
{/* 表头 — 与分类列表顶对齐 */}
<div className="shrink-0 flex items-center px-5 h-9 bg-neutral-100 border-b border-neutral-200 text-[11px] font-medium text-neutral-500">
<div className="w-[40%] pl-3"></div>
<div className="w-[12%] text-center"></div>
<div className="w-[10%] text-center"></div>
@@ -366,7 +602,7 @@ const Knowledge = () => {
tabIndex={0}
onClick={() => setDetailEntry(entry)}
onKeyDown={e => { if (e.key === 'Enter') setDetailEntry(entry) }}
className="flex items-center px-6 py-3 border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer transition-colors"
className="flex items-center px-5 py-3 border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer transition-colors"
>
<div className="w-[40%] pl-3 min-w-0">
<div className="text-sm font-medium text-neutral-900 truncate">{entry.title}</div>
@@ -450,7 +686,7 @@ const Knowledge = () => {
</div>
{total > 0 && (
<div className="shrink-0 px-6 py-3 border-t border-neutral-200 bg-white flex justify-end">
<div className="shrink-0 px-5 py-3 border-t border-neutral-200 bg-white flex justify-end">
<Pagination
current={page}
total={total}
@@ -539,8 +775,10 @@ const Knowledge = () => {
</Form.Item>
<Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
<Select
options={categories.map(c => ({ value: c.id, label: c.name }))}
options={categorySelectOptions}
placeholder={categories.length ? '选择分类' : '请先创建分类'}
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }, { max: 5000 }]}>
@@ -553,16 +791,29 @@ const Knowledge = () => {
</Modal>
<Modal
title="新建分类"
title={editingCategory ? '编辑分类' : (categoryParentId ? '新建子分类' : '新建分类')}
open={categoryModalOpen}
onCancel={() => setCategoryModalOpen(false)}
onOk={() => categoryForm.submit()}
confirmLoading={savingCat}
destroyOnClose
okText="创建"
okText="保存"
>
<Form form={categoryForm} layout="vertical" onFinish={handleCreateCategory} className="mt-2">
<Form form={categoryForm} layout="vertical" onFinish={handleSaveCategory} className="mt-2">
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入名称' }, { min: 2, max: 30 }]}>
<Input maxLength={30} placeholder="如:售后服务" />
<Input maxLength={30} placeholder="如:售后服务、账户相关" />
</Form.Item>
<Form.Item
name="parent_id"
label="上级分类"
extra="留空为一级分类;选择后成为其子分类(最多两级)"
>
<Select
allowClear
placeholder="无(一级分类)"
options={rootCategoryOptions.filter(o => !editingCategory || o.value !== editingCategory.id)}
disabled={!!categoryParentId && !editingCategory}
/>
</Form.Item>
</Form>
</Modal>
+8 -1
View File
@@ -39,7 +39,10 @@ export interface Customer {
export interface KnowledgeCategory {
id: number; tenant_id: number; parent_id: number | null; name: string
/** 本分类直属条目数 */
entry_count?: number
/** 含子分类汇总 */
total_count?: number
}
export interface KnowledgeEntry {
@@ -185,7 +188,11 @@ export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// Knowledge
export const getKnowledgeCategories = () =>
get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories')
export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post<KnowledgeCategory>('/knowledge/categories', data)
export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) =>
post<KnowledgeCategory>('/knowledge/categories', data)
export const updateKnowledgeCategory = (id: number, data: { name: string; parent_id?: number | null }) =>
put<KnowledgeCategory>(`/knowledge/categories/${id}`, data)
export const deleteKnowledgeCategory = (id: number) => del(`/knowledge/categories/${id}`)
export const getKnowledgeEntries = (params?: {
category_id?: string
search?: string