fix: paginate knowledge base dashboard lists (#9055)

* fix: paginate knowledge base dashboard lists

* fix: preserve knowledge document search pagination
This commit is contained in:
lxfight
2026-06-28 14:00:45 +08:00
committed by GitHub
parent 3d4c4ed01b
commit 758e43273d
10 changed files with 221 additions and 28 deletions

View File

@@ -219,25 +219,45 @@ class KBSQLiteDatabase:
kb_id: str,
offset: int = 0,
limit: int = 100,
search: str | None = None,
) -> list[KBDocument]:
"""列出知识库的所有文档"""
"""List documents in a knowledge base.
Args:
kb_id: Knowledge base ID.
offset: Number of documents to skip.
limit: Maximum number of documents to return.
search: Optional partial match on document name; disabled when None or empty.
Returns:
List of matching KBDocument rows.
"""
async with self.get_db() as session:
stmt = select(KBDocument).where(col(KBDocument.kb_id) == kb_id)
if search:
stmt = stmt.where(col(KBDocument.doc_name).contains(search))
stmt = (
select(KBDocument)
.where(col(KBDocument.kb_id) == kb_id)
.offset(offset)
.limit(limit)
.order_by(desc(KBDocument.created_at))
stmt.offset(offset).limit(limit).order_by(desc(KBDocument.created_at))
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def count_documents_by_kb(self, kb_id: str) -> int:
"""统计知识库的文档数量"""
async def count_documents_by_kb(self, kb_id: str, search: str | None = None) -> int:
"""Count documents in a knowledge base.
Args:
kb_id: Knowledge base ID.
search: Optional partial match on document name; disabled when None or empty.
Returns:
Total number of matching documents.
"""
async with self.get_db() as session:
stmt = select(func.count(col(KBDocument.id))).where(
col(KBDocument.kb_id) == kb_id,
)
if search:
stmt = stmt.where(col(KBDocument.doc_name).contains(search))
result = await session.execute(stmt)
return result.scalar() or 0

View File

@@ -469,11 +469,37 @@ class KBHelper:
self,
offset: int = 0,
limit: int = 100,
search: str | None = None,
) -> list[KBDocument]:
"""列出知识库的所有文档"""
docs = await self.kb_db.list_documents_by_kb(self.kb.kb_id, offset, limit)
"""List documents in the knowledge base.
Args:
offset: Number of documents to skip.
limit: Maximum number of documents to return.
search: Optional partial match on document name; disabled when None or empty.
Returns:
List of matching KBDocument rows.
"""
docs = await self.kb_db.list_documents_by_kb(
self.kb.kb_id,
offset,
limit,
search=search,
)
return docs
async def count_documents(self, search: str | None = None) -> int:
"""Count documents in the knowledge base.
Args:
search: Optional partial match on document name; disabled when None or empty.
Returns:
Total number of matching documents.
"""
return await self.kb_db.count_documents_by_kb(self.kb.kb_id, search=search)
async def get_document(self, doc_id: str) -> KBDocument | None:
"""获取单个文档"""
doc = await self.kb_db.get_document_by_id(doc_id)

View File

@@ -182,6 +182,7 @@ async def list_knowledge_base_documents(
kb_id=kb_id,
page=_to_int(request.query_params.get("page"), 1),
page_size=_to_int(request.query_params.get("page_size"), 100),
search=request.query_params.get("search"),
),
prefix="获取文档列表失败",
)
@@ -390,6 +391,7 @@ async def dashboard_list_documents(
kb_id=request.query_params.get("kb_id"),
page=_to_int(request.query_params.get("page"), 1),
page_size=_to_int(request.query_params.get("page_size"), 100),
search=request.query_params.get("search"),
),
prefix="获取文档列表失败",
)

View File

