From 4c298fc38d38f5cc24f5fabbeb17fa25c0410021 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 13:23:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E4=B8=A4=E7=BA=A7=E5=88=86=E7=B1=BB=E6=A0=91=E4=B8=8E=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持父子分类、展开侧栏与改删校验;选中父分类可筛子级条目;对齐左右顶栏并更新种子树形数据。 --- server/cmd/seed/main.go | 38 ++- server/internal/handler/knowledge.go | 223 +++++++++++++++- server/internal/handler/router.go | 2 + web/src/pages/agent/Knowledge.tsx | 379 ++++++++++++++++++++++----- web/src/services/api.ts | 9 +- 5 files changed, 560 insertions(+), 91 deletions(-) diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index 2b87447..4672bc9 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -125,20 +125,32 @@ func seed() { } model.DB.Create(&messages) - // Knowledge categories & entries - cats := []model.Category{ - {TenantID: tenants[0].ID, Name: "产品常见问题"}, - {TenantID: tenants[0].ID, Name: "售后服务"}, - {TenantID: tenants[0].ID, Name: "技术支持"}, - {TenantID: tenants[0].ID, Name: "快捷回复模板"}, - } - model.DB.Create(&cats) + // Knowledge categories(两级树,对齐设计稿) + tid := tenants[0].ID + rootFAQ := model.Category{TenantID: tid, Name: "产品常见问题"} + rootAfter := model.Category{TenantID: tid, Name: "售后服务"} + rootTech := model.Category{TenantID: tid, Name: "技术支持"} + rootPolicy := model.Category{TenantID: tid, Name: "政策条款"} + rootQuick := model.Category{TenantID: tid, Name: "快捷回复模板"} + model.DB.Create(&rootFAQ) + model.DB.Create(&rootAfter) + model.DB.Create(&rootTech) + model.DB.Create(&rootPolicy) + model.DB.Create(&rootQuick) + subAccount := model.Category{TenantID: tid, Name: "账户相关", ParentID: &rootFAQ.ID} + subFeature := model.Category{TenantID: tid, Name: "功能使用", ParentID: &rootFAQ.ID} + subBilling := model.Category{TenantID: tid, Name: "计费问题", ParentID: &rootFAQ.ID} + model.DB.Create(&subAccount) + model.DB.Create(&subFeature) + model.DB.Create(&subBilling) entries := []model.KnowledgeEntry{ - {TenantID: tenants[0].ID, CategoryID: cats[0].ID, Title: "如何修改登录密码", Content: "登录后在右上角头像→个人设置→修改密码", Status: "published", UsageCount: 156}, - {TenantID: tenants[0].ID, CategoryID: cats[0].ID, Title: "支持哪些支付方式", Content: "支持微信支付、支付宝、银行转账", Status: "published", UsageCount: 98}, - {TenantID: tenants[0].ID, CategoryID: cats[1].ID, Title: "退货流程说明", Content: "在线申请→审核→寄回→退款,全程3-5个工作日", Status: "published", UsageCount: 45}, - {TenantID: tenants[0].ID, CategoryID: cats[2].ID, Title: "API接口文档", Content: "开发者文档请访问 docs.example.com/api", Status: "draft", UsageCount: 0}, - {TenantID: tenants[0].ID, CategoryID: cats[3].ID, Title: "欢迎语模板", Content: "您好!欢迎来到客服云,请问有什么可以帮您的?", Status: "published", UsageCount: 230}, + {TenantID: tid, CategoryID: subAccount.ID, Title: "如何修改登录密码", Content: "登录后在右上角头像→个人设置→修改密码。也可通过手机验证码或邮箱链接重置。", Status: "published", UsageCount: 156}, + {TenantID: tid, CategoryID: subBilling.ID, Title: "支持哪些支付方式", Content: "支持微信支付、支付宝、银行转账", Status: "published", UsageCount: 98}, + {TenantID: tid, CategoryID: subFeature.ID, Title: "如何升级为高级会员", Content: "进入账户设置页面,选择会员升级选项,支持月付和年付两种方式,年付享8折优惠", Status: "published", UsageCount: 88}, + {TenantID: tid, CategoryID: rootAfter.ID, Title: "退货流程说明", Content: "在线申请→审核→寄回→退款,全程3-5个工作日", Status: "published", UsageCount: 45}, + {TenantID: tid, CategoryID: rootTech.ID, Title: "API接口文档", Content: "开发者文档请访问 docs.example.com/api", Status: "draft", UsageCount: 0}, + {TenantID: tid, CategoryID: rootPolicy.ID, Title: "隐私政策更新说明", Content: "我们会定期更新隐私政策,重大变更将通过站内信通知", Status: "published", UsageCount: 12}, + {TenantID: tid, CategoryID: rootQuick.ID, Title: "欢迎语模板", Content: "您好!欢迎来到客服云,请问有什么可以帮您的?", Status: "published", UsageCount: 230}, } model.DB.Create(&entries) diff --git a/server/internal/handler/knowledge.go b/server/internal/handler/knowledge.go index 32c0f42..59f38a9 100644 --- a/server/internal/handler/knowledge.go +++ b/server/internal/handler/knowledge.go @@ -2,6 +2,9 @@ package handler import ( "net/http" + "strconv" + "strings" + "unicode/utf8" "github.com/gin-gonic/gin" "kefu-sys/server/internal/middleware" @@ -44,7 +47,13 @@ func hasKnowledgeCapacity(tenantID uint) (bool, error) { type CategoryListItem struct { model.Category - EntryCount int64 `json:"entry_count"` + EntryCount int64 `json:"entry_count"` // 本分类直属条目数 + TotalCount int64 `json:"total_count"` // 含子分类汇总 +} + +type CategoryReq struct { + Name string `json:"name" binding:"required"` + ParentID *uint `json:"parent_id"` } func (h *KnowledgeHandler) ListCategories(c *gin.Context) { @@ -63,40 +72,223 @@ func (h *KnowledgeHandler) ListCategories(c *gin.Context) { Where("tenant_id = ?", tenantID). Group("category_id"). Scan(&rows) - countMap := map[uint]int64{} - var total int64 + directMap := map[uint]int64{} for _, r := range rows { - countMap[r.CategoryID] = r.Cnt - total += r.Cnt + directMap[r.CategoryID] = r.Cnt } + // 子分类映射 parent -> children + childrenMap := map[uint][]uint{} + for _, cat := range categories { + if cat.ParentID != nil { + childrenMap[*cat.ParentID] = append(childrenMap[*cat.ParentID], cat.ID) + } + } + var rollup func(id uint) int64 + rollup = func(id uint) int64 { + sum := directMap[id] + for _, child := range childrenMap[id] { + sum += rollup(child) + } + return sum + } + + var total int64 + model.DB.Model(&model.KnowledgeEntry{}).Where("tenant_id = ?", tenantID).Count(&total) items := make([]CategoryListItem, 0, len(categories)) for _, cat := range categories { - items = append(items, CategoryListItem{Category: cat, EntryCount: countMap[cat.ID]}) + items = append(items, CategoryListItem{ + Category: cat, + EntryCount: directMap[cat.ID], + TotalCount: rollup(cat.ID), + }) } - // total 放在额外字段便于侧栏「全部知识库」 middleware.JSON(c, gin.H{"list": items, "total_entries": total}) } +func validateCategoryName(name string) string { + name = strings.TrimSpace(name) + n := utf8.RuneCountInString(name) + if n < 2 || n > 30 { + return "" + } + return name +} + +// 校验父分类:最多两级(父分类自身 parent_id 必须为空) +func validateCategoryParent(tenantID uint, parentID *uint) error { + if parentID == nil { + return nil + } + var parent model.Category + if err := model.DB.Where("id = ? AND tenant_id = ?", *parentID, tenantID).First(&parent).Error; err != nil { + return errParentNotFound + } + if parent.ParentID != nil { + return errCategoryDepth + } + return nil +} + +var ( + errParentNotFound = errCat("父分类不存在") + errCategoryDepth = errCat("仅支持两级分类,不能挂到子分类下") + errCategoryName = errCat("分类名称需 2-30 个字符") + errCategoryDup = errCat("同级下分类名称已存在") +) + +type catError string + +func errCat(s string) catError { return catError(s) } +func (e catError) Error() string { return string(e) } + +func isCategoryNameTaken(tenantID uint, name string, parentID *uint, excludeID uint) bool { + q := model.DB.Model(&model.Category{}).Where("tenant_id = ? AND name = ?", tenantID, name) + if parentID == nil { + q = q.Where("parent_id IS NULL") + } else { + q = q.Where("parent_id = ?", *parentID) + } + if excludeID > 0 { + q = q.Where("id <> ?", excludeID) + } + var n int64 + q.Count(&n) + return n > 0 +} + func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { if !requireKnowledgeManager(c) { return } - var category model.Category - if err := c.ShouldBindJSON(&category); err != nil { + var req CategoryReq + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - category.TenantID = middleware.GetTenantID(c) - + name := validateCategoryName(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": errCategoryName.Error()}) + return + } + tenantID := middleware.GetTenantID(c) + if err := validateCategoryParent(tenantID, req.ParentID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + if isCategoryNameTaken(tenantID, name, req.ParentID, 0) { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": errCategoryDup.Error()}) + return + } + category := model.Category{ + TenantID: tenantID, + Name: name, + ParentID: req.ParentID, + } if err := model.DB.Create(&category).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } - middleware.JSON(c, category) } +func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) { + if !requireKnowledgeManager(c) { + return + } + tenantID := middleware.GetTenantID(c) + id := c.Param("id") + var category model.Category + if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&category).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"}) + return + } + var req CategoryReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + name := validateCategoryName(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": errCategoryName.Error()}) + return + } + // 禁止把自己设为自己的子级;改父级时校验 + if req.ParentID != nil && *req.ParentID == category.ID { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能将分类设为自己的子分类"}) + return + } + // 若本分类已有子分类,则不能再挂到其它父级下(保持最多两级) + if req.ParentID != nil { + var childCnt int64 + model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt) + if childCnt > 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "已有子分类的节点不能改为子分类"}) + return + } + } + if err := validateCategoryParent(tenantID, req.ParentID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + if isCategoryNameTaken(tenantID, name, req.ParentID, category.ID) { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": errCategoryDup.Error()}) + return + } + updates := map[string]interface{}{ + "name": name, + "parent_id": req.ParentID, + } + if err := model.DB.Model(&category).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) + return + } + model.DB.First(&category, category.ID) + middleware.JSON(c, category) +} + +func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) { + if !requireKnowledgeManager(c) { + return + } + tenantID := middleware.GetTenantID(c) + id := c.Param("id") + var category model.Category + if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&category).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"}) + return + } + var childCnt int64 + model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt) + if childCnt > 0 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "请先删除或移走子分类"}) + return + } + // 本分类及不应有子分类后,检查条目 + var entryCnt int64 + model.DB.Model(&model.KnowledgeEntry{}).Where("category_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&entryCnt) + if entryCnt > 0 { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "分类下仍有知识条目,请先删除或迁移条目"}) + return + } + if err := model.DB.Delete(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"}) + return + } + middleware.JSON(c, gin.H{"message": "已删除"}) +} + +// categoryScopeIDs 选中分类时包含自身与所有直接子分类(两级树) +func categoryScopeIDs(tenantID, categoryID uint) []uint { + ids := []uint{categoryID} + var children []model.Category + model.DB.Where("tenant_id = ? AND parent_id = ?", tenantID, categoryID).Find(&children) + for _, ch := range children { + ids = append(ids, ch.ID) + } + return ids +} + type EntryListItem struct { model.KnowledgeEntry CategoryName string `json:"category_name"` @@ -115,7 +307,12 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) { query := model.DB.Where("tenant_id = ?", tenantID) if categoryID != "" { - query = query.Where("category_id = ?", categoryID) + if id64, err := strconv.ParseUint(categoryID, 10, 64); err == nil && id64 > 0 { + ids := categoryScopeIDs(tenantID, uint(id64)) + query = query.Where("category_id IN ?", ids) + } else { + query = query.Where("category_id = ?", categoryID) + } } if search != "" { query = query.Where("title LIKE ? OR content LIKE ?", "%"+search+"%", "%"+search+"%") diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index d468a61..25eb8e6 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -76,6 +76,8 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S kb := authRequired.Group("/knowledge") kb.GET("/categories", knowledge.ListCategories) kb.POST("/categories", knowledge.CreateCategory) + kb.PUT("/categories/:id", knowledge.UpdateCategory) + kb.DELETE("/categories/:id", knowledge.DeleteCategory) kb.GET("/entries", knowledge.ListEntries) kb.POST("/entries", knowledge.CreateEntry) kb.PUT("/entries/:id", knowledge.UpdateEntry) diff --git a/web/src/pages/agent/Knowledge.tsx b/web/src/pages/agent/Knowledge.tsx index 37d08b6..a01bac6 100644 --- a/web/src/pages/agent/Knowledge.tsx +++ b/web/src/pages/agent/Knowledge.tsx @@ -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(null) + const [categoryParentId, setCategoryParentId] = useState(null) + const [expandedIds, setExpandedIds] = useState>(new Set()) const [editingEntry, setEditingEntry] = useState(null) const [detailEntry, setDetailEntry] = useState(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() + 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() + 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 if (name.includes('政策') || name.includes('条款')) return + if (name.includes('售后')) return + if (name.includes('技术')) return return } + const renderCatActions = (cat: KnowledgeCategory, isRoot: boolean) => { + if (!canManage) return null + return ( + e.stopPropagation()}> + {isRoot && ( + + )} + + handleDeleteCategory(cat)} + > + + + + ) + } + return (
- {/* 左侧分类 280px */} + {/* 左侧分类 280px — 顶栏固定 56px 与右侧对齐 */}