From 65c85180c224251db1b5347b9cd240710b5a8407 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 | 51 +- .../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, 3419 insertions(+), 493 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 cbeeda0a..89ea65e1 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'; @@ -305,4 +306,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 c8da9ae1..c64de861 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.vue +++ b/easyflow-ui-admin/app/src/components/page/PageData.vue @@ -9,6 +9,7 @@ import { api } from '#/api/request'; import { getEmptyStateImageUrl } from '#/utils/assets'; interface PageDataProps { + beforePageChange?: () => boolean | Promise; pageUrl: string; refreshUrl?: string; pageSize?: number; @@ -44,6 +45,7 @@ interface PageDataLoadEvent { lightweight: boolean; pageNumber: number; recordCount: number; + silent: boolean; } interface PageDataLoadErrorEvent extends PageDataLoadEvent { @@ -51,6 +53,7 @@ interface PageDataLoadErrorEvent extends PageDataLoadEvent { } const props = withDefaults(defineProps(), { + beforePageChange: undefined, pageSize: 10, pageSizes: () => [10, 20, 50, 100], refreshUrl: undefined, @@ -113,6 +116,7 @@ const loadPageListOnce = async (request: PageDataRequest) => { lightweight: true, pageNumber: pageInfo.pageNumber, recordCount: pageList.value.length, + silent: request.silent, }); return; } @@ -130,6 +134,7 @@ const loadPageListOnce = async (request: PageDataRequest) => { lightweight: false, pageNumber: pageInfo.pageNumber, recordCount: pageList.value.length, + silent: request.silent, }); } } catch (error) { @@ -144,6 +149,7 @@ const loadPageListOnce = async (request: PageDataRequest) => { lightweight, pageNumber: pageInfo.pageNumber, recordCount: pageList.value.length, + silent: request.silent, }); } } @@ -197,13 +203,21 @@ const reload = (options: PageDataReloadOptions = {}) => 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(); }; @@ -213,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), @@ -254,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; @@ -285,9 +326,11 @@ const restoreState = (state: PageDataRestoreState) => { // 暴露方法给父组件 defineExpose({ + getPageRows, getPageState, reload, patchRowById, + removeRowById, restoreState, setQuery, }); @@ -319,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: + '