实现知识库两级分类树与完整 CRUD
支持父子分类、展开侧栏与改删校验;选中父分类可筛子级条目;对齐左右顶栏并更新种子树形数据。
This commit is contained in:
+25
-13
@@ -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)
|
||||
|
||||
|
||||
@@ -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+"%")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user