From 4ffda00a8659653009701411705e8b2d0b409394 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Sun, 4 Jan 2026 19:35:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E6=9C=AA=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E6=A0=87=E7=AD=BE=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将未分类标签从空字符串改为特殊标识符__untagged__,统一前后端处理逻辑 --- .../src/views/knowledge/KnowledgeBase.vue | 60 +++++++++---------- internal/application/repository/knowledge.go | 14 ++++- internal/application/repository/tag.go | 21 +++++++ internal/application/service/tag.go | 30 +++++++++- internal/types/interfaces/tag.go | 6 ++ 5 files changed, 96 insertions(+), 35 deletions(-) diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index d080e3da..d4c5515b 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -46,10 +46,9 @@ let knowledgeScroll = ref() let page = 1; let pageSize = 35; -const selectedTagId = ref(""); +const selectedTagId = ref("__untagged__"); const tagList = ref([]); const tagLoading = ref(false); -const overallKnowledgeTotal = ref(0); const tagSearchQuery = ref(''); const TAG_PAGE_SIZE = 50; const tagPage = ref(1); @@ -60,6 +59,7 @@ let tagSearchDebounce: ReturnType | null = null; let docSearchDebounce: ReturnType | null = null; const docSearchKeyword = ref(''); const selectedFileType = ref(''); +const UNTAGGED_TAG_ID = '__untagged__'; const fileTypeOptions = computed(() => [ { content: t('knowledgeBase.allFileTypes') || '全部类型', value: '' }, { content: 'PDF', value: 'pdf' }, @@ -73,11 +73,13 @@ const fileTypeOptions = computed(() => [ type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>; const tagDropdownOptions = computed(() => { const options = [ - { content: t('knowledgeBase.untagged') || '未分类', value: "" }, - ...tagList.value.map((tag: any) => ({ - content: tag.name, - value: tag.id, - })), + { content: t('knowledgeBase.untagged') || '未分类', value: UNTAGGED_TAG_ID }, + ...tagList.value + .filter((tag: any) => tag.id !== UNTAGGED_TAG_ID) + .map((tag: any) => ({ + content: tag.name, + value: tag.id, + })), ]; return options; }); @@ -88,17 +90,15 @@ const tagMap = computed>(() => { }); return map; }); -const sidebarCategoryCount = computed(() => tagList.value.length + 1); -const assignedKnowledgeTotal = computed(() => - tagList.value.reduce((sum, tag) => sum + (tag.knowledge_count || 0), 0), -); -const untaggedKnowledgeCount = computed(() => { - return Math.max(overallKnowledgeTotal.value - assignedKnowledgeTotal.value, 0); -}); +const sidebarCategoryCount = computed(() => tagList.value.length); +// Filter out the untagged pseudo-tag from regular tags +const regularTags = computed(() => tagList.value.filter((tag) => tag.id !== UNTAGGED_TAG_ID)); +// Get the untagged pseudo-tag from the list +const untaggedTag = computed(() => tagList.value.find((tag) => tag.id === UNTAGGED_TAG_ID)); const filteredTags = computed(() => { const query = tagSearchQuery.value.trim().toLowerCase(); - if (!query) return tagList.value; - return tagList.value.filter((tag) => (tag.name || '').toLowerCase().includes(query)); + if (!query) return regularTags.value; + return regularTags.value.filter((tag) => (tag.name || '').toLowerCase().includes(query)); }); const editingTagInputRefs = new Map(); @@ -129,6 +129,7 @@ getPageSize() // 直接调用 API 获取知识库文件列表 const getTagName = (tagId?: string | number) => { if (!tagId && tagId !== 0) return t('knowledgeBase.untagged') || '未分类'; + if (tagId === UNTAGGED_TAG_ID) return t('knowledgeBase.untagged') || '未分类'; const key = String(tagId); return tagMap.value[key]?.name || (t('knowledgeBase.untagged') || '未分类'); }; @@ -252,8 +253,8 @@ const handleUntaggedClick = () => { editingTagId.value = null; editingTagName.value = ''; } - if (selectedTagId.value === '') return; - handleTagFilterChange(''); + if (selectedTagId.value === UNTAGGED_TAG_ID) return; + handleTagFilterChange(UNTAGGED_TAG_ID); }; const startCreateTag = () => { @@ -363,7 +364,7 @@ const confirmDeleteTag = (tag: any) => { .then(() => { MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess')); if (selectedTagId.value === tag.id) { - handleTagFilterChange(''); + handleTagFilterChange(UNTAGGED_TAG_ID); } loadTags(kbId.value); loadKnowledgeFiles(kbId.value); @@ -375,7 +376,9 @@ const confirmDeleteTag = (tag: any) => { const handleKnowledgeTagChange = async (knowledgeId: string, tagValue: string) => { try { - await updateKnowledgeTagBatch({ updates: { [knowledgeId]: tagValue || null } }); + // When selecting "untagged", pass null to clear the tag + const tagIdToUpdate = tagValue === UNTAGGED_TAG_ID ? null : (tagValue || null); + await updateKnowledgeTagBatch({ updates: { [knowledgeId]: tagIdToUpdate } }); MessagePlugin.success(t('knowledgeBase.tagUpdateSuccess') || '分类已更新'); loadKnowledgeFiles(kbId.value); loadTags(kbId.value); @@ -393,16 +396,14 @@ const loadKnowledgeBaseInfo = async (targetKbId: string) => { try { const res: any = await getKnowledgeBaseById(targetKbId); kbInfo.value = res?.data || null; - selectedTagId.value = ""; + selectedTagId.value = UNTAGGED_TAG_ID; if (!isFAQ.value) { - getKnowled({ page: 1, page_size: pageSize, tag_id: undefined }, targetKbId); loadKnowledgeFiles(targetKbId); } else { cardList.value = []; total.value = 0; } loadTags(targetKbId, true); - overallKnowledgeTotal.value = total.value; } catch (error) { console.error('Failed to load knowledge base info:', error); kbInfo.value = null; @@ -440,12 +441,6 @@ watch(selectedTagId, (newVal, oldVal) => { } }); -watch(total, (val) => { - if (selectedTagId.value === '') { - overallKnowledgeTotal.value = val || 0; - } -}); - watch(tagSearchQuery, (newVal, oldVal) => { if (newVal === oldVal) return; if (tagSearchDebounce) { @@ -857,7 +852,7 @@ const handleScroll = () => { if (scrollTop + clientHeight >= scrollHeight) { page++; if (cardList.value.length < total.value && page <= pageNum) { - getKnowled({ page, page_size: pageSize, tag_id: selectedTagId.value || undefined, keyword: docSearchKeyword.value ? docSearchKeyword.value.trim() : undefined, file_type: selectedFileType.value || undefined }); + getKnowled({ page, page_size: pageSize, tag_id: selectedTagId.value, keyword: docSearchKeyword.value ? docSearchKeyword.value.trim() : undefined, file_type: selectedFileType.value || undefined }); } } } @@ -1009,15 +1004,16 @@ async function createNewSession(value: string): Promise {
{{ $t('knowledgeBase.untagged') || '未分类' }}
- {{ untaggedKnowledgeCount }} + {{ untaggedTag.knowledge_count || 0 }}
diff --git a/internal/application/repository/knowledge.go b/internal/application/repository/knowledge.go index 209dac65..3d60c077 100644 --- a/internal/application/repository/knowledge.go +++ b/internal/application/repository/knowledge.go @@ -72,7 +72,12 @@ func (r *knowledgeRepository) ListPagedKnowledgeByKnowledgeBaseID( query := r.db.WithContext(ctx).Model(&types.Knowledge{}). Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID) if tagID != "" { - query = query.Where("tag_id = ?", tagID) + if tagID == types.UntaggedTagID { + // Special value to filter entries without a tag + query = query.Where("tag_id = '' OR tag_id IS NULL") + } else { + query = query.Where("tag_id = ?", tagID) + } } if keyword != "" { query = query.Where("file_name LIKE ?", "%"+keyword+"%") @@ -96,7 +101,12 @@ func (r *knowledgeRepository) ListPagedKnowledgeByKnowledgeBaseID( dataQuery := r.db.WithContext(ctx). Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID) if tagID != "" { - dataQuery = dataQuery.Where("tag_id = ?", tagID) + if tagID == types.UntaggedTagID { + // Special value to filter entries without a tag + dataQuery = dataQuery.Where("tag_id = '' OR tag_id IS NULL") + } else { + dataQuery = dataQuery.Where("tag_id = ?", tagID) + } } if keyword != "" { dataQuery = dataQuery.Where("file_name LIKE ?", "%"+keyword+"%") diff --git a/internal/application/repository/tag.go b/internal/application/repository/tag.go index c47564a3..778170bf 100644 --- a/internal/application/repository/tag.go +++ b/internal/application/repository/tag.go @@ -135,3 +135,24 @@ func (r *knowledgeTagRepository) CountReferences( } return } + +// CountUntaggedReferences returns the number of knowledges and chunks without a tag +func (r *knowledgeTagRepository) CountUntaggedReferences( + ctx context.Context, + tenantID uint64, + kbID string, +) (knowledgeCount int64, chunkCount int64, err error) { + if err = r.db.WithContext(ctx). + Model(&types.Knowledge{}). + Where("tenant_id = ? AND knowledge_base_id = ? AND (tag_id = '' OR tag_id IS NULL)", tenantID, kbID). + Count(&knowledgeCount).Error; err != nil { + return + } + if err = r.db.WithContext(ctx). + Model(&types.Chunk{}). + Where("tenant_id = ? AND knowledge_base_id = ? AND (tag_id = '' OR tag_id IS NULL)", tenantID, kbID). + Count(&chunkCount).Error; err != nil { + return + } + return +} diff --git a/internal/application/service/tag.go b/internal/application/service/tag.go index e0bf52dc..a6f51251 100644 --- a/internal/application/service/tag.go +++ b/internal/application/service/tag.go @@ -72,7 +72,29 @@ func (s *knowledgeTagService) ListTags( return nil, err } - results := make([]*types.KnowledgeTagWithStats, 0, len(tags)) + results := make([]*types.KnowledgeTagWithStats, 0, len(tags)+1) + + // Add untagged pseudo-tag at the beginning (only on first page and when no keyword filter) + if page.Page <= 1 && keyword == "" { + untaggedKCount, untaggedCCount, err := s.repo.CountUntaggedReferences(ctx, tenantID, kbID) + if err != nil { + logger.ErrorWithFields(ctx, err, map[string]interface{}{ + "kb_id": kbID, + }) + return nil, err + } + results = append(results, &types.KnowledgeTagWithStats{ + KnowledgeTag: types.KnowledgeTag{ + ID: types.UntaggedTagID, + TenantID: tenantID, + KnowledgeBaseID: kbID, + Name: "未分类", + }, + KnowledgeCount: untaggedKCount, + ChunkCount: untaggedCCount, + }) + } + for _, tag := range tags { if tag == nil { continue @@ -91,6 +113,12 @@ func (s *knowledgeTagService) ListTags( ChunkCount: cCount, }) } + + // Adjust total to include the untagged pseudo-tag + if keyword == "" { + total++ + } + return types.NewPageResult(total, page, results), nil } diff --git a/internal/types/interfaces/tag.go b/internal/types/interfaces/tag.go index fd578bba..36be449a 100644 --- a/internal/types/interfaces/tag.go +++ b/internal/types/interfaces/tag.go @@ -48,4 +48,10 @@ type KnowledgeTagRepository interface { kbID string, tagID string, ) (knowledgeCount int64, chunkCount int64, err error) + // CountUntaggedReferences returns number of knowledges and chunks without a tag. + CountUntaggedReferences( + ctx context.Context, + tenantID uint64, + kbID string, + ) (knowledgeCount int64, chunkCount int64, err error) }