From ebade41e40c0d93ef759bcd2d7f2e96d7e50d3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 4 Sep 2026 11:33:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E6=96=87=E6=A1=A3=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E5=9D=97=E5=86=85=E8=81=94=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/components/page/PageData.test.ts | 70 + .../app/src/components/page/PageData.vue | 111 +- .../langs/en-US/documentCollection.json | 21 +- .../langs/zh-CN/documentCollection.json | 21 +- .../ChunkDocumentTable.test.ts | 781 ++++++++ .../documentCollection/ChunkDocumentTable.vue | 1568 ++++++++++++----- .../views/ai/documentCollection/Document.vue | 51 +- .../documentCollection/DocumentTable.test.ts | 348 ++++ .../ai/documentCollection/DocumentTable.vue | 296 +++- .../documentCollection/KnowledgeShareView.vue | 34 +- .../editor-ui/src/MarkdownLiveEditor.test.ts | 296 +++- .../editor-ui/src/MarkdownLiveEditor.vue | 339 +++- .../@core/ui-kit/editor-ui/src/index.ts | 4 + .../editor-ui/src/markdown-fidelity.test.ts | 9 + .../ui-kit/editor-ui/src/markdown-fidelity.ts | 23 + 15 files changed, 3473 insertions(+), 499 deletions(-) create mode 100644 easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentTable.test.ts diff --git a/easyflow-ui-admin/app/src/components/page/PageData.test.ts b/easyflow-ui-admin/app/src/components/page/PageData.test.ts index 59670f8b..8923a0cc 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.test.ts +++ b/easyflow-ui-admin/app/src/components/page/PageData.test.ts @@ -1,5 +1,6 @@ import { flushPromises, mount } from '@vue/test-utils'; +import { ElPagination } from 'element-plus'; import { describe, expect, it, vi } from 'vitest'; import PageData from './PageData.vue'; @@ -202,4 +203,73 @@ describe('page data recovery', () => { params: { pageNumber: 1, pageSize: 10, title: '月报' }, }); }); + + it('keeps the current page when an unsaved-change guard rejects navigation', async () => { + const get = vi.fn().mockResolvedValue({ + data: { records: [{ id: 'row' }], totalRow: 30 }, + }); + const beforePageChange = vi.fn().mockResolvedValue(false); + const wrapper = mount(PageData, { + global: { directives: { loading: {} } }, + props: { + beforePageChange, + pageUrl: '/page', + requestClient: { get }, + }, + }); + await flushPromises(); + + wrapper.findComponent(ElPagination).vm.$emit('current-change', 2); + await flushPromises(); + + expect(beforePageChange).toHaveBeenCalledTimes(1); + expect((wrapper.vm as any).getPageState().pageNumber).toBe(1); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('removes a persisted row locally without waiting for another page request', async () => { + const get = vi.fn().mockResolvedValue({ + data: { records: [{ id: 'a' }, { id: 'b' }], totalRow: 2 }, + }); + const wrapper = mount(PageData, { + global: { directives: { loading: {} } }, + props: { pageUrl: '/page', requestClient: { get } }, + }); + await flushPromises(); + + expect((wrapper.vm as any).removeRowById('a')).toBe(true); + await flushPromises(); + + expect((wrapper.vm as any).getPageRows()).toEqual([{ id: 'b' }]); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('silently backfills the current page after deleting from a non-last page', async () => { + const initialRows = Array.from({ length: 10 }, (_, index) => ({ + id: `row-${index + 1}`, + })); + const backfilledRows = [ + ...initialRows.slice(1), + { id: 'row-11' }, + ]; + const get = vi + .fn() + .mockResolvedValueOnce({ + data: { records: initialRows, totalRow: 11 }, + }) + .mockResolvedValueOnce({ + data: { records: backfilledRows, totalRow: 10 }, + }); + const wrapper = mount(PageData, { + global: { directives: { loading: {} } }, + props: { pageUrl: '/page', requestClient: { get } }, + }); + await flushPromises(); + + expect((wrapper.vm as any).removeRowById('row-1')).toBe(true); + await flushPromises(); + + expect(get).toHaveBeenCalledTimes(2); + expect((wrapper.vm as any).getPageRows()).toEqual(backfilledRows); + }); }); diff --git a/easyflow-ui-admin/app/src/components/page/PageData.vue b/easyflow-ui-admin/app/src/components/page/PageData.vue index 74588d0f..c64de861 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.vue +++ b/easyflow-ui-admin/app/src/components/page/PageData.vue @@ -9,7 +9,9 @@ import { api } from '#/api/request'; import { getEmptyStateImageUrl } from '#/utils/assets'; interface PageDataProps { + beforePageChange?: () => boolean | Promise; pageUrl: string; + refreshUrl?: string; pageSize?: number; pageSizes?: number[]; extraQueryParams?: Record; @@ -29,23 +31,40 @@ interface PageDataRestoreState extends PageDataState { } interface PageDataReloadOptions { + lightweight?: boolean; silent?: boolean; } interface PageDataRequest { + lightweight: boolean; silent: boolean; version: number; } +interface PageDataLoadEvent { + lightweight: boolean; + pageNumber: number; + recordCount: number; + silent: boolean; +} + +interface PageDataLoadErrorEvent extends PageDataLoadEvent { + error: unknown; +} + const props = withDefaults(defineProps(), { + beforePageChange: undefined, pageSize: 10, pageSizes: () => [10, 20, 50, 100], + refreshUrl: undefined, extraQueryParams: () => ({}), initialPageNumber: 1, initialQueryParams: () => ({}), requestClient: () => api, }); const emit = defineEmits<{ + (e: 'loadError', event: PageDataLoadErrorEvent): void; + (e: 'loadSuccess', event: PageDataLoadEvent): void; (e: 'stateChange', state: PageDataState): void; }>(); @@ -67,10 +86,10 @@ const pageInfo = reactive({ }); // 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整 -const doGet = async (params: Record) => { +const doGet = async (url: string, params: Record) => { // 这里替换为你的实际 API 调用 // 例如:return await api.get(props.pageUrl, { params }) - const response = await props.requestClient.get(`${props.pageUrl}`, { + const response = await props.requestClient.get(url, { params, }); const data = await response.data; @@ -78,14 +97,29 @@ const doGet = async (params: Record) => { }; const loadPageListOnce = async (request: PageDataRequest) => { + const lightweight = Boolean( + request.lightweight && props.refreshUrl && pageInfo.pageNumber === 1, + ); try { - const res = await doGet({ + const res = await doGet(lightweight ? props.refreshUrl! : props.pageUrl, { pageNumber: pageInfo.pageNumber, pageSize: pageInfo.pageSize, ...props.extraQueryParams, ...queryParams.value, }); if (request.version === pageRequestVersion) { + if (lightweight) { + pageList.value = Array.isArray(res.data) + ? res.data + : res.data?.records || []; + emit('loadSuccess', { + lightweight: true, + pageNumber: pageInfo.pageNumber, + recordCount: pageList.value.length, + silent: request.silent, + }); + return; + } const rawTotal = Number(res.data?.totalRow || 0); const total = Number.isFinite(rawTotal) ? Math.max(0, rawTotal) : 0; const lastPage = Math.max(1, Math.ceil(total / pageInfo.pageSize)); @@ -96,6 +130,12 @@ const loadPageListOnce = async (request: PageDataRequest) => { return; } pageList.value = res.data?.records || []; + emit('loadSuccess', { + lightweight: false, + pageNumber: pageInfo.pageNumber, + recordCount: pageList.value.length, + silent: request.silent, + }); } } catch (error) { if (request.version === pageRequestVersion) { @@ -104,12 +144,25 @@ const loadPageListOnce = async (request: PageDataRequest) => { pageList.value = []; pageInfo.total = 0; } + emit('loadError', { + error, + lightweight, + pageNumber: pageInfo.pageNumber, + recordCount: pageList.value.length, + silent: request.silent, + }); } } }; -const requestPageList = (silent: boolean) => { +const requestPageList = (silent: boolean, lightweight = false) => { + // 自动刷新只补充最新行;已有请求能够提供同等或更完整的数据时直接复用, + // 避免它使正在进行的分页查询失效或额外排队。 + if (activePageRequest && lightweight) { + return activePageRequest; + } const request: PageDataRequest = { + lightweight, silent: silent && pageList.value.length > 0, version: ++pageRequestVersion, }; @@ -120,6 +173,7 @@ const requestPageList = (silent: boolean) => { if (activePageRequest) { pendingPageRequest = pendingPageRequest ? { + lightweight: pendingPageRequest.lightweight && request.lightweight, silent: pendingPageRequest.silent && request.silent, version: request.version, } @@ -143,19 +197,27 @@ const requestPageList = (silent: boolean) => { }; // 获取页面数据 -const getPageList = () => requestPageList(false); +const getPageList = () => requestPageList(false, false); const reload = (options: PageDataReloadOptions = {}) => - requestPageList(Boolean(options.silent)); + requestPageList(Boolean(options.silent), Boolean(options.lightweight)); // 分页事件处理 -const handleSizeChange = (newSize: number) => { +const canChangePage = async () => (await props.beforePageChange?.()) !== false; + +const handleSizeChange = async (newSize: number) => { + if (!(await canChangePage())) { + return; + } pageInfo.pageSize = newSize; pageInfo.pageNumber = 1; // 重置到第一页 emitPageState(); }; -const handleCurrentChange = (newPage: number) => { +const handleCurrentChange = async (newPage: number) => { + if (!(await canChangePage())) { + return; + } pageInfo.pageNumber = newPage; emitPageState(); }; @@ -165,6 +227,8 @@ const getPageState = (): PageDataState => ({ pageSize: pageInfo.pageSize, }); +const getPageRows = () => pageList.value; + function normalizeQueryParams(value: Record = {}) { return Object.fromEntries( Object.entries(value).filter(([, item]) => item !== undefined), @@ -206,6 +270,31 @@ const patchRowById = ( return true; }; +const removeRowById = (id: number | string): boolean => { + const rowIndex = pageList.value.findIndex( + (item) => String(item?.id ?? '') === String(id ?? ''), + ); + if (rowIndex === -1) return false; + const nextPageList = [...pageList.value]; + nextPageList.splice(rowIndex, 1); + pageList.value = nextPageList; + pageInfo.total = Math.max(0, pageInfo.total - 1); + if (pageList.value.length === 0 && pageInfo.pageNumber > 1) { + pageInfo.pageNumber--; + emitPageState(); + } else { + const pageStart = (pageInfo.pageNumber - 1) * pageInfo.pageSize; + const expectedRowCount = Math.min( + pageInfo.pageSize, + Math.max(0, pageInfo.total - pageStart), + ); + if (pageList.value.length < expectedRowCount) { + void reload({ silent: true }); + } + } + return true; +}; + // 暴露给父组件的方法 (替代 useImperativeHandle) const setQuery = (newQueryParams: Record) => { pageInfo.pageNumber = 1; @@ -237,9 +326,11 @@ const restoreState = (state: PageDataRestoreState) => { // 暴露方法给父组件 defineExpose({ + getPageRows, getPageState, reload, patchRowById, + removeRowById, restoreState, setQuery, }); @@ -271,8 +362,8 @@ onMounted(() => { class="page-data-container__pagination mx-auto mt-6 w-fit" > ({ rows: [] as any[] })); +const liveEditorState = vi.hoisted(() => ({ + flushedMarkdown: undefined as string | undefined, + focus: vi.fn(), +})); +const mountedWrappers: Array> = []; + +vi.mock('element-plus', async (importOriginal) => { + const actual = await importOriginal(); + const { defineComponent, h } = await import('vue'); + const ElTable = defineComponent({ + name: 'ElTable', + props: ['data'], + setup(props, { slots }) { + return () => { + tableState.rows = props.data || []; + return h('div', { class: 'table-stub' }, slots.default?.()); + }; + }, + }); + const ElTableColumn = defineComponent({ + name: 'ElTableColumn', + setup(_props, { slots }) { + return () => + h( + 'div', + { class: 'table-column-stub' }, + tableState.rows.map((row) => slots.default?.({ row })), + ); + }, + }); + return { ...actual, ElTable, ElTableColumn }; +}); + +vi.mock('#/api/request', () => ({ api: {} })); + +vi.mock('@easyflow/locales', () => ({ + $t: (key: string) => { + const messages: Record = { + 'documentCollection.continueEditing': '继续编辑', + 'documentCollection.deleteChunk': '删除分块', + 'documentCollection.emptyChunkDeleteConfirm': + '分块内容为空,保存后将删除该分块。删除后无法恢复。', + 'documentCollection.chunkSyncPending': '正在更新检索索引', + 'documentCollection.chunkSyncSucceeded': '检索索引已更新', + 'documentCollection.chunkSyncFailed': '索引同步失败,点击重试', + 'documentCollection.chunkSyncRetryFailed': '索引同步重试失败', + }; + return messages[key] || key; + }, +})); + +vi.mock('vue-element-plus-x/es/XMarkdown/index.js', () => ({ + default: { + props: ['markdown'], + template: '
{{ markdown }}
', + }, +})); + +vi.mock('@easyflow-core/editor-ui', () => ({ + containsRawHtml: (markdown: string) => /<\/?[a-z][^>]*>/i.test(markdown), + EMPTY_IMAGE_DATA_URL: 'data:image/gif;base64,empty', + hasEquivalentMarkdownSemantics: (source: string, normalized: string) => + source.trim() === normalized.trim(), + MAX_LIVE_MARKDOWN_CHARACTERS: 300_000, + MarkdownSourceEditor: { + emits: ['save', 'update:modelValue'], + methods: { focus: () => undefined }, + name: 'MarkdownSourceEditor', + props: ['highlight', 'modelValue', 'readonly'], + template: + '