diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index c12e1661..fcc72d30 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -160,12 +160,12 @@ export function togglePinKnowledgeBase(id: string) { } // 知识文件 API(基于具体知识库) -// data.tag_id: 可选,指定知识所属的分类ID +// data.tag_ids: 可选,指定知识所属的多个分类ID export function uploadKnowledgeFile( kbId: string, data: { file: File - tag_id?: string + tag_ids?: string[] fileName?: string process_config?: KnowledgeProcessOverrides | string [key: string]: any @@ -176,7 +176,9 @@ export function uploadKnowledgeFile( Object.keys(data).forEach(key => { const value = data[key]; if (value === undefined) return; - if (key === 'process_config' && value && typeof value !== 'string') { + if (key === 'tag_ids' && Array.isArray(value)) { + formData.append(key, value.join(',')); + } else if (key === 'process_config' && value && typeof value !== 'string') { formData.append(key, JSON.stringify(value)); } else { formData.append(key, value); @@ -186,23 +188,23 @@ export function uploadKnowledgeFile( } // 从URL创建知识 -// data.tag_id: 可选,指定知识所属的分类ID +// data.tag_ids: 可选,指定知识所属的多个分类ID export function createKnowledgeFromURL( kbId: string, - data: { url: string; enable_multimodel?: boolean; tag_id?: string; process_config?: KnowledgeProcessOverrides }, + data: { url: string; enable_multimodel?: boolean; tag_ids?: string[]; process_config?: KnowledgeProcessOverrides }, ) { return post(`/api/v1/knowledge-bases/${kbId}/knowledge/url`, data); } // 手工创建知识 -// data.tag_id: 可选,指定知识所属的分类ID +// data.tag_ids: 可选,指定知识所属的分类ID export function createManualKnowledge( kbId: string, data: { title: string content: string status: string - tag_id?: string + tag_ids?: string[] process_config?: KnowledgeProcessOverrides }, ) { @@ -214,7 +216,7 @@ export function listKnowledgeFiles( params: { page: number; page_size: number; - tag_id?: string; + tag_ids?: string; keyword?: string; file_type?: string; parse_status?: string; @@ -226,7 +228,7 @@ export function listKnowledgeFiles( const query = new URLSearchParams(); query.append('page', String(params.page)); query.append('page_size', String(params.page_size)); - if (params.tag_id) query.append('tag_id', params.tag_id); + if (params.tag_ids) query.append('tag_ids', params.tag_ids); if (params.keyword) query.append('keyword', params.keyword); if (params.file_type) query.append('file_type', params.file_type); if (params.parse_status) query.append('parse_status', params.parse_status); @@ -331,7 +333,7 @@ export function deleteKnowledgeBaseTag(kbId: string, tagSeqId: number, params?: return del(`/api/v1/knowledge-bases/${kbId}/tags/${tagSeqId}${forceQuery}`); } -export function updateKnowledgeTagBatch(data: { updates: Record }) { +export function updateKnowledgeTagBatch(data: { updates: Record }) { return put(`/api/v1/knowledge/tags`, data); } diff --git a/frontend/src/components/manual-knowledge-editor.vue b/frontend/src/components/manual-knowledge-editor.vue index fdc57a32..84a14773 100644 --- a/frontend/src/components/manual-knowledge-editor.vue +++ b/frontend/src/components/manual-knowledge-editor.vue @@ -611,20 +611,20 @@ const handleSave = async (targetStatus: ManualStatus) => { saving.value = true savingAction.value = targetStatus try { - const tagIdToUpload = uiStore.selectedTagId !== '__untagged__' ? uiStore.selectedTagId : undefined + const tagIdsToUpload = uiStore.selectedTagIds.length > 0 ? [...uiStore.selectedTagIds] : undefined const payload: { title: string content: string status: string - tag_id?: string + tag_ids?: string[] process_config?: KnowledgeProcessOverrides } = { title: form.title.trim(), content: form.content, status: targetStatus, } - if (tagIdToUpload) { - payload.tag_id = tagIdToUpload + if (tagIdsToUpload && tagIdsToUpload.length > 0) { + payload.tag_ids = tagIdsToUpload } if (targetStatus === 'publish') { @@ -649,7 +649,7 @@ const handleSave = async (targetStatus: ManualStatus) => { knowledgeId: knowledgeId.value || undefined, title: payload.title, content: payload.content, - tagId: tagIdToUpload, + tagIds: tagIdsToUpload, }, }) payload.process_config = confirmResult.processConfig diff --git a/frontend/src/hooks/useKnowledgeBase.ts b/frontend/src/hooks/useKnowledgeBase.ts index eaff2a6c..eda85145 100644 --- a/frontend/src/hooks/useKnowledgeBase.ts +++ b/frontend/src/hooks/useKnowledgeBase.ts @@ -42,7 +42,7 @@ export default function (knowledgeBaseId?: string) { query: { page: number; page_size: number; - tag_id?: string; + tag_ids?: string; keyword?: string; file_type?: string; parse_status?: string; @@ -155,9 +155,9 @@ export default function (knowledgeBaseId?: string) { // 获取当前选中的分类ID const uiStore = useUIStore(); - const tagIdToUpload = uiStore.selectedTagId !== '__untagged__' ? uiStore.selectedTagId : undefined; - - uploadKnowledgeFile(currentKbId, { file, tag_id: tagIdToUpload }) + const tagIdsToUpload = uiStore.selectedTagIds.length > 0 ? [...uiStore.selectedTagIds] : undefined; + + uploadKnowledgeFile(currentKbId, { file, tag_ids: tagIdsToUpload }) .then((result: any) => { if (result.success) { MessagePlugin.info(t('knowledgeBase.uploadSuccess')); diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 81245c9c..17398172 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -370,6 +370,11 @@ export default { publishToWeb: 'Publish to Web', documentCategoryTitle: 'Document Categories', tagUpdateSuccess: 'Tag updated successfully', + tagEditDialogTitle: 'Edit Tags — {name}', + tagEditSearch: 'Search tags...', + tagSelectedCount: '{count} selected', + tagOverflowTip: 'Click to edit tags', + tagNewPlaceholder: 'New tag name, press Enter to add', category: 'Category', faqCategoryTitle: 'FAQ Categories', untagged: 'Uncategorized', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 16993a61..c21cafb0 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -372,6 +372,11 @@ export default { untagged: "미분류", tagClearAction: "분류 해제", tagUpdateSuccess: "태그 업데이트 성공", + tagEditDialogTitle: '태그 편집 — {name}', + tagEditSearch: '태그 검색...', + tagSelectedCount: '{count}개 선택됨', + tagOverflowTip: '클릭하여 태그 편집', + tagNewPlaceholder: '새 태그 이름 입력, Enter로 추가', tagSearchTooltip: "태그 검색", category: "분류", tagCreateAction: "태그 생성", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index c5209934..d5d83a1e 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -313,6 +313,11 @@ export default { tagClearAction: 'Снять категорию', tagSearchTooltip: 'Поиск тегов', tagUpdateSuccess: 'Тег успешно обновлен', + tagEditDialogTitle: 'Редактировать теги — {name}', + tagEditSearch: 'Поиск тегов...', + tagSelectedCount: 'Выбрано: {count}', + tagOverflowTip: 'Нажмите, чтобы изменить теги', + tagNewPlaceholder: 'Название нового тега, Enter для добавления', category: 'Категория', tagCreateAction: 'Создать тег', tagSearchPlaceholder: 'Введите название тега', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 578a9160..c27e7af3 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -373,6 +373,11 @@ export default { untagged: "未分类", tagClearAction: "取消分类", tagUpdateSuccess: "标签更新成功", + tagEditDialogTitle: '编辑标签 — {name}', + tagEditSearch: "搜索标签...", + tagSelectedCount: "已选 {count} 个标签", + tagOverflowTip: "点击编辑标签", + tagNewPlaceholder: "输入新标签名称,回车添加", tagSearchTooltip: "搜索标签", category: "分类", tagCreateAction: "新建标签", diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts index 3c3b15f0..49cc08d5 100644 --- a/frontend/src/stores/ui.ts +++ b/frontend/src/stores/ui.ts @@ -8,7 +8,7 @@ export const useUIStore = defineStore('ui', { currentKBId: null as string | null, kbEditorType: 'document' as 'document' | 'faq', // 当前选中的分类ID,用于文件上传时传递 - selectedTagId: '__untagged__' as string, + selectedTagIds: [] as string[], kbEditorInitialSection: null as string | null, settingsInitialSection: null as string | null, settingsInitialSubSection: null as string | null, @@ -107,8 +107,17 @@ export const useUIStore = defineStore('ui', { }, // 设置当前选中的分类ID - setSelectedTagId(tagId: string) { - this.selectedTagId = tagId + toggleSelectedTagId(tagId: string) { + const idx = this.selectedTagIds.indexOf(tagId) + if (idx >= 0) { + this.selectedTagIds.splice(idx, 1) + } else { + this.selectedTagIds.push(tagId) + } + }, + + clearSelectedTagIds() { + this.selectedTagIds = [] }, toggleSidebar() { diff --git a/frontend/src/stores/uploadConfirm.ts b/frontend/src/stores/uploadConfirm.ts index 1a298bab..c8837466 100644 --- a/frontend/src/stores/uploadConfirm.ts +++ b/frontend/src/stores/uploadConfirm.ts @@ -8,7 +8,7 @@ export interface UploadConfirmManualSource { knowledgeId?: string title: string content: string - tagId?: string + tagIds?: string[] } export interface UploadConfirmReparseSource { diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 1e901b67..94a0caaa 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -45,6 +45,7 @@ import FAQEntryManager from './components/FAQEntryManager.vue'; import DocumentListView from './components/DocumentListView.vue'; import DocumentBatchBar from './components/DocumentBatchBar.vue'; import KbUploadSourceDropdown from './components/KbUploadSourceDropdown.vue'; +import TagEditDialog from './components/TagEditDialog.vue'; import type { KnowledgeProcessOverrides } from '@/types/knowledgeProcess'; import { useUploadConfirmStore, type UploadConfirmResult } from '@/stores/uploadConfirm'; import WikiBrowser from './wiki/WikiBrowser.vue'; @@ -490,7 +491,7 @@ const confirmBatchReparse = async () => { } }; -const selectedTagId = ref(''); +const selectedTagIds = ref([]); const tagList = ref([]); const tagLoading = ref(false); const tagSearchQuery = ref(''); @@ -556,7 +557,7 @@ const disableFutureDate = { after: new Date(new Date().setHours(23, 59, 59, 999) const filterParams = computed(() => { const [start, end] = updatedTimeRange.value || []; return { - tag_id: selectedTagId.value || undefined, + tag_ids: selectedTagIds.value.length > 0 ? selectedTagIds.value.join(',') : undefined, keyword: docSearchKeyword.value ? docSearchKeyword.value.trim() : undefined, file_type: selectedFileType.value || undefined, parse_status: selectedParseStatus.value || undefined, @@ -568,10 +569,23 @@ const filterParams = computed(() => { type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>; const onPickTag = (item: any, tagId: string | number) => { if (item) item.isTagPopup = false; - const currentId = item?.tag_id ? String(item.tag_id) : ''; - const nextId = tagId ? String(tagId) : ''; - if (currentId === nextId) return; - handleKnowledgeTagChange(item.id, nextId); + const currentTags = item?.tags || []; + const tagIdStr = tagId ? String(tagId) : ''; + let updatedTags: string[]; + if (!tagIdStr) { + // Clear all tags + updatedTags = []; + } else { + const idx = currentTags.findIndex((t: any) => t.id === tagIdStr); + if (idx >= 0) { + // Remove tag + updatedTags = currentTags.filter((t: any) => t.id !== tagIdStr).map((t: any) => t.id); + } else { + // Add tag + updatedTags = [...currentTags.map((t: any) => t.id), tagIdStr]; + } + } + handleKnowledgeTagChange(item.id, updatedTags); }; const tagMap = computed>(() => { const map: Record = {}; @@ -605,6 +619,74 @@ const newTagName = ref(''); const editingTagId = ref(null); const editingTagName = ref(''); const editingTagSubmitting = ref(false); +// 标签编辑弹窗 +const tagEditDialogVisible = ref(false); +const tagEditTarget = ref(null); + +// 动态标签可见数量(根据卡片容器宽度自适应) +const TAG_EST_WIDTH = 82; // 单个标签估算宽度(含间距) +const TAG_OVERFLOW_MIN = 32; // +N 徽章预留宽度 +const tagVisibleLimit = reactive>({}); +const tagItemTotalMap = new Map(); +let tagChipsRO: ResizeObserver | null = null; + +function setupTagChipsObserver(el: Element | null, itemId: string, totalCount: number) { + if (!el) return; + const htmlEl = el as HTMLElement; + htmlEl.dataset.tagItemId = itemId; + tagItemTotalMap.set(itemId, totalCount); + if (!tagChipsRO) { + tagChipsRO = new ResizeObserver((entries) => { + for (const entry of entries) { + const target = entry.target as HTMLElement; + const id = target.dataset.tagItemId; + if (!id) continue; + const w = entry.contentRect.width; + const total = tagItemTotalMap.get(id) ?? 0; + if (total <= 0) { tagVisibleLimit[id] = 99; continue; } + // 计算能容纳几个标签:总宽 - 溢出徽章预留,除以单标签宽 + const maxFit = Math.floor((w - TAG_OVERFLOW_MIN) / TAG_EST_WIDTH); + const limit = Math.max(1, Math.min(maxFit, total)); + tagVisibleLimit[id] = limit >= total ? 99 : limit; + } + }); + } + tagChipsRO.observe(htmlEl); + // 初始计算 + requestAnimationFrame(() => { + const w = htmlEl.clientWidth; + const total = totalCount; + if (total <= 0) { tagVisibleLimit[itemId] = 99; return; } + const maxFit = Math.floor((w - TAG_OVERFLOW_MIN) / TAG_EST_WIDTH); + const limit = Math.max(1, Math.min(maxFit, total)); + tagVisibleLimit[itemId] = limit >= total ? 99 : limit; + }); +} + +function getTagLimit(itemId: string): number { + return tagVisibleLimit[itemId] ?? 99; +} + +function hasTagOverflow(itemId: string, total: number): boolean { + const limit = tagVisibleLimit[itemId] ?? 99; + return total > limit; +} + +function getOverflowCount(itemId: string, total: number): number { + const limit = tagVisibleLimit[itemId] ?? 99; + return Math.max(0, total - limit); +} + +function openTagEditDialog(item: KnowledgeCard) { + tagEditTarget.value = item; + tagEditDialogVisible.value = true; +} + +function onTagEditConfirm(tagIds: string[]) { + if (tagEditTarget.value) { + handleKnowledgeTagChange(tagEditTarget.value.id, tagIds); + } +} const getPageSize = () => { const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const itemHeight = 148; @@ -731,10 +813,11 @@ const loadTags = async (kbIdValue: string, reset = false) => { } }; -const handleTagFilterChange = (value: string) => { - selectedTagId.value = value; - // 同步更新 store 中的 selectedTagId,供 menu.vue 上传时使用 - uiStore.setSelectedTagId(value); +const handleTagFilterChange = (tagIds: string[]) => { + selectedTagIds.value = tagIds; + // 同步更新 store 中的 selectedTagIds,供 menu.vue 上传时使用 + uiStore.clearSelectedTagIds(); + tagIds.forEach(id => uiStore.toggleSelectedTagId(id)); resetPage(); loadKnowledgeFiles(kbId.value); }; @@ -748,11 +831,14 @@ const handleTagRowClick = (tagId: string) => { editingTagId.value = null; editingTagName.value = ''; } - if (selectedTagId.value === tagId) { - handleTagFilterChange(''); - return; + // Toggle selection + const idx = selectedTagIds.value.indexOf(tagId); + if (idx >= 0) { + selectedTagIds.value.splice(idx, 1); + } else { + selectedTagIds.value.push(tagId); } - handleTagFilterChange(tagId); + handleTagFilterChange([...selectedTagIds.value]); }; const startCreateTag = () => { @@ -862,28 +948,30 @@ const confirmDeleteTag = (tag: any) => { deleteKnowledgeBaseTag(kbId.value, tag.seq_id, { force: true }) .then(() => { MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess')); - if (selectedTagId.value === tag.id) { + if (selectedTagIds.value.includes(tag.id)) { // Reset to show all entries when current tag is deleted - selectedTagId.value = ''; - handleTagFilterChange(''); + selectedTagIds.value = selectedTagIds.value.filter(id => id !== tag.id); + handleTagFilterChange([...selectedTagIds.value]); } loadTags(kbId.value, true); - // 由于后端是异步删除文档,延迟刷新以确保看到最新数据 + // 由于后端是异步删除文档,延迟刷新文档列表 setTimeout(() => { resetPage(); // Reset page counter when reloading files after tag deletion loadKnowledgeFiles(kbId.value); - }, 500); + }, 800); + // 再次延迟刷新标签列表,确保异步删除完成后其他标签的数量正确更新 + setTimeout(() => { + loadTags(kbId.value, true); + }, 2500); }) .catch((error: any) => { MessagePlugin.error(error?.message || t('common.operationFailed')); }); }; -const handleKnowledgeTagChange = async (knowledgeId: string, tagValue: string) => { +const handleKnowledgeTagChange = async (knowledgeId: string, tagIds: string[]) => { try { - // Pass the tag value directly (empty string means no tag) - const tagIdToUpdate = tagValue || null; - await updateKnowledgeTagBatch({ updates: { [knowledgeId]: tagIdToUpdate } }); + await updateKnowledgeTagBatch({ updates: { [knowledgeId]: tagIds } }); MessagePlugin.success(t('knowledgeBase.tagUpdateSuccess')); resetPage(); // Reset page counter to 1 when reloading files after tag change loadKnowledgeFiles(kbId.value); @@ -906,9 +994,10 @@ const loadKnowledgeBaseInfo = async (targetKbId: string, force = false) => { if (!isCurrentKb(targetKbId)) return; kbInfo.value = data; - selectedTagId.value = ''; + selectedTagIds.value = []; + uiStore.clearSelectedTagIds(); // 重置store中的标签选择状态,避免上传文档时自动带上之前选择的标签 - uiStore.setSelectedTagId(''); + uiStore.clearSelectedTagIds(); if (!isFAQ.value) { docListLoading.value = true; loadKnowledgeFiles(targetKbId); @@ -988,14 +1077,14 @@ watch(() => kbId.value, (newKbId, oldKbId) => { resetPage(); tagSearchQuery.value = ''; tagPage.value = 1; - uiStore.setSelectedTagId(''); + uiStore.clearSelectedTagIds(); } loadKnowledgeBaseInfo(newKbId); }, { immediate: true }); -watch(selectedTagId, (newVal, oldVal) => { +watch(selectedTagIds, (newVal, oldVal) => { if (oldVal === undefined) return - if (newVal !== oldVal && kbId.value) { + if (kbId.value) { loadKnowledgeFiles(kbId.value); } }); @@ -1173,7 +1262,7 @@ type KnowledgeCard = { isMore?: boolean; metadata?: any; error_message?: string; - tag_id?: string; + tags?: Array<{ id: string; name: string; color?: string }>; }; // needsStatusPolling decides whether a card row is still "in flight" // enough that the doc list should keep refreshing it. Keep in sync with @@ -1530,7 +1619,7 @@ const executeUploadBatch = async ( return { successCount: 0, failCount: files.length }; } - const tagIdToUpload = selectedTagId.value !== '__untagged__' ? selectedTagId.value : undefined; + const tagIdsToUpload = selectedTagIds.value.length > 0 ? [...selectedTagIds.value] : undefined; let successCount = 0; let failCount = 0; const totalCount = files.length; @@ -1543,10 +1632,10 @@ const executeUploadBatch = async ( try { const uploadData: { file: File - tag_id?: string + tag_ids?: string[] fileName?: string process_config?: KnowledgeProcessOverrides - } = { file, tag_id: tagIdToUpload }; + } = { file, tag_ids: tagIdsToUpload }; const fileName = getFolderUploadFileName(file); if (fileName) uploadData.fileName = fileName; @@ -1602,11 +1691,11 @@ const executeUrlImport = async (url: string, processConfig?: KnowledgeProcessOve return; } - const tagIdToUpload = selectedTagId.value !== '__untagged__' ? selectedTagId.value : undefined; + const tagIdsToUpload = selectedTagIds.value.length > 0 ? [...selectedTagIds.value] : undefined; try { const responseData: any = await createKnowledgeFromURL(targetKbId, { url, - tag_id: tagIdToUpload, + tag_ids: tagIdsToUpload, process_config: processConfig, }); window.dispatchEvent(new CustomEvent('knowledgeFileUploaded', { @@ -2010,7 +2099,7 @@ const handleListAction = ( // Clear selection on filter/tag/kb change to avoid acting on hidden items. watch( - [selectedTagId, docSearchKeyword, selectedFileType, selectedParseStatus, selectedSource, updatedTimeRange, kbId], + [selectedTagIds, docSearchKeyword, selectedFileType, selectedParseStatus, selectedSource, updatedTimeRange, kbId], () => { clearSelection(); }, @@ -2223,7 +2312,7 @@ async function createNewSession(value: string): Promise {