mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-16 13:19:17 +02:00
Add files via upload
This commit is contained in:
@@ -50,18 +50,168 @@ func (h *KnowledgeHandler) GetCategories(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
||||
}
|
||||
|
||||
// GetItems 获取知识项列表
|
||||
// GetItems 获取知识项列表(支持按分类分页和关键字搜索,默认不返回完整内容)
|
||||
func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
category := c.Query("category")
|
||||
searchKeyword := c.Query("search") // 搜索关键字
|
||||
|
||||
// 如果提供了搜索关键字,执行关键字搜索(在所有数据中搜索)
|
||||
if searchKeyword != "" {
|
||||
items, err := h.manager.SearchItemsByKeyword(searchKeyword, category)
|
||||
if err != nil {
|
||||
h.logger.Error("搜索知识项失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.manager.GetItems(category)
|
||||
if err != nil {
|
||||
h.logger.Error("获取知识项失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// 按分类分组结果
|
||||
groupedByCategory := make(map[string][]*knowledge.KnowledgeItemSummary)
|
||||
for _, item := range items {
|
||||
cat := item.Category
|
||||
if cat == "" {
|
||||
cat = "未分类"
|
||||
}
|
||||
groupedByCategory[cat] = append(groupedByCategory[cat], item)
|
||||
}
|
||||
|
||||
// 转换为CategoryWithItems格式
|
||||
categoriesWithItems := make([]*knowledge.CategoryWithItems, 0, len(groupedByCategory))
|
||||
for cat, catItems := range groupedByCategory {
|
||||
categoriesWithItems = append(categoriesWithItems, &knowledge.CategoryWithItems{
|
||||
Category: cat,
|
||||
ItemCount: len(catItems),
|
||||
Items: catItems,
|
||||
})
|
||||
}
|
||||
|
||||
// 按分类名称排序
|
||||
for i := 0; i < len(categoriesWithItems)-1; i++ {
|
||||
for j := i + 1; j < len(categoriesWithItems); j++ {
|
||||
if categoriesWithItems[i].Category > categoriesWithItems[j].Category {
|
||||
categoriesWithItems[i], categoriesWithItems[j] = categoriesWithItems[j], categoriesWithItems[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"categories": categoriesWithItems,
|
||||
"total": len(categoriesWithItems),
|
||||
"search": searchKeyword,
|
||||
"is_search": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 分页模式:categoryPage=true 表示按分类分页,否则按项分页(向后兼容)
|
||||
categoryPageMode := c.Query("categoryPage") != "false" // 默认使用分类分页
|
||||
|
||||
// 分页参数
|
||||
limit := 50 // 默认每页50条(分类分页时为分类数,项分页时为项数)
|
||||
offset := 0
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsed, err := parseInt(limitStr); err == nil && parsed > 0 && parsed <= 500 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
||||
if parsed, err := parseInt(offsetStr); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// 如果指定了category参数,且使用分类分页模式,则只返回该分类
|
||||
if category != "" && categoryPageMode {
|
||||
// 单分类模式:返回该分类的所有知识项(不分页)
|
||||
items, total, err := h.manager.GetItemsSummary(category, 0, 0)
|
||||
if err != nil {
|
||||
h.logger.Error("获取知识项失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 包装成分类结构
|
||||
categoriesWithItems := []*knowledge.CategoryWithItems{
|
||||
{
|
||||
Category: category,
|
||||
ItemCount: total,
|
||||
Items: items,
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"categories": categoriesWithItems,
|
||||
"total": 1, // 只有一个分类
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
if categoryPageMode {
|
||||
// 按分类分页模式(默认)
|
||||
// limit表示每页分类数,推荐5-10个分类
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 10 // 默认每页10个分类
|
||||
}
|
||||
|
||||
categoriesWithItems, totalCategories, err := h.manager.GetCategoriesWithItems(limit, offset)
|
||||
if err != nil {
|
||||
h.logger.Error("获取分类知识项失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"categories": categoriesWithItems,
|
||||
"total": totalCategories,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 按项分页模式(向后兼容)
|
||||
// 是否包含完整内容(默认false,只返回摘要)
|
||||
includeContent := c.Query("includeContent") == "true"
|
||||
|
||||
if includeContent {
|
||||
// 返回完整内容(向后兼容)
|
||||
items, err := h.manager.GetItemsWithOptions(category, limit, offset, true)
|
||||
if err != nil {
|
||||
h.logger.Error("获取知识项失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
total, err := h.manager.GetItemsCount(category)
|
||||
if err != nil {
|
||||
h.logger.Warn("获取知识项总数失败", zap.Error(err))
|
||||
total = len(items)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
} else {
|
||||
// 返回摘要(不包含完整内容,推荐方式)
|
||||
items, total, err := h.manager.GetItemsSummary(category, limit, offset)
|
||||
if err != nil {
|
||||
h.logger.Error("获取知识项失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetItem 获取单个知识项
|
||||
|
||||
+304
-11
@@ -153,21 +153,115 @@ func (m *Manager) GetCategories() ([]string, error) {
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// GetItems 获取知识项列表
|
||||
// GetCategoriesWithItems 按分类分页获取知识项(每个分类包含其下的所有知识项)
|
||||
// limit: 每页分类数量(0表示不限制)
|
||||
// offset: 偏移量(按分类偏移)
|
||||
func (m *Manager) GetCategoriesWithItems(limit, offset int) ([]*CategoryWithItems, int, error) {
|
||||
// 首先获取所有分类(带数量统计)
|
||||
rows, err := m.db.Query(`
|
||||
SELECT category, COUNT(*) as item_count
|
||||
FROM knowledge_base_items
|
||||
GROUP BY category
|
||||
ORDER BY category
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询分类失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 收集所有分类信息
|
||||
type categoryInfo struct {
|
||||
name string
|
||||
itemCount int
|
||||
}
|
||||
var allCategories []categoryInfo
|
||||
for rows.Next() {
|
||||
var info categoryInfo
|
||||
if err := rows.Scan(&info.name, &info.itemCount); err != nil {
|
||||
return nil, 0, fmt.Errorf("扫描分类失败: %w", err)
|
||||
}
|
||||
allCategories = append(allCategories, info)
|
||||
}
|
||||
|
||||
totalCategories := len(allCategories)
|
||||
|
||||
// 应用分页(按分类分页)
|
||||
var paginatedCategories []categoryInfo
|
||||
if limit > 0 {
|
||||
start := offset
|
||||
end := offset + limit
|
||||
if start >= totalCategories {
|
||||
paginatedCategories = []categoryInfo{}
|
||||
} else {
|
||||
if end > totalCategories {
|
||||
end = totalCategories
|
||||
}
|
||||
paginatedCategories = allCategories[start:end]
|
||||
}
|
||||
} else {
|
||||
paginatedCategories = allCategories
|
||||
}
|
||||
|
||||
// 为每个分类获取其下的知识项(只返回摘要,不包含完整内容)
|
||||
result := make([]*CategoryWithItems, 0, len(paginatedCategories))
|
||||
for _, catInfo := range paginatedCategories {
|
||||
// 获取该分类下的所有知识项
|
||||
items, _, err := m.GetItemsSummary(catInfo.name, 0, 0)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("获取分类 %s 的知识项失败: %w", catInfo.name, err)
|
||||
}
|
||||
|
||||
result = append(result, &CategoryWithItems{
|
||||
Category: catInfo.name,
|
||||
ItemCount: catInfo.itemCount,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
return result, totalCategories, nil
|
||||
}
|
||||
|
||||
// GetItems 获取知识项列表(完整内容,用于向后兼容)
|
||||
func (m *Manager) GetItems(category string) ([]*KnowledgeItem, error) {
|
||||
return m.GetItemsWithOptions(category, 0, 0, true)
|
||||
}
|
||||
|
||||
// GetItemsWithOptions 获取知识项列表(支持分页和可选内容)
|
||||
// category: 分类筛选(空字符串表示所有分类)
|
||||
// limit: 每页数量(0表示不限制)
|
||||
// offset: 偏移量
|
||||
// includeContent: 是否包含完整内容(false时只返回摘要)
|
||||
func (m *Manager) GetItemsWithOptions(category string, limit, offset int, includeContent bool) ([]*KnowledgeItem, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if category != "" {
|
||||
rows, err = m.db.Query(
|
||||
"SELECT id, category, title, file_path, content, created_at, updated_at FROM knowledge_base_items WHERE category = ? ORDER BY title",
|
||||
category,
|
||||
)
|
||||
// 构建SQL查询
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if includeContent {
|
||||
query = "SELECT id, category, title, file_path, content, created_at, updated_at FROM knowledge_base_items"
|
||||
} else {
|
||||
rows, err = m.db.Query(
|
||||
"SELECT id, category, title, file_path, content, created_at, updated_at FROM knowledge_base_items ORDER BY category, title",
|
||||
)
|
||||
query = "SELECT id, category, title, file_path, created_at, updated_at FROM knowledge_base_items"
|
||||
}
|
||||
|
||||
if category != "" {
|
||||
query += " WHERE category = ?"
|
||||
args = append(args, category)
|
||||
}
|
||||
|
||||
query += " ORDER BY category, title"
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
if offset > 0 {
|
||||
query += " OFFSET ?"
|
||||
args = append(args, offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err = m.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询知识项失败: %w", err)
|
||||
}
|
||||
@@ -177,8 +271,17 @@ func (m *Manager) GetItems(category string) ([]*KnowledgeItem, error) {
|
||||
for rows.Next() {
|
||||
item := &KnowledgeItem{}
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&item.ID, &item.Category, &item.Title, &item.FilePath, &item.Content, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描知识项失败: %w", err)
|
||||
|
||||
if includeContent {
|
||||
if err := rows.Scan(&item.ID, &item.Category, &item.Title, &item.FilePath, &item.Content, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描知识项失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := rows.Scan(&item.ID, &item.Category, &item.Title, &item.FilePath, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描知识项失败: %w", err)
|
||||
}
|
||||
// 不包含内容时,Content为空字符串
|
||||
item.Content = ""
|
||||
}
|
||||
|
||||
// 解析时间 - 支持多种格式
|
||||
@@ -225,6 +328,196 @@ func (m *Manager) GetItems(category string) ([]*KnowledgeItem, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetItemsCount 获取知识项总数
|
||||
func (m *Manager) GetItemsCount(category string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
|
||||
if category != "" {
|
||||
err = m.db.QueryRow("SELECT COUNT(*) FROM knowledge_base_items WHERE category = ?", category).Scan(&count)
|
||||
} else {
|
||||
err = m.db.QueryRow("SELECT COUNT(*) FROM knowledge_base_items").Scan(&count)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("查询知识项总数失败: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SearchItemsByKeyword 按关键字搜索知识项(在所有数据中搜索,支持标题、分类、路径匹配)
|
||||
func (m *Manager) SearchItemsByKeyword(keyword string, category string) ([]*KnowledgeItemSummary, error) {
|
||||
if keyword == "" {
|
||||
return nil, fmt.Errorf("搜索关键字不能为空")
|
||||
}
|
||||
|
||||
// 构建SQL查询,使用LIKE进行关键字匹配(不区分大小写)
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
// SQLite的LIKE不区分大小写,使用COLLATE NOCASE或LOWER()函数
|
||||
// 使用%keyword%进行模糊匹配
|
||||
searchPattern := "%" + keyword + "%"
|
||||
|
||||
query = `
|
||||
SELECT id, category, title, file_path, created_at, updated_at
|
||||
FROM knowledge_base_items
|
||||
WHERE (LOWER(title) LIKE LOWER(?) OR LOWER(category) LIKE LOWER(?) OR LOWER(file_path) LIKE LOWER(?))
|
||||
`
|
||||
args = append(args, searchPattern, searchPattern, searchPattern)
|
||||
|
||||
// 如果指定了分类,添加分类过滤
|
||||
if category != "" {
|
||||
query += " AND category = ?"
|
||||
args = append(args, category)
|
||||
}
|
||||
|
||||
query += " ORDER BY category, title"
|
||||
|
||||
rows, err := m.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜索知识项失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []*KnowledgeItemSummary
|
||||
for rows.Next() {
|
||||
item := &KnowledgeItemSummary{}
|
||||
var createdAt, updatedAt string
|
||||
|
||||
if err := rows.Scan(&item.ID, &item.Category, &item.Title, &item.FilePath, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描知识项失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析时间
|
||||
timeFormats := []string{
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02T15:04:05.999999999Z07:00",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
}
|
||||
|
||||
if createdAt != "" {
|
||||
for _, format := range timeFormats {
|
||||
parsed, err := time.Parse(format, createdAt)
|
||||
if err == nil && !parsed.IsZero() {
|
||||
item.CreatedAt = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updatedAt != "" {
|
||||
for _, format := range timeFormats {
|
||||
parsed, err := time.Parse(format, updatedAt)
|
||||
if err == nil && !parsed.IsZero() {
|
||||
item.UpdatedAt = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.UpdatedAt.IsZero() && !item.CreatedAt.IsZero() {
|
||||
item.UpdatedAt = item.CreatedAt
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetItemsSummary 获取知识项摘要列表(不包含完整内容,支持分页)
|
||||
func (m *Manager) GetItemsSummary(category string, limit, offset int) ([]*KnowledgeItemSummary, int, error) {
|
||||
// 获取总数
|
||||
total, err := m.GetItemsCount(category)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取列表数据(不包含内容)
|
||||
var rows *sql.Rows
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
query = "SELECT id, category, title, file_path, created_at, updated_at FROM knowledge_base_items"
|
||||
|
||||
if category != "" {
|
||||
query += " WHERE category = ?"
|
||||
args = append(args, category)
|
||||
}
|
||||
|
||||
query += " ORDER BY category, title"
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
if offset > 0 {
|
||||
query += " OFFSET ?"
|
||||
args = append(args, offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err = m.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询知识项失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []*KnowledgeItemSummary
|
||||
for rows.Next() {
|
||||
item := &KnowledgeItemSummary{}
|
||||
var createdAt, updatedAt string
|
||||
|
||||
if err := rows.Scan(&item.ID, &item.Category, &item.Title, &item.FilePath, &createdAt, &updatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("扫描知识项失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析时间
|
||||
timeFormats := []string{
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02T15:04:05.999999999Z07:00",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
}
|
||||
|
||||
if createdAt != "" {
|
||||
for _, format := range timeFormats {
|
||||
parsed, err := time.Parse(format, createdAt)
|
||||
if err == nil && !parsed.IsZero() {
|
||||
item.CreatedAt = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updatedAt != "" {
|
||||
for _, format := range timeFormats {
|
||||
parsed, err := time.Parse(format, updatedAt)
|
||||
if err == nil && !parsed.IsZero() {
|
||||
item.UpdatedAt = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.UpdatedAt.IsZero() && !item.CreatedAt.IsZero() {
|
||||
item.UpdatedAt = item.CreatedAt
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetItem 获取单个知识项
|
||||
func (m *Manager) GetItem(id string) (*KnowledgeItem, error) {
|
||||
item := &KnowledgeItem{}
|
||||
|
||||
@@ -16,6 +16,45 @@ type KnowledgeItem struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// KnowledgeItemSummary 知识库项摘要(用于列表,不包含完整内容)
|
||||
type KnowledgeItemSummary struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
FilePath string `json:"filePath"`
|
||||
Content string `json:"content,omitempty"` // 可选:内容预览(如果提供,通常只包含前150字符)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) {
|
||||
type Alias KnowledgeItemSummary
|
||||
aux := &struct {
|
||||
*Alias
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}{
|
||||
Alias: (*Alias)(k),
|
||||
}
|
||||
|
||||
// 格式化创建时间
|
||||
if k.CreatedAt.IsZero() {
|
||||
aux.CreatedAt = ""
|
||||
} else {
|
||||
aux.CreatedAt = k.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// 格式化更新时间
|
||||
if k.UpdatedAt.IsZero() {
|
||||
aux.UpdatedAt = ""
|
||||
} else {
|
||||
aux.UpdatedAt = k.UpdatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
func (k *KnowledgeItem) MarshalJSON() ([]byte, error) {
|
||||
type Alias KnowledgeItem
|
||||
@@ -85,6 +124,13 @@ func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// CategoryWithItems 分类及其下的知识项(用于按分类分页)
|
||||
type CategoryWithItems struct {
|
||||
Category string `json:"category"` // 分类名称
|
||||
ItemCount int `json:"itemCount"` // 该分类下的知识项总数
|
||||
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
|
||||
}
|
||||
|
||||
// SearchRequest 搜索请求
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
|
||||
+308
-59
@@ -4,6 +4,13 @@ let knowledgeItems = [];
|
||||
let currentEditingItemId = null;
|
||||
let isSavingKnowledgeItem = false; // 防止重复提交
|
||||
let retrievalLogsData = []; // 存储检索日志数据,用于详情查看
|
||||
let knowledgePagination = {
|
||||
currentPage: 1,
|
||||
pageSize: 10, // 每页分类数(改为按分类分页)
|
||||
total: 0,
|
||||
currentCategory: ''
|
||||
};
|
||||
let searchTimeout = null; // 搜索防抖定时器
|
||||
|
||||
// 加载知识分类
|
||||
async function loadKnowledgeCategories() {
|
||||
@@ -77,14 +84,21 @@ async function loadKnowledgeCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载知识项列表
|
||||
async function loadKnowledgeItems(category = '') {
|
||||
// 加载知识项列表(支持按分类分页,默认不加载完整内容)
|
||||
async function loadKnowledgeItems(category = '', page = 1, pageSize = 10) {
|
||||
try {
|
||||
// 添加时间戳参数避免缓存
|
||||
// 更新分页状态
|
||||
knowledgePagination.currentCategory = category;
|
||||
knowledgePagination.currentPage = page;
|
||||
knowledgePagination.pageSize = pageSize;
|
||||
|
||||
// 构建URL(按分类分页模式,不包含完整内容)
|
||||
const timestamp = Date.now();
|
||||
const url = category
|
||||
? `/api/knowledge/items?category=${encodeURIComponent(category)}&_t=${timestamp}`
|
||||
: `/api/knowledge/items?_t=${timestamp}`;
|
||||
const offset = (page - 1) * pageSize;
|
||||
let url = `/api/knowledge/items?categoryPage=true&limit=${pageSize}&offset=${offset}&_t=${timestamp}`;
|
||||
if (category) {
|
||||
url += `&category=${encodeURIComponent(category)}`;
|
||||
}
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'GET',
|
||||
@@ -123,12 +137,27 @@ async function loadKnowledgeItems(category = '') {
|
||||
`;
|
||||
}
|
||||
knowledgeItems = [];
|
||||
knowledgePagination.total = 0;
|
||||
renderKnowledgePagination();
|
||||
return [];
|
||||
}
|
||||
|
||||
knowledgeItems = data.items || [];
|
||||
renderKnowledgeItems(knowledgeItems);
|
||||
return knowledgeItems;
|
||||
// 处理按分类分页的响应数据
|
||||
const categoriesWithItems = data.categories || [];
|
||||
knowledgePagination.total = data.total || 0; // 总分类数
|
||||
|
||||
renderKnowledgeItemsByCategories(categoriesWithItems);
|
||||
|
||||
// 如果选择了单个分类,不显示分页(因为只显示一个分类)
|
||||
if (category) {
|
||||
const paginationContainer = document.getElementById('knowledge-pagination');
|
||||
if (paginationContainer) {
|
||||
paginationContainer.innerHTML = '';
|
||||
}
|
||||
} else {
|
||||
renderKnowledgePagination();
|
||||
}
|
||||
return categoriesWithItems;
|
||||
} catch (error) {
|
||||
console.error('加载知识项失败:', error);
|
||||
// 只在非功能未启用的情况下显示错误
|
||||
@@ -139,7 +168,51 @@ async function loadKnowledgeItems(category = '') {
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染知识项列表
|
||||
// 渲染知识项列表(按分类分页的数据结构)
|
||||
function renderKnowledgeItemsByCategories(categoriesWithItems) {
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (!container) return;
|
||||
|
||||
if (categoriesWithItems.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">暂无知识项</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算总项数和分类数
|
||||
const totalItems = categoriesWithItems.reduce((sum, cat) => sum + (cat.items?.length || 0), 0);
|
||||
const categoryCount = categoriesWithItems.length;
|
||||
|
||||
// 更新统计信息
|
||||
updateKnowledgeStats(categoriesWithItems, categoryCount);
|
||||
|
||||
// 渲染分类及知识项
|
||||
let html = '<div class="knowledge-categories-container">';
|
||||
|
||||
categoriesWithItems.forEach(categoryData => {
|
||||
const category = categoryData.category || '未分类';
|
||||
const categoryItems = categoryData.items || [];
|
||||
const categoryCount = categoryData.itemCount || categoryItems.length;
|
||||
|
||||
html += `
|
||||
<div class="knowledge-category-section" data-category="${escapeHtml(category)}">
|
||||
<div class="knowledge-category-header">
|
||||
<div class="knowledge-category-info">
|
||||
<h3 class="knowledge-category-title">${escapeHtml(category)}</h3>
|
||||
<span class="knowledge-category-count">${categoryCount} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="knowledge-items-grid">
|
||||
${categoryItems.map(item => renderKnowledgeItemCard(item)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 渲染知识项列表(向后兼容,用于按项分页的旧代码)
|
||||
function renderKnowledgeItems(items) {
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (!container) return;
|
||||
@@ -189,22 +262,66 @@ function renderKnowledgeItems(items) {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 渲染分页控件(按分类分页)
|
||||
function renderKnowledgePagination() {
|
||||
const container = document.getElementById('knowledge-pagination');
|
||||
if (!container) return;
|
||||
|
||||
const { currentPage, pageSize, total } = knowledgePagination;
|
||||
const totalPages = Math.ceil(total / pageSize); // total是总分类数
|
||||
|
||||
if (totalPages <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="knowledge-pagination" style="display: flex; justify-content: center; align-items: center; gap: 8px; padding: 20px; flex-wrap: wrap;">';
|
||||
|
||||
// 上一页按钮
|
||||
html += `<button class="pagination-btn" onclick="loadKnowledgePage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled style="opacity: 0.5; cursor: not-allowed;"' : ''}>上一页</button>`;
|
||||
|
||||
// 页码显示(显示分类数)
|
||||
html += `<span style="padding: 0 12px;">第 ${currentPage} 页,共 ${totalPages} 页(共 ${total} 个分类)</span>`;
|
||||
|
||||
// 下一页按钮
|
||||
html += `<button class="pagination-btn" onclick="loadKnowledgePage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled style="opacity: 0.5; cursor: not-allowed;"' : ''}>下一页</button>`;
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 加载指定页码的知识项
|
||||
function loadKnowledgePage(page) {
|
||||
const { currentCategory, pageSize, total } = knowledgePagination;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
if (page < 1 || page > totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadKnowledgeItems(currentCategory, page, pageSize);
|
||||
}
|
||||
|
||||
// 渲染单个知识项卡片
|
||||
function renderKnowledgeItemCard(item) {
|
||||
// 提取内容预览(去除markdown格式,取前150字符)
|
||||
let preview = item.content || '';
|
||||
// 移除markdown标题标记
|
||||
preview = preview.replace(/^#+\s+/gm, '');
|
||||
// 移除代码块
|
||||
preview = preview.replace(/```[\s\S]*?```/g, '');
|
||||
// 移除行内代码
|
||||
preview = preview.replace(/`[^`]+`/g, '');
|
||||
// 移除链接
|
||||
preview = preview.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
|
||||
// 清理多余空白
|
||||
preview = preview.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const previewText = preview.length > 150 ? preview.substring(0, 150) + '...' : preview;
|
||||
// 提取内容预览(如果item没有content字段,说明是摘要,不显示预览)
|
||||
let previewText = '';
|
||||
if (item.content) {
|
||||
// 去除markdown格式,取前150字符
|
||||
let preview = item.content;
|
||||
// 移除markdown标题标记
|
||||
preview = preview.replace(/^#+\s+/gm, '');
|
||||
// 移除代码块
|
||||
preview = preview.replace(/```[\s\S]*?```/g, '');
|
||||
// 移除行内代码
|
||||
preview = preview.replace(/`[^`]+`/g, '');
|
||||
// 移除链接
|
||||
preview = preview.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
|
||||
// 清理多余空白
|
||||
preview = preview.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
previewText = preview.length > 150 ? preview.substring(0, 150) + '...' : preview;
|
||||
}
|
||||
|
||||
// 提取文件路径显示
|
||||
const filePath = item.filePath || '';
|
||||
@@ -248,9 +365,11 @@ function renderKnowledgeItemCard(item) {
|
||||
</div>
|
||||
${relativePath ? `<div class="knowledge-item-path">📁 ${escapeHtml(relativePath)}</div>` : ''}
|
||||
</div>
|
||||
${previewText ? `
|
||||
<div class="knowledge-item-card-content">
|
||||
<p class="knowledge-item-preview">${escapeHtml(previewText || '无内容预览')}</p>
|
||||
<p class="knowledge-item-preview">${escapeHtml(previewText)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="knowledge-item-card-footer">
|
||||
<div class="knowledge-item-meta">
|
||||
${displayTime ? `<span class="knowledge-item-time" title="${timeLabel}">🕒 ${displayTime}</span>` : ''}
|
||||
@@ -261,27 +380,39 @@ function renderKnowledgeItemCard(item) {
|
||||
`;
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
function updateKnowledgeStats(items, categoryCount) {
|
||||
// 更新统计信息(支持按分类分页的数据结构)
|
||||
function updateKnowledgeStats(data, categoryCount) {
|
||||
const statsContainer = document.getElementById('knowledge-stats');
|
||||
if (!statsContainer) return;
|
||||
|
||||
const totalItems = items.length;
|
||||
const totalSize = items.reduce((sum, item) => sum + (item.content?.length || 0), 0);
|
||||
const sizeKB = (totalSize / 1024).toFixed(1);
|
||||
// 计算当前页的知识项数
|
||||
let currentPageItemCount = 0;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
// 判断是categoriesWithItems还是items数组
|
||||
if (data[0].category !== undefined && data[0].items !== undefined) {
|
||||
// 是按分类分页的数据结构
|
||||
currentPageItemCount = data.reduce((sum, cat) => sum + (cat.items?.length || 0), 0);
|
||||
} else {
|
||||
// 是按项分页的数据结构(向后兼容)
|
||||
currentPageItemCount = data.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 总分类数(来自分页信息)
|
||||
const totalCategories = knowledgePagination.total || categoryCount;
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<div class="knowledge-stat-item">
|
||||
<span class="knowledge-stat-label">总知识项</span>
|
||||
<span class="knowledge-stat-value">${totalItems}</span>
|
||||
<span class="knowledge-stat-label">总分类数</span>
|
||||
<span class="knowledge-stat-value">${totalCategories}</span>
|
||||
</div>
|
||||
<div class="knowledge-stat-item">
|
||||
<span class="knowledge-stat-label">分类数</span>
|
||||
<span class="knowledge-stat-value">${categoryCount}</span>
|
||||
<span class="knowledge-stat-label">当前页分类</span>
|
||||
<span class="knowledge-stat-value">${categoryCount} 个</span>
|
||||
</div>
|
||||
<div class="knowledge-stat-item">
|
||||
<span class="knowledge-stat-label">总内容</span>
|
||||
<span class="knowledge-stat-value">${sizeKB} KB</span>
|
||||
<span class="knowledge-stat-label">当前页知识项</span>
|
||||
<span class="knowledge-stat-value">${currentPageItemCount} 项</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -396,7 +527,8 @@ function selectKnowledgeCategory(category) {
|
||||
}
|
||||
});
|
||||
}
|
||||
loadKnowledgeItems(category);
|
||||
// 切换分类时重置到第一页(如果选择了分类,API会返回该分类的所有项)
|
||||
loadKnowledgeItems(category, 1, knowledgePagination.pageSize);
|
||||
}
|
||||
|
||||
// 筛选知识项
|
||||
@@ -405,32 +537,149 @@ function filterKnowledgeItems() {
|
||||
if (wrapper) {
|
||||
const selectedOption = wrapper.querySelector('.custom-select-option.selected');
|
||||
const category = selectedOption ? selectedOption.getAttribute('data-value') : '';
|
||||
loadKnowledgeItems(category);
|
||||
// 重置到第一页
|
||||
loadKnowledgeItems(category, 1, knowledgePagination.pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索知识项
|
||||
function searchKnowledgeItems() {
|
||||
const searchTerm = document.getElementById('knowledge-search').value.toLowerCase().trim();
|
||||
// 处理搜索输入(带防抖)
|
||||
function handleKnowledgeSearchInput() {
|
||||
const searchInput = document.getElementById('knowledge-search');
|
||||
const searchTerm = searchInput?.value.trim() || '';
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
// 如果搜索框为空,立即恢复列表
|
||||
if (!searchTerm) {
|
||||
// 恢复原始列表
|
||||
const wrapper = document.getElementById('knowledge-category-filter-wrapper');
|
||||
let category = '';
|
||||
if (wrapper) {
|
||||
const selectedOption = wrapper.querySelector('.custom-select-option.selected');
|
||||
category = selectedOption ? selectedOption.getAttribute('data-value') : '';
|
||||
}
|
||||
loadKnowledgeItems(category);
|
||||
loadKnowledgeItems(category, 1, knowledgePagination.pageSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = knowledgeItems.filter(item =>
|
||||
item.title.toLowerCase().includes(searchTerm) ||
|
||||
item.content.toLowerCase().includes(searchTerm) ||
|
||||
item.category.toLowerCase().includes(searchTerm) ||
|
||||
(item.filePath && item.filePath.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
renderKnowledgeItems(filtered);
|
||||
// 有搜索词时,延迟500ms后执行搜索(防抖)
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchKnowledgeItems();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 搜索知识项(后端关键字匹配,在所有数据中搜索)
|
||||
async function searchKnowledgeItems() {
|
||||
const searchInput = document.getElementById('knowledge-search');
|
||||
const searchTerm = searchInput?.value.trim() || '';
|
||||
|
||||
if (!searchTerm) {
|
||||
// 恢复原始列表(重置到第一页)
|
||||
const wrapper = document.getElementById('knowledge-category-filter-wrapper');
|
||||
let category = '';
|
||||
if (wrapper) {
|
||||
const selectedOption = wrapper.querySelector('.custom-select-option.selected');
|
||||
category = selectedOption ? selectedOption.getAttribute('data-value') : '';
|
||||
}
|
||||
await loadKnowledgeItems(category, 1, knowledgePagination.pageSize);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前选择的分类
|
||||
const wrapper = document.getElementById('knowledge-category-filter-wrapper');
|
||||
let category = '';
|
||||
if (wrapper) {
|
||||
const selectedOption = wrapper.querySelector('.custom-select-option.selected');
|
||||
category = selectedOption ? selectedOption.getAttribute('data-value') : '';
|
||||
}
|
||||
|
||||
// 调用后端API进行全量搜索
|
||||
const timestamp = Date.now();
|
||||
let url = `/api/knowledge/items?search=${encodeURIComponent(searchTerm)}&_t=${timestamp}`;
|
||||
if (category) {
|
||||
url += `&category=${encodeURIComponent(category)}`;
|
||||
}
|
||||
|
||||
const response = await apiFetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('搜索失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 检查知识库功能是否启用
|
||||
if (data.enabled === false) {
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
|
||||
<h3 style="margin-bottom: 10px; color: #666;">知识库功能未启用</h3>
|
||||
<p style="color: #999; margin-bottom: 20px;">${data.message || '请前往系统设置启用知识检索功能'}</p>
|
||||
<button onclick="switchToSettings()" style="
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
">前往设置</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理搜索结果
|
||||
const categoriesWithItems = data.categories || [];
|
||||
|
||||
// 渲染搜索结果
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (!container) return;
|
||||
|
||||
if (categoriesWithItems.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">🔍</div>
|
||||
<h3 style="margin-bottom: 10px;">未找到匹配的知识项</h3>
|
||||
<p style="color: #999;">关键词 "<strong>${escapeHtml(searchTerm)}</strong>" 在所有数据中没有匹配结果</p>
|
||||
<p style="color: #999; margin-top: 10px; font-size: 0.9em;">请尝试其他关键词,或使用分类筛选功能</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// 计算总项数和分类数
|
||||
const totalItems = categoriesWithItems.reduce((sum, cat) => sum + (cat.items?.length || 0), 0);
|
||||
const categoryCount = categoriesWithItems.length;
|
||||
|
||||
// 更新统计信息
|
||||
updateKnowledgeStats(categoriesWithItems, categoryCount);
|
||||
|
||||
// 渲染搜索结果
|
||||
renderKnowledgeItemsByCategories(categoriesWithItems);
|
||||
}
|
||||
|
||||
// 搜索时隐藏分页(因为搜索结果显示所有匹配结果)
|
||||
const paginationContainer = document.getElementById('knowledge-pagination');
|
||||
if (paginationContainer) {
|
||||
paginationContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('搜索知识项失败:', error);
|
||||
showNotification('搜索失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新知识库
|
||||
@@ -450,9 +699,9 @@ async function refreshKnowledgeBase() {
|
||||
} else {
|
||||
showNotification(data.message || '扫描完成,没有需要索引的新项或更新项', 'success');
|
||||
}
|
||||
// 重新加载知识项
|
||||
// 重新加载知识项(重置到第一页)
|
||||
await loadKnowledgeCategories();
|
||||
await loadKnowledgeItems();
|
||||
await loadKnowledgeItems(knowledgePagination.currentCategory, 1, knowledgePagination.pageSize);
|
||||
|
||||
// 停止现有的轮询
|
||||
if (indexProgressInterval) {
|
||||
@@ -686,8 +935,8 @@ async function saveKnowledgeItem() {
|
||||
showNotification(`✅ ${action}成功!已切换到分类"${newItemCategory}"查看新添加的知识项。`, 'success');
|
||||
}
|
||||
|
||||
// 刷新知识项列表
|
||||
await loadKnowledgeItems(categoryToShow);
|
||||
// 刷新知识项列表(重置到第一页)
|
||||
await loadKnowledgeItems(categoryToShow, 1, knowledgePagination.pageSize);
|
||||
console.log('知识项刷新完成');
|
||||
} catch (err) {
|
||||
console.error('刷新数据失败:', err);
|
||||
@@ -805,9 +1054,9 @@ async function deleteKnowledgeItem(id) {
|
||||
// 显示成功通知
|
||||
showNotification('✅ 删除成功!知识项已从系统中移除。', 'success');
|
||||
|
||||
// 重新加载数据以确保数据同步
|
||||
// 重新加载数据以确保数据同步(保持当前页码)
|
||||
await loadKnowledgeCategories();
|
||||
await loadKnowledgeItems();
|
||||
await loadKnowledgeItems(knowledgePagination.currentCategory, knowledgePagination.currentPage, knowledgePagination.pageSize);
|
||||
|
||||
} catch (error) {
|
||||
console.error('删除知识项失败:', error);
|
||||
@@ -821,8 +1070,8 @@ async function deleteKnowledgeItem(id) {
|
||||
|
||||
// 如果分类被移除了,需要恢复
|
||||
if (categorySection && !categorySection.parentElement) {
|
||||
// 需要重新加载来恢复
|
||||
await loadKnowledgeItems();
|
||||
// 需要重新加载来恢复(保持当前分页状态)
|
||||
await loadKnowledgeItems(knowledgePagination.currentCategory, knowledgePagination.currentPage, knowledgePagination.pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1537,7 +1786,7 @@ if (typeof switchPage === 'function') {
|
||||
|
||||
if (page === 'knowledge-management') {
|
||||
loadKnowledgeCategories();
|
||||
loadKnowledgeItems();
|
||||
loadKnowledgeItems(knowledgePagination.currentCategory, 1, knowledgePagination.pageSize);
|
||||
updateIndexProgress(); // 更新索引进度
|
||||
} else if (page === 'knowledge-retrieval-logs') {
|
||||
loadRetrievalLogs();
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
</div>
|
||||
</label>
|
||||
<div class="search-box">
|
||||
<input type="text" id="knowledge-search" placeholder="搜索知识..." oninput="searchKnowledgeItems()" />
|
||||
<input type="text" id="knowledge-search" placeholder="搜索知识..." oninput="handleKnowledgeSearchInput()" onkeydown="if(event.key==='Enter') searchKnowledgeItems()" />
|
||||
<button class="btn-search" onclick="searchKnowledgeItems()" title="搜索">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,6 +392,7 @@
|
||||
<div id="knowledge-items-list" class="knowledge-items-list">
|
||||
<div class="loading-spinner">加载中...</div>
|
||||
</div>
|
||||
<div id="knowledge-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user