From 7b71b58008a8fe01950cee322c0faed17a18d29d Mon Sep 17 00:00:00 2001 From: lyh131201 Date: Fri, 7 Aug 2026 12:14:59 +0800 Subject: [PATCH] feat(frontend): select all documents across pages The document list loads incrementally via infinite scroll, so the header select-all checkbox could only select the loaded slice; reaching documents further down required scrolling until every page had loaded. Fetch all ids matching the current filters page by page (page_size 1000, the backend maximum) when select-all is checked. While fetching, the batch action bar shows a loading indicator and disables its action buttons so operations don't run on an incomplete selection. Cancelling select-all, switching filters or knowledge bases mid-fetch discards the in-flight result via a generation counter, and the selection is no longer pruned against the loaded window while cross-page select-all is active. --- .../src/views/knowledge/KnowledgeBase.vue | 59 +++++++++++++++++-- .../knowledge/components/DocumentBatchBar.vue | 18 ++++-- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 1b1963bfce..405d8715fd 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -35,6 +35,7 @@ import { batchReparseKnowledge, getKnowledgeSpans, getKnowledgeDetails, + listKnowledgeFiles, listKnowledgeFolders, moveKnowledgeToFolder, renameKnowledgeFolder, @@ -1999,15 +2000,57 @@ const onCardGridCheckboxChange = (id: string, checked: boolean, ctx?: { e?: Even toggleSelectRow(id, checked, !!me?.shiftKey); }; -const toggleSelectAll = (checked: boolean) => { - if (checked) { - for (const item of cardList.value || []) selectedIds.value.add(item.id); - } else { - for (const item of cardList.value || []) selectedIds.value.delete(item.id); +// Select all across pages: the list is loaded incrementally via infinite +// scroll, so cardList only holds the loaded window. When the header checkbox +// is checked, fetch every document id matching the current filters from the +// backend page by page; otherwise only the loaded slice would be selected. +// The backend caps page_size at 1000. +const SELECT_ALL_PAGE_SIZE = 1000; +const selectAllAcrossPages = ref(false); +const selectAllLoading = ref(false); +let selectAllGeneration = 0; + +const toggleSelectAll = async (checked: boolean) => { + if (!checked) { + clearSelection(); + return; + } + for (const item of cardList.value || []) selectedIds.value.add(item.id); + selectAllAcrossPages.value = true; + if (cardList.value.length >= total.value) return; + const currentKbId = kbId.value; + if (!currentKbId) return; + const generation = ++selectAllGeneration; + selectAllLoading.value = true; + try { + let pageNum = 1; + while (true) { + const res: any = await listKnowledgeFiles(currentKbId, { + page: pageNum, + page_size: SELECT_ALL_PAGE_SIZE, + ...filterParams.value, + }); + // The user cancelled select-all or switched filters/kb while fetching: + // discard this result. + if (generation !== selectAllGeneration || !isCurrentKb(currentKbId)) return; + const items = res?.data || []; + const totalAll = res?.total || 0; + for (const it of items) { + if (it?.id) selectedIds.value.add(it.id); + } + if (items.length === 0 || pageNum * SELECT_ALL_PAGE_SIZE >= totalAll) break; + pageNum++; + } + } catch { + // Keep the partially selected ids if the fetch fails midway. + } finally { + if (generation === selectAllGeneration) selectAllLoading.value = false; } }; const clearSelection = () => { + selectAllGeneration++; + selectAllAcrossPages.value = false; selectedIds.value.clear(); lastSelectedIndex = -1; }; @@ -2197,6 +2240,9 @@ watch(cardList, () => { moreIndex.value = -1; } if (selectedIds.value.size === 0) return; + // With select-all across pages, selectedIds intentionally contains ids + // outside the loaded window; don't prune them against the visible list. + if (selectAllAcrossPages.value) return; const visible = new Set(items.map((i: KnowledgeCard) => i.id)); for (const id of selectedIds.value) { if (!visible.has(id)) selectedIds.value.delete(id); @@ -2629,7 +2675,8 @@ async function createNewSession(value: string): Promise {
+ {{ t('knowledgeBase.selectedCount', { count }) }} {{ t('knowledgeBase.clearSelection') }} @@ -45,14 +49,14 @@ const folderPickerVisible = ref(false); :confirm-btn="{ content: t('knowledgeBase.confirmBatchReparse'), theme: 'warning' }" :cancel-btn="{ content: t('common.cancel') }" placement="top" @confirm="emit('reparse')"> + :disabled="count === 0 || deleteLoading || reparseLoading || tagLoading || selectAllLoading" :loading="reparseLoading" @click.stop> {{ t('knowledgeBase.rebuildDocument') }} {{ t('knowledgeBase.batchTag') }} @@ -61,7 +65,7 @@ const folderPickerVisible = ref(false); + :disabled="count === 0 || deleteLoading || reparseLoading || tagLoading || selectAllLoading"> {{ t('knowledgeBase.moveToFolder.action') }} @@ -77,7 +81,7 @@ const folderPickerVisible = ref(false); :confirm-btn="{ content: t('knowledgeBase.confirmDelete'), theme: 'danger' }" :cancel-btn="{ content: t('common.cancel') }" placement="top" @confirm="emit('delete')"> + :disabled="count === 0 || deleteLoading || reparseLoading || tagLoading || selectAllLoading" :loading="deleteLoading" @click.stop> {{ t('knowledgeBase.batchDelete') }} @@ -126,6 +130,12 @@ const folderPickerVisible = ref(false); white-space: nowrap; } +.batch-bar-loading { + flex-shrink: 0; + display: inline-flex; + align-items: center; +} + .batch-bar-clear { flex-shrink: 0; padding: 0 6px !important;