From 8abea44aa729f2899bc0a1e4210e0d667e92f438 Mon Sep 17 00:00:00 2001 From: bruce Date: Thu, 21 May 2026 23:20:51 +0800 Subject: [PATCH] feat(frontend): add rag document parse controls --- frontend/src/api/ragDocuments.ts | 29 + .../rag/RagDocumentBatchUploadDialog.vue | 184 +++++ frontend/src/pages/RagDocumentsPage.vue | 478 ------------- .../pages/__tests__/RagDocumentsPage.spec.ts | 125 ---- .../src/pages/{ => common}/NotFoundPage.vue | 0 .../pages/{ => dashboard}/DashboardPage.vue | 0 frontend/src/pages/rag/RagDocumentsPage.vue | 638 ++++++++++++++++++ .../src/pages/{ => rag}/RagStoresPage.vue | 52 +- .../rag/__tests__/RagDocumentsPage.spec.ts | 203 ++++++ .../{ => rag}/__tests__/RagStoresPage.spec.ts | 44 ++ .../{ => system}/SystemAttachmentsPage.vue | 0 .../pages/{ => system}/SystemEnumsPage.vue | 0 .../__tests__/SystemEnumsPage.spec.ts | 0 frontend/src/router/index.ts | 12 +- 14 files changed, 1154 insertions(+), 611 deletions(-) create mode 100644 frontend/src/components/rag/RagDocumentBatchUploadDialog.vue delete mode 100644 frontend/src/pages/RagDocumentsPage.vue delete mode 100644 frontend/src/pages/__tests__/RagDocumentsPage.spec.ts rename frontend/src/pages/{ => common}/NotFoundPage.vue (100%) rename frontend/src/pages/{ => dashboard}/DashboardPage.vue (100%) create mode 100644 frontend/src/pages/rag/RagDocumentsPage.vue rename frontend/src/pages/{ => rag}/RagStoresPage.vue (93%) create mode 100644 frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts rename frontend/src/pages/{ => rag}/__tests__/RagStoresPage.spec.ts (83%) rename frontend/src/pages/{ => system}/SystemAttachmentsPage.vue (100%) rename frontend/src/pages/{ => system}/SystemEnumsPage.vue (100%) rename frontend/src/pages/{ => system}/__tests__/SystemEnumsPage.spec.ts (100%) diff --git a/frontend/src/api/ragDocuments.ts b/frontend/src/api/ragDocuments.ts index 9c1aff0..e18f4c6 100644 --- a/frontend/src/api/ragDocuments.ts +++ b/frontend/src/api/ragDocuments.ts @@ -47,6 +47,31 @@ export interface RagDocumentBatchUploadRequest { remark?: string; } +export type RagChunkStrategy = + | 'FIXED_LENGTH' + | 'PARAGRAPH' + | 'HEADING' + | 'TABLE_ROW' + | 'DELIMITER' + | 'SEMANTIC'; + +export interface RagDocumentParseRequest { + documentIds: string[]; + chunkStrategy: RagChunkStrategy; + chunkSize?: number; + chunkOverlap?: number; + delimiter?: string; +} + +export interface RagDocumentParseResponse { + documentId: string; + parseStatus: string; + textLength?: number | null; + pageCount?: number | null; + sheetCount?: number | null; + metadata?: Record; +} + export function listRagDocuments() { return post('/rag/documents/list'); } @@ -86,3 +111,7 @@ export function batchUploadRagDocuments(data: RagDocumentBatchUploadRequest) { } return post('/rag/documents/batchUpload', formData); } + +export function parseRagDocuments(data: RagDocumentParseRequest) { + return post('/rag/documents/parse', data); +} diff --git a/frontend/src/components/rag/RagDocumentBatchUploadDialog.vue b/frontend/src/components/rag/RagDocumentBatchUploadDialog.vue new file mode 100644 index 0000000..168986e --- /dev/null +++ b/frontend/src/components/rag/RagDocumentBatchUploadDialog.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend/src/pages/RagDocumentsPage.vue b/frontend/src/pages/RagDocumentsPage.vue deleted file mode 100644 index 71176c6..0000000 --- a/frontend/src/pages/RagDocumentsPage.vue +++ /dev/null @@ -1,478 +0,0 @@ - - - - - diff --git a/frontend/src/pages/__tests__/RagDocumentsPage.spec.ts b/frontend/src/pages/__tests__/RagDocumentsPage.spec.ts deleted file mode 100644 index 49c6988..0000000 --- a/frontend/src/pages/__tests__/RagDocumentsPage.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { flushPromises, mount } from '@vue/test-utils'; -import ElementPlus from 'element-plus'; -import { describe, expect, it, vi } from 'vitest'; - -import RagDocumentsPage from '../RagDocumentsPage.vue'; -import { getRagDocumentById, listRagDocuments, queryRagDocuments } from '@/api/ragDocuments'; -import { queryRagStores } from '@/api/ragStores'; - -vi.mock('@/api/ragStores', () => ({ - queryRagStores: vi.fn(() => - Promise.resolve({ - resultcode: '0', - message: null, - data: [ - { id: '1', storeCode: 'PROD_DOC', storeName: '产品制度库', status: '启用' }, - { id: '2', storeCode: 'FAQ', storeName: 'FAQ知识库', status: '停用' }, - ], - }), - ), -})); - -vi.mock('@/api/ragDocuments', () => ({ - SOURCE_TYPE_RAG: 'RAG', - listRagDocuments: vi.fn(() => - Promise.resolve({ - resultcode: '0', - message: null, - data: [ - { - id: '11', - storeId: '1', - attachmentId: '101', - documentTitle: '产品制度总则', - documentSummary: '制度摘要', - parseStatus: 'UPLOADED', - indexStatus: 'PENDING', - enabled: true, - remark: '制度文档', - createTime: '2026-05-21 10:00:00', - }, - { - id: '22', - storeId: '2', - attachmentId: '202', - documentTitle: 'FAQ 手册', - documentSummary: 'FAQ 摘要', - parseStatus: 'PARSED', - indexStatus: 'INDEXED', - enabled: false, - remark: '常见问题', - createTime: '2026-05-21 11:00:00', - }, - ], - }), - ), - queryRagDocuments: vi.fn(), - getRagDocumentById: vi.fn((id: string) => - Promise.resolve({ - resultcode: '0', - message: null, - data: { - id, - storeId: '2', - attachmentId: '202', - documentTitle: 'FAQ 手册', - documentSummary: 'FAQ 摘要', - enabled: false, - remark: '常见问题', - }, - }), - ), - saveRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })), - deleteRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })), - batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })), -})); - -describe('RagDocumentsPage', () => { - it('loads documents from list api instead of broken query api', async () => { - const wrapper = mount(RagDocumentsPage, { - global: { - plugins: [ElementPlus], - }, - }); - - await flushPromises(); - - expect(queryRagStores).toHaveBeenCalled(); - expect(listRagDocuments).toHaveBeenCalled(); - expect(queryRagDocuments).not.toHaveBeenCalled(); - expect(wrapper.text()).toContain('产品制度总则'); - expect(wrapper.text()).toContain('FAQ 手册'); - }); - - it('filters rows locally and still avoids query api on search', async () => { - const wrapper = mount(RagDocumentsPage, { - global: { - plugins: [ElementPlus], - }, - }); - - await flushPromises(); - await wrapper.get('[data-test="doc-keyword-input"]').setValue('FAQ'); - await wrapper.get('[data-test="doc-search"]').trigger('click'); - await flushPromises(); - - expect(listRagDocuments).toHaveBeenCalled(); - expect(queryRagDocuments).not.toHaveBeenCalled(); - expect(wrapper.text()).toContain('FAQ 手册'); - expect(wrapper.text()).not.toContain('产品制度总则'); - }); - - it('loads backend detail when editing a row', async () => { - const wrapper = mount(RagDocumentsPage, { - global: { - plugins: [ElementPlus], - }, - }); - - await flushPromises(); - await wrapper.get('[data-test="doc-edit-22"]').trigger('click'); - await flushPromises(); - - expect(getRagDocumentById).toHaveBeenCalledWith('22'); - }); -}); diff --git a/frontend/src/pages/NotFoundPage.vue b/frontend/src/pages/common/NotFoundPage.vue similarity index 100% rename from frontend/src/pages/NotFoundPage.vue rename to frontend/src/pages/common/NotFoundPage.vue diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/dashboard/DashboardPage.vue similarity index 100% rename from frontend/src/pages/DashboardPage.vue rename to frontend/src/pages/dashboard/DashboardPage.vue diff --git a/frontend/src/pages/rag/RagDocumentsPage.vue b/frontend/src/pages/rag/RagDocumentsPage.vue new file mode 100644 index 0000000..a722b9d --- /dev/null +++ b/frontend/src/pages/rag/RagDocumentsPage.vue @@ -0,0 +1,638 @@ + + + + + diff --git a/frontend/src/pages/RagStoresPage.vue b/frontend/src/pages/rag/RagStoresPage.vue similarity index 93% rename from frontend/src/pages/RagStoresPage.vue rename to frontend/src/pages/rag/RagStoresPage.vue index 6b5d53c..cebd45d 100644 --- a/frontend/src/pages/RagStoresPage.vue +++ b/frontend/src/pages/rag/RagStoresPage.vue @@ -2,6 +2,7 @@ import { CirclePlus, Delete, Edit, FolderAdd, Refresh, Search, UploadFilled } from '@element-plus/icons-vue'; import { ElMessage, ElMessageBox } from 'element-plus'; import { computed, onMounted, reactive, ref } from 'vue'; +import { useRouter } from 'vue-router'; import { deleteRagStore, @@ -14,9 +15,11 @@ import { type RagStoreOverview, type RagStore, } from '@/api/ragStores'; +import RagDocumentBatchUploadDialog from '@/components/rag/RagDocumentBatchUploadDialog.vue'; type StoreStatus = '启用' | '停用'; +const router = useRouter(); const loading = ref(false); const detailLoading = ref(false); const submitting = ref(false); @@ -32,6 +35,7 @@ const queryForm = reactive({ const createDialogVisible = ref(false); const editDialogVisible = ref(false); +const uploadDialogVisible = ref(false); const createForm = reactive({ storeCode: '', @@ -223,6 +227,31 @@ function showFutureMessage(actionName: string) { ElMessage.info(`${actionName} 会在下一批接口里补齐`); } +function openBatchUploadDialog() { + if (!activeStore.value?.id) { + ElMessage.warning('请选择知识库'); + return; + } + uploadDialogVisible.value = true; +} + +function viewActiveStoreDocuments() { + if (!activeStore.value?.id) { + return; + } + router.push({ + name: 'rag-documents', + query: { storeId: String(activeStore.value.id) }, + }); +} + +async function refreshAfterUpload() { + await Promise.all([ + loadOverview(), + activeStoreId.value ? selectStore(activeStoreId.value) : Promise.resolve(), + ]); +} + function getStatusTagType(status?: string | null) { return status === '启用' ? 'success' : 'info'; } @@ -314,7 +343,12 @@ onMounted(() => {
编辑 - + 批量导入文件 重建索引 @@ -353,7 +387,14 @@ onMounted(() => {

文档概览

- 已对接后端聚合接口 + + 查看文档 +
@@ -456,6 +497,13 @@ onMounted(() => { 保存 + + diff --git a/frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts b/frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts new file mode 100644 index 0000000..9d5fc17 --- /dev/null +++ b/frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts @@ -0,0 +1,203 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; +import { describe, expect, it, vi } from 'vitest'; + +import RagDocumentsPage from '../RagDocumentsPage.vue'; +import { getRagDocumentById, parseRagDocuments, queryRagDocuments } from '@/api/ragDocuments'; +import { queryRagStores } from '@/api/ragStores'; + +const routeQuery = vi.hoisted(() => ({ storeId: undefined as string | undefined })); + +vi.mock('vue-router', () => ({ + useRoute: () => ({ + query: routeQuery, + }), +})); + +vi.mock('@/api/ragStores', () => ({ + queryRagStores: vi.fn(() => + Promise.resolve({ + resultcode: '0', + message: null, + data: [ + { id: '1', storeCode: 'PROD_DOC', storeName: '产品制度库', status: '启用' }, + { id: '2', storeCode: 'FAQ', storeName: 'FAQ知识库', status: '停用' }, + ], + }), + ), +})); + +vi.mock('@/api/ragDocuments', () => ({ + SOURCE_TYPE_RAG: 'RAG', + queryRagDocuments: vi.fn((query?: { storeId?: string }) => { + const rows = [ + { + id: '11', + storeId: '1', + attachmentId: '101', + documentTitle: '产品制度总则', + documentSummary: '制度摘要', + parseStatus: 'UPLOADED', + indexStatus: 'PENDING', + enabled: true, + remark: '制度文档', + createTime: '2026-05-21 10:00:00', + }, + { + id: '22', + storeId: '2', + attachmentId: '202', + documentTitle: 'FAQ 手册', + documentSummary: 'FAQ 摘要', + parseStatus: 'PARSED', + indexStatus: 'INDEXED', + enabled: false, + remark: '常见问题', + createTime: '2026-05-21 11:00:00', + }, + ]; + const data = query?.storeId ? rows.filter((row) => row.storeId === query.storeId) : rows; + return Promise.resolve({ resultcode: '0', message: null, data }); + }), + getRagDocumentById: vi.fn((id: string) => + Promise.resolve({ + resultcode: '0', + message: null, + data: { + id, + storeId: '2', + attachmentId: '202', + documentTitle: 'FAQ 手册', + documentSummary: 'FAQ 摘要', + enabled: false, + remark: '常见问题', + }, + }), + ), + saveRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })), + deleteRagDocument: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })), + batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })), + parseRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })), +})); + +describe('RagDocumentsPage', () => { + it('loads documents from query api', async () => { + routeQuery.storeId = undefined; + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + + expect(queryRagStores).toHaveBeenCalled(); + expect(queryRagDocuments).toHaveBeenCalledWith({}); + expect(wrapper.text()).toContain('产品制度总则'); + expect(wrapper.text()).toContain('FAQ 手册'); + }); + + it('renders document filters as a form-style query bar', async () => { + routeQuery.storeId = undefined; + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + + expect(wrapper.find('[data-test="document-query-bar"]').exists()).toBe(true); + expect(wrapper.find('[data-test="document-query-form"]').exists()).toBe(true); + expect(wrapper.find('.toolbar__filters').exists()).toBe(false); + const labels = wrapper.findAll('.document-query-form .el-form-item__label').map((label) => label.text()); + expect(labels).toEqual(expect.arrayContaining(['知识库', '解析状态', '索引状态', '启用状态'])); + }); + + it('uses route storeId as the default document query', async () => { + routeQuery.storeId = '2'; + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + + expect(queryRagDocuments).toHaveBeenCalledWith({ storeId: '2' }); + expect(wrapper.text()).toContain('FAQ 手册'); + expect(wrapper.text()).not.toContain('产品制度总则'); + }); + + it('loads backend detail when editing a row', async () => { + routeQuery.storeId = undefined; + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="doc-edit-22"]').trigger('click'); + await flushPromises(); + + expect(getRagDocumentById).toHaveBeenCalledWith('22'); + }); + + it('opens parse dialog with chunk strategy options from row action', async () => { + routeQuery.storeId = undefined; + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="doc-parse-11"]').trigger('click'); + await flushPromises(); + + expect(wrapper.find('[data-test="document-parse-dialog"]').exists()).toBe(true); + expect(wrapper.text()).toContain('固定长度切片'); + expect(wrapper.text()).toContain('按分隔符切片'); + expect(wrapper.text()).toContain('语义切片'); + }); + + it('submits parse request with selected chunk strategy', async () => { + routeQuery.storeId = undefined; + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="doc-parse-11"]').trigger('click'); + await flushPromises(); + await wrapper.get('[data-test="document-parse-submit"]').trigger('click'); + await flushPromises(); + + expect(parseRagDocuments).toHaveBeenCalledWith({ + documentIds: ['11'], + chunkStrategy: 'FIXED_LENGTH', + chunkSize: 800, + chunkOverlap: 120, + delimiter: '。', + }); + }); + + it('renders reusable upload dialog with drag upload area', async () => { + routeQuery.storeId = '1'; + const wrapper = mount(RagDocumentsPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="open-doc-upload"]').trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain('拖拽文件到此处'); + expect(wrapper.find('[data-test="batch-upload-locked-store"]').exists()).toBe(true); + }); +}); diff --git a/frontend/src/pages/__tests__/RagStoresPage.spec.ts b/frontend/src/pages/rag/__tests__/RagStoresPage.spec.ts similarity index 83% rename from frontend/src/pages/__tests__/RagStoresPage.spec.ts rename to frontend/src/pages/rag/__tests__/RagStoresPage.spec.ts index 1450ae8..cd9fed3 100644 --- a/frontend/src/pages/__tests__/RagStoresPage.spec.ts +++ b/frontend/src/pages/rag/__tests__/RagStoresPage.spec.ts @@ -11,6 +11,19 @@ import { saveRagStore, } from '@/api/ragStores'; +const routerPush = vi.hoisted(() => vi.fn()); + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: routerPush, + }), +})); + +vi.mock('@/api/ragDocuments', () => ({ + SOURCE_TYPE_RAG: 'RAG', + batchUploadRagDocuments: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: [] })), +})); + vi.mock('@/api/ragStores', () => ({ getRagStoreOverview: vi.fn(() => Promise.resolve({ @@ -181,4 +194,35 @@ describe('RagStoresPage', () => { }), ); }); + + it('links document overview to the selected store documents page', async () => { + const wrapper = mount(RagStoresPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="view-store-documents"]').trigger('click'); + + expect(routerPush).toHaveBeenCalledWith({ + name: 'rag-documents', + query: { storeId: '1' }, + }); + }); + + it('opens reusable locked upload dialog from store detail', async () => { + const wrapper = mount(RagStoresPage, { + global: { + plugins: [ElementPlus], + }, + }); + + await flushPromises(); + await wrapper.get('[data-test="store-batch-upload"]').trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain('拖拽文件到此处'); + expect(wrapper.text()).toContain('产品制度库'); + }); }); diff --git a/frontend/src/pages/SystemAttachmentsPage.vue b/frontend/src/pages/system/SystemAttachmentsPage.vue similarity index 100% rename from frontend/src/pages/SystemAttachmentsPage.vue rename to frontend/src/pages/system/SystemAttachmentsPage.vue diff --git a/frontend/src/pages/SystemEnumsPage.vue b/frontend/src/pages/system/SystemEnumsPage.vue similarity index 100% rename from frontend/src/pages/SystemEnumsPage.vue rename to frontend/src/pages/system/SystemEnumsPage.vue diff --git a/frontend/src/pages/__tests__/SystemEnumsPage.spec.ts b/frontend/src/pages/system/__tests__/SystemEnumsPage.spec.ts similarity index 100% rename from frontend/src/pages/__tests__/SystemEnumsPage.spec.ts rename to frontend/src/pages/system/__tests__/SystemEnumsPage.spec.ts diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aaf8539..07c0f35 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,12 +1,12 @@ import type { RouteRecordRaw } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router'; -import DashboardPage from '@/pages/DashboardPage.vue'; -import NotFoundPage from '@/pages/NotFoundPage.vue'; -import RagDocumentsPage from '@/pages/RagDocumentsPage.vue'; -import RagStoresPage from '@/pages/RagStoresPage.vue'; -import SystemAttachmentsPage from '@/pages/SystemAttachmentsPage.vue'; -import SystemEnumsPage from '@/pages/SystemEnumsPage.vue'; +import DashboardPage from '@/pages/dashboard/DashboardPage.vue'; +import NotFoundPage from '@/pages/common/NotFoundPage.vue'; +import RagDocumentsPage from '@/pages/rag/RagDocumentsPage.vue'; +import RagStoresPage from '@/pages/rag/RagStoresPage.vue'; +import SystemAttachmentsPage from '@/pages/system/SystemAttachmentsPage.vue'; +import SystemEnumsPage from '@/pages/system/SystemEnumsPage.vue'; import AdminLayout from '@/layouts/AdminLayout.vue'; export const routes: RouteRecordRaw[] = [