From c62ff4c90613613ae149bb1f22ffa55013ceace9 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Wed, 29 Apr 2026 15:15:55 +0800 Subject: [PATCH] fix(frontend): knowledge document list layout, batch bar, and selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Platform route outlet: flex min-height so document area is not collapsed - Card grid: stable :key=item.id; clamp selection anchor after list reload - DocumentListView: TDesign checkbox, sticky header, neutral file icons - DocumentBatchBar: column-footer placement; toned style vs floating pill - Clear selection i18n: less ambiguous wording (e.g. 取消选择) - Batch delete: poll list refresh after async queue; getKnowled returns Promise - files.ts: use file icon for text types; align chat/upload icon usage --- frontend/src/components/AttachmentUpload.vue | 2 +- frontend/src/hooks/useKnowledgeBase.ts | 8 +- frontend/src/i18n/locales/en-US.ts | 2 +- frontend/src/i18n/locales/ko-KR.ts | 2 +- frontend/src/i18n/locales/ru-RU.ts | 2 +- frontend/src/i18n/locales/zh-CN.ts | 2 +- frontend/src/utils/files.ts | 3 +- .../src/views/chat/components/usermsg.vue | 2 +- .../src/views/knowledge/KnowledgeBase.vue | 199 ++++++++----- .../knowledge/components/DocumentBatchBar.vue | 100 ++++--- .../knowledge/components/DocumentListView.vue | 276 ++++++++++++------ frontend/src/views/platform/index.vue | 16 +- 12 files changed, 395 insertions(+), 219 deletions(-) diff --git a/frontend/src/components/AttachmentUpload.vue b/frontend/src/components/AttachmentUpload.vue index 1588256b2..c29fff0db 100644 --- a/frontend/src/components/AttachmentUpload.vue +++ b/frontend/src/components/AttachmentUpload.vue @@ -116,7 +116,7 @@ const getFileIcon = (fileName: string): string => { if (['doc', 'docx'].includes(ext || '')) return 'file-word'; if (['xls', 'xlsx'].includes(ext || '')) return 'file-excel'; if (['ppt', 'pptx'].includes(ext || '')) return 'file-powerpoint'; - if (['txt', 'md'].includes(ext || '')) return 'file-text'; + if (['txt', 'md'].includes(ext || '')) return 'file'; if (['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac'].includes(ext || '')) return 'sound'; return 'file'; }; diff --git a/frontend/src/hooks/useKnowledgeBase.ts b/frontend/src/hooks/useKnowledgeBase.ts index 117e1d7f6..58e708937 100644 --- a/frontend/src/hooks/useKnowledgeBase.ts +++ b/frontend/src/hooks/useKnowledgeBase.ts @@ -38,11 +38,11 @@ export default function (knowledgeBaseId?: string) { const getKnowled = ( query: { page: number; page_size: number; tag_id?: string; keyword?: string; file_type?: string } = { page: 1, page_size: 35 }, kbId?: string, - ) => { + ): Promise => { const targetKbId = kbId || knowledgeBaseId; - if (!targetKbId) return; - - listKnowledgeFiles(targetKbId, query) + if (!targetKbId) return Promise.resolve(); + + return listKnowledgeFiles(targetKbId, query) .then((result: any) => { const { data, total: totalResult } = result; const cardList_ = data.map((item: any) => { diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index fd962c71a..74f1ba871 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -255,7 +255,7 @@ export default { columnActions: 'Actions', selectAll: 'Select all', selectedCount: '{count} selected', - clearSelection: 'Clear', + clearSelection: 'Deselect all', batchDelete: 'Delete selected', batchDeleteConfirmation: 'Confirm Batch Delete', confirmBatchDeleteDocument: 'Delete {count} selected documents? This action cannot be undone.', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 1878d228b..2179f17a5 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -259,7 +259,7 @@ export default { columnActions: "작업", selectAll: "전체 선택", selectedCount: "{count}개 선택됨", - clearSelection: "지우기", + clearSelection: "선택 해제", batchDelete: "선택 삭제", batchDeleteConfirmation: "일괄 삭제 확인", confirmBatchDeleteDocument: "선택한 {count}개 문서를 삭제하시겠습니까? 삭제 후 복구할 수 없습니다.", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index d77c85f2b..7eb834edc 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -227,7 +227,7 @@ export default { columnActions: 'Действия', selectAll: 'Выбрать все', selectedCount: 'Выбрано: {count}', - clearSelection: 'Очистить', + clearSelection: 'Снять выделение', batchDelete: 'Удалить выбранные', batchDeleteConfirmation: 'Подтверждение пакетного удаления', confirmBatchDeleteDocument: 'Удалить {count} выбранных документов? Это действие нельзя отменить.', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 03faa171d..28f9d837b 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -256,7 +256,7 @@ export default { columnActions: "操作", selectAll: "全选", selectedCount: "已选 {count} 项", - clearSelection: "清空", + clearSelection: "取消选择", batchDelete: "批量删除", batchDeleteConfirmation: "批量删除确认", confirmBatchDeleteDocument: "确认删除选中的 {count} 个文档?删除后将无法恢复。", diff --git a/frontend/src/utils/files.ts b/frontend/src/utils/files.ts index fe4831f19..163c0f7b0 100644 --- a/frontend/src/utils/files.ts +++ b/frontend/src/utils/files.ts @@ -31,7 +31,8 @@ export function getFileIcon(input: string | { type?: string; file_type?: string; if (['doc', 'docx'].includes(ext)) return 'file-word'; if (['xls', 'xlsx', 'csv'].includes(ext)) return 'file-excel'; if (['ppt', 'pptx'].includes(ext)) return 'file-powerpoint'; - if (['txt', 'md', 'markdown'].includes(ext)) return 'file-text'; + // TDesign 无稳定 `file-text` 字形时会导致空白,纯文类统一用 `file` + if (['txt', 'md', 'markdown', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) return 'file'; if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(ext)) return 'image'; if (['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac'].includes(ext)) return 'sound'; return 'file'; diff --git a/frontend/src/views/chat/components/usermsg.vue b/frontend/src/views/chat/components/usermsg.vue index 5c4a1d5a8..b9637c30b 100644 --- a/frontend/src/views/chat/components/usermsg.vue +++ b/frontend/src/views/chat/components/usermsg.vue @@ -115,7 +115,7 @@ const getAttachmentIcon = (fileNameOrType) => { if (['doc', 'docx'].includes(ext)) return 'file-word'; if (['xls', 'xlsx'].includes(ext)) return 'file-excel'; if (['ppt', 'pptx'].includes(ext)) return 'file-powerpoint'; - if (['txt', 'md'].includes(ext)) return 'file-text'; + if (['txt', 'md'].includes(ext)) return 'file'; if (['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac'].includes(ext)) return 'sound'; return 'file'; }; diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index d04cd1aca..0c8a53f28 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -413,9 +413,9 @@ const getKnowledgeType = (item: any) => { return '--'; } -const loadKnowledgeFiles = (kbIdValue: string) => { - if (!kbIdValue) return; - getKnowled( +const loadKnowledgeFiles = (kbIdValue: string): Promise => { + if (!kbIdValue) return Promise.resolve(); + return getKnowled( { page: 1, page_size: pageSize, @@ -1632,6 +1632,11 @@ const toggleSelectRow = (id: string, checked: boolean, shiftKey?: boolean) => { lastSelectedIndex = idx; }; +const onCardGridCheckboxChange = (id: string, checked: boolean, ctx?: { e?: Event }) => { + const me = ctx?.e as MouseEvent | undefined; + toggleSelectRow(id, checked, !!me?.shiftKey); +}; + const toggleSelectAll = (checked: boolean) => { if (checked) { for (const item of cardList.value || []) selectedIds.value.add(item.id); @@ -1653,6 +1658,7 @@ const openBatchDeleteDialog = () => { const confirmBatchDelete = async () => { if (batchDeleting.value || selectedIds.value.size === 0) return; const ids = Array.from(selectedIds.value); + const deletedIdSet = new Set(ids); batchDeleting.value = true; try { const res: any = await batchDeleteKnowledge(kbId.value, ids); @@ -1661,7 +1667,15 @@ const confirmBatchDelete = async () => { clearSelection(); batchDeleteDialog.value = false; page = 1; - loadKnowledgeFiles(kbId.value); + // 后端将批量删除放入异步队列,立刻拉列表仍可能包含待删项;短轮询直到列表与后端一致或超时 + const maxPolls = 30; + const delayMs = 400; + for (let i = 0; i < maxPolls; i++) { + await loadKnowledgeFiles(kbId.value); + const stillPresent = (cardList.value || []).some((c: KnowledgeCard) => deletedIdSet.has(c.id)); + if (!stillPresent) break; + await new Promise((r) => setTimeout(r, delayMs)); + } loadTags(kbId.value); } else { MessagePlugin.error(res?.message || t('knowledgeBase.batchDeleteFailed')); @@ -1690,10 +1704,18 @@ watch([selectedTagId, docSearchKeyword, selectedFileType, kbId], () => { clearSelection(); }); -// After cardList reloads, drop ids that are no longer present. +// After cardList reloads: stable keys rely on correct indices for shift-range; clamp anchor index. watch(cardList, () => { + const items = cardList.value || []; + const n = items.length; + if (lastSelectedIndex >= n) { + lastSelectedIndex = n > 0 ? n - 1 : -1; + } + if (moreIndex.value >= n) { + moreIndex.value = -1; + } if (selectedIds.value.size === 0) return; - const visible = new Set((cardList.value || []).map((i: KnowledgeCard) => i.id)); + const visible = new Set(items.map((i: KnowledgeCard) => i.id)); for (const id of selectedIds.value) { if (!visible.has(id)) selectedIds.value.delete(id); } @@ -2144,27 +2166,28 @@ async function createNewSession(value: string): Promise { class="knowledge-card" :class="{ 'is-selected': selectedIds.has(item.id), 'has-selection': selectedIds.size > 0 }" v-for="(item, index) in cardList" - :key="index" + :key="item.id" @click="openCardDetails(item)" @mouseenter="onCardMouseEnter($event, item)" @mousemove="onCardMouseMove($event)" @mouseleave="onCardMouseLeave" > -
+
+ +
{{ item.file_name }} {
+
+
{ flex: 1; width: 100%; min-width: 0; - padding: 24px 32px 32px; + padding: 24px 32px 0px; box-sizing: border-box; } @@ -3112,6 +3137,12 @@ async function createNewSession(value: string): Promise { } } +/* 批量条在滚动区外,始终贴主内容列底部,不随列表高度在列内上下漂移 */ +.doc-batch-bar-anchor { + flex-shrink: 0; + padding-top: 4px; +} + // Header 样式(无底部分割线,留更多空间给下方内容区) .document-header { display: flex; @@ -3388,6 +3419,7 @@ async function createNewSession(value: string): Promise { .faq-manager-wrapper { flex: 1; + min-height: 0; padding: 24px 32px; overflow-y: auto; margin: 0 16px 0 4px; @@ -3442,7 +3474,7 @@ async function createNewSession(value: string): Promise { box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); - gap: 14px; + gap: 16px; align-content: flex-start; width: 100%; @@ -3453,14 +3485,24 @@ async function createNewSession(value: string): Promise { .knowledge-card-skeleton { cursor: default; - .card-content { padding: 15px 17px 13px; } - .card-content-nav { margin-bottom: 14px; } + + .card-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 12px 16px 8px; + } + + .card-content-nav { + margin-bottom: 8px; + } + .card-bottom { - position: absolute; - bottom: 0; - left: 0; + flex-shrink: 0; + margin-top: auto; width: 100%; - padding: 0 17px; + padding: 0 16px; box-sizing: border-box; height: 34px; display: flex; @@ -3747,6 +3789,7 @@ async function createNewSession(value: string): Promise { align-items: center; gap: 8px; padding: 6px 0; + flex-shrink: 0; } .card-draft-tip { @@ -3756,65 +3799,77 @@ async function createNewSession(value: string): Promise { .knowledge-card { min-width: 248px; - border: 1px solid var(--td-component-stroke); + display: flex; + flex-direction: column; + border: 1px solid color-mix(in srgb, var(--td-component-stroke) 82%, var(--td-bg-color-secondarycontainer)); height: 148px; border-radius: 9px; overflow: hidden; box-sizing: border-box; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.035); background: var(--td-bg-color-container); position: relative; cursor: pointer; transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; - &.is-selected { - border-color: var(--td-brand-color, #0052d9); - box-shadow: 0 0 0 1px var(--td-brand-color, #0052d9), 0 4px 12px rgba(0, 82, 217, 0.08); - background: var(--td-brand-color-1, #f0f6ff); - } - - .card-select-overlay { - position: absolute; - top: 8px; - left: 8px; - width: 22px; - height: 22px; + /* 初始态略可见,悬停/多选/已选时再强调,避免「凭空多一列」的突兀感 */ + .card-nav-check { + flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; - background: var(--td-bg-color-container, #fff); - border: 1px solid var(--td-component-border, #e0e0e0); - border-radius: 6px; + width: 22px; + height: 29px; + opacity: 0.4; + transition: opacity 0.2s ease; cursor: pointer; - opacity: 0; - transition: opacity 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; - z-index: 1; - - input[type='checkbox'] { - width: 14px; - height: 14px; - margin: 0; - cursor: pointer; - accent-color: var(--td-brand-color, #0052d9); - } &.active, &:focus-within { opacity: 1; - border-color: var(--td-brand-color, #0052d9); + } + + .card-select-checkbox { + margin: 0; + line-height: 0; + + :deep(.t-checkbox) { + align-items: center; + } + + :deep(.t-checkbox__label) { + display: none !important; + width: 0 !important; + min-width: 0 !important; + margin: 0 !important; + padding: 0 !important; + } + + :deep(.t-checkbox__input) { + margin: 0; + } + + :deep(.t-checkbox__input-wrapper) { + margin: 0; + } } } - &:hover .card-select-overlay, - &.has-selection .card-select-overlay { + &:hover .card-nav-check, + &.has-selection .card-nav-check { opacity: 1; } .card-content { - padding: 15px 17px 13px; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 12px 16px 8px; } .card-analyze { + flex-shrink: 0; height: 52px; display: flex; } @@ -3838,11 +3893,11 @@ async function createNewSession(value: string): Promise { } .card-content-nav { + flex-shrink: 0; display: flex; - justify-content: space-between; align-items: flex-start; - margin-bottom: 11px; gap: 8px; + margin-bottom: 8px; } .card-content-title { @@ -3886,6 +3941,8 @@ async function createNewSession(value: string): Promise { } .card-content-txt { + flex: 1; + min-height: 0; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; @@ -3899,9 +3956,9 @@ async function createNewSession(value: string): Promise { } .card-bottom { - position: absolute; - bottom: 0; - padding: 0 17px; + flex-shrink: 0; + margin-top: auto; + padding: 0 16px; box-sizing: border-box; height: 34px; width: 100%; @@ -3920,19 +3977,19 @@ async function createNewSession(value: string): Promise { } .card-type { - color: var(--td-text-color-secondary); + color: var(--td-text-color-placeholder); font-family: "PingFang SC"; font-size: 11px; font-weight: 500; - padding: 3px 8px; - background: var(--td-bg-color-secondarycontainer); - border-radius: 4px; + padding: 0; + background: transparent; + letter-spacing: 0.02em; } } .knowledge-card:hover { - border-color: var(--td-brand-color); - box-shadow: 0 2px 8px rgba(7, 192, 95, 0.12); + border-color: color-mix(in srgb, var(--td-component-stroke) 55%, var(--td-brand-color)); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07); } /* 悬停知识卡片时跟随鼠标的详情气泡 */ diff --git a/frontend/src/views/knowledge/components/DocumentBatchBar.vue b/frontend/src/views/knowledge/components/DocumentBatchBar.vue index bf2fd30b1..24a37c642 100644 --- a/frontend/src/views/knowledge/components/DocumentBatchBar.vue +++ b/frontend/src/views/knowledge/components/DocumentBatchBar.vue @@ -17,23 +17,25 @@ const { t } = useI18n();