@@ -266,16 +266,24 @@ class KnowledgeBaseService:
async def list_kbs(self, *, page: int, page_size: int) -> dict[str, Any]:
kb_manager = self.get_kb_manager()
kbs = await kb_manager.list_kbs()
total = len(kbs)
# Clamp page and page_size to at least 1 before calculating offsets/slices.
page = max(page, 1)
page_size = max(page_size, 1)
start = (page - 1) * page_size
end = start + page_size
paged_kbs = kbs[start:end]
kb_list = []
for kb in kbs:
for kb in paged_kbs:
kb_dict = kb.model_dump()
kb_helper = await kb_manager.get_kb(kb.kb_id)
if kb_helper and kb_helper.init_error:
kb_dict["init_error"] = kb_helper.init_error
kb_list.append(kb_dict)
return {"items": kb_list, "page": page, "page_size": page_size}
return {"items": kb_list, "page": page, "page_size": page_size, "total": total}
async def list_kbs_from_dashboard_query(self, *, page, page_size) -> dict[str, Any]:
return await self.list_kbs(
@@ -437,6 +445,7 @@ class KnowledgeBaseService:
kb_id: str | None,
page: int,
page_size: int,
search: str | None = None,
) -> dict[str, Any]:
if not kb_id:
raise KnowledgeBaseServiceError("缺少参数 kb_id")
@@ -444,12 +453,25 @@ class KnowledgeBaseService:
if not kb_helper:
raise KnowledgeBaseServiceError("知识库不存在")
if search is not None:
search = search.strip()
if not search:
search = None
page = max(page, 1)
page_size = max(page_size, 1)
offset = (page - 1) * page_size
doc_list = await kb_helper.list_documents(offset=offset, limit=page_size)
doc_list = await kb_helper.list_documents(
offset=offset,
limit=page_size,
search=search,
)
total = await kb_helper.count_documents(search=search)
return {
"items": [doc.model_dump() for doc in doc_list],
"page": page,
"page_size": page_size,
"total": total,
}
async def list_documents_from_dashboard_query(
@@ -458,11 +480,13 @@ class KnowledgeBaseService:
kb_id: str | None,
page,
page_size,
search: str | None = None,
) -> dict[str, Any]:
return await self.list_documents(
kb_id=kb_id,
page=self._to_int(page, 1),
page_size=self._to_int(page_size, 100),
search=search,
)
async def upload_document(

View File

@@ -2661,6 +2661,10 @@ export type ListKnowledgeDocumentsData = {
query?: {
page?: number;
page_size?: number;
/**
* Filter documents by name (case-insensitive partial match).
*/
search?: string;
};
};

View File

@@ -1384,7 +1384,7 @@ export const knowledgeApi = {
openApiV1.deleteKnowledgeBase({ path: { kb_id: kbId } }),
);
},
documents(kbId: string, params?: { page?: number; page_size?: number }) {
documents(kbId: string, params?: { page?: number; page_size?: number; search?: string }) {
return typed<any>(
openApiV1.listKnowledgeDocuments({
path: { kb_id: kbId },

View File

@@ -79,6 +79,15 @@
</v-tooltip>
</template>
</OutlinedActionListItem>
<v-pagination
v-if="total > pageSize"
v-model="page"
:length="Math.ceil(total / pageSize)"
:total-visible="7"
class="mt-4"
@update:model-value="loadKnowledgeBases()"
/>
</div>
<!-- 空状态 -->
@@ -269,6 +278,9 @@ const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const kbList = ref<any[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const embeddingProviders = ref<any[]>([])
const rerankProviders = ref<any[]>([])
const originalEmbeddingProvider = ref<string | null>(null)
@@ -324,18 +336,18 @@ const emojiCategories = [
const loadKnowledgeBases = async (refreshStats = false) => {
loading.value = true
try {
const params: any = {}
if (refreshStats) {
params.refresh_stats = 'true'
page.value = 1
}
const response = await knowledgeApi.list({
page: params.page,
page_size: params.page_size,
refresh_stats: params.refresh_stats === 'true'
page: page.value,
page_size: pageSize.value,
refresh_stats: refreshStats
})
if (response.data.status === 'ok') {
kbList.value = response.data.data.items || []
const data = response.data.data
kbList.value = data.items || []
total.value = data.total || 0
} else {
showSnackbar(response.data.message || t('messages.loadError'), 'error')
}
@@ -407,7 +419,9 @@ const deleteKB = async () => {
if (response.data.status === 'ok') {
showSnackbar(t('messages.deleteSuccess'))
// 先刷新列表,再关闭对话框
if (kbList.value.length === 1 && page.value > 1) {
page.value -= 1
}
await loadKnowledgeBases()
showDeleteDialog.value = false
deleteTarget.value = null

View File

@@ -11,7 +11,9 @@
<!-- 文档列表 -->
<v-card variant="outlined">
<v-data-table :headers="headers" :items="documents" :loading="loading" :search="searchQuery" :items-per-page="10">
<v-data-table-server :headers="headers" :items="documents" :loading="loading"
:items-per-page="pageSize" :page="page" :items-length="total"
@update:page="onPageChange" @update:items-per-page="onItemsPerPageChange">
<template #item.doc_name="{ item }">
<div class="d-flex align-center gap-2">
<v-icon :color="getFileColor(item.file_type)" class="mr-2">
@@ -53,7 +55,7 @@
<p class="mt-4 text-medium-emphasis">{{ t('documents.empty') }}</p>
</div>
</template>
</v-data-table>
</v-data-table-server>
</v-card>
<!-- 上传对话框 -->
@@ -236,7 +238,7 @@
<script setup lang="ts">
import TavilyKeyDialog from './TavilyKeyDialog.vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { configProfileApi, knowledgeApi, providerApi } from '@/api/v1'
import { useModuleI18n } from '@/i18n/composables'
@@ -256,6 +258,9 @@ const loading = ref(false)
const uploading = ref(false)
const deleting = ref(false)
const documents = ref<any[]>([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const searchQuery = ref('')
const showUploadDialog = ref(false)
const showDeleteDialog = ref(false)
@@ -340,9 +345,15 @@ const headers = [
const loadDocuments = async () => {
loading.value = true
try {
const response = await knowledgeApi.documents(props.kbId)
const response = await knowledgeApi.documents(props.kbId, {
page: page.value,
page_size: pageSize.value,
search: searchQuery.value.trim() || undefined,
})
if (response.data.status === 'ok') {
documents.value = response.data.data.items || []
const data = response.data.data
documents.value = data.items || []
total.value = data.total || 0
}
} catch (error) {
console.error('Failed to load documents:', error)
@@ -352,6 +363,18 @@ const loadDocuments = async () => {
}
}
// Handle pagination
const onPageChange = (newPage: number) => {
page.value = newPage
loadDocuments()
}
const onItemsPerPageChange = (newSize: number) => {
pageSize.value = newSize
page.value = 1
loadDocuments()
}
// 文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
@@ -591,7 +614,7 @@ const startProgressPolling = (taskId: string) => {
// 移除上传中的占位文档
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
// 重新加载文档列表
// Reload current page
await loadDocuments()
emit('refresh')
@@ -684,6 +707,10 @@ const deleteDocument = async () => {
if (response.data.status === 'ok') {
showSnackbar(t('documents.deleteSuccess'))
showDeleteDialog.value = false
// If current page becomes empty after delete and is not the first page, go back one page
if (documents.value.length === 1 && page.value > 1) {
page.value -= 1
}
await loadDocuments()
emit('refresh')
} else {
@@ -782,6 +809,12 @@ const onTavilyKeySet = () => {
checkTavilyConfig()
}
// Reset to page 1 and reload when search text changes
watch(searchQuery, () => {
page.value = 1
loadDocuments()
})
onMounted(() => {
loadDocuments()
loadLlmProviders()

View File

@@ -3446,6 +3446,12 @@ paths:
- $ref: "#/components/parameters/KbId"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PageSize"
- name: search
in: query
required: false
schema:
type: string
description: Filter documents by name (case-insensitive partial match).
responses:
"200":
$ref: "#/components/responses/Ok"

View File

@@ -293,3 +293,67 @@ async def test_import_documents_invalid_input(
data = await response.get_json()
assert data["status"] == "error"
assert "chunks 必须是非空字符串列表" in data["message"]
def _make_service_with_mock_kb_helper():
"""Create a KnowledgeBaseService whose kb_manager returns a mock kb_helper.
Returns:
Tuple of (service, kb_helper).
"""
from unittest.mock import AsyncMock, MagicMock
kb_helper = AsyncMock()
kb_helper.list_documents = AsyncMock()
kb_helper.count_documents = AsyncMock()
kb_manager = MagicMock()
kb_manager.get_kb = AsyncMock(return_value=kb_helper)
service = KnowledgeBaseService.__new__(KnowledgeBaseService)
service.core_lifecycle = MagicMock()
service.core_lifecycle.kb_manager = kb_manager
service.upload_progress = {}
service.upload_tasks = {}
return service, kb_helper
@pytest.mark.asyncio
async def test_list_documents_clamps_page_and_page_size_below_one():
"""page and page_size below 1 are clamped to 1 before calling kb_helper."""
service, kb_helper = _make_service_with_mock_kb_helper()
kb_helper.list_documents.return_value = []
kb_helper.count_documents.return_value = 0
await service.list_documents(kb_id="kb1", page=0, page_size=-5)
kb_helper.list_documents.assert_awaited_once_with(offset=0, limit=1, search=None)
@pytest.mark.asyncio
async def test_list_documents_trims_search_and_turns_empty_to_none():
"""search is stripped; whitespace-only search becomes None."""
service, kb_helper = _make_service_with_mock_kb_helper()
kb_helper.list_documents.return_value = []
kb_helper.count_documents.return_value = 0
await service.list_documents(kb_id="kb1", page=1, page_size=10, search=" ")
kb_helper.list_documents.assert_awaited_once_with(
offset=0, limit=10, search=None,
)
@pytest.mark.asyncio
async def test_list_documents_total_comes_from_count_documents():
"""total uses count_documents(search=normalized_search), not stale kb.doc_count."""
service, kb_helper = _make_service_with_mock_kb_helper()
kb_helper.list_documents.return_value = []
kb_helper.count_documents.return_value = 42
result = await service.list_documents(
kb_id="kb1", page=1, page_size=10, search=" foo ",
)
assert result["total"] == 42
kb_helper.count_documents.assert_awaited_once_with(search="foo")