diff --git a/easyflow-ui-admin/app/package.json b/easyflow-ui-admin/app/package.json index 91f278c7..9391616a 100644 --- a/easyflow-ui-admin/app/package.json +++ b/easyflow-ui-admin/app/package.json @@ -13,6 +13,14 @@ "#/*": "./src/*" }, "dependencies": { + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.12.2", + "@codemirror/legacy-modes": "^6.5.1", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.15", + "@easyflow-core/editor-ui": "workspace:*", "@easyflow-core/shadcn-ui": "workspace:*", "@easyflow/access": "workspace:*", "@easyflow/common-ui": "workspace:*", @@ -28,13 +36,6 @@ "@easyflow/styles": "workspace:*", "@easyflow/types": "workspace:*", "@easyflow/utils": "workspace:*", - "@codemirror/commands": "^6.10.2", - "@codemirror/lang-javascript": "^6.2.4", - "@codemirror/lang-python": "^6.2.1", - "@codemirror/language": "^6.12.2", - "@codemirror/legacy-modes": "^6.5.1", - "@codemirror/state": "^6.5.4", - "@codemirror/view": "^6.39.15", "@element-plus/icons-vue": "^2.3.2", "@tinyflow-ai/vue": "workspace:*", "@ungap/structured-clone": "1.3.0", @@ -55,7 +56,8 @@ "vue-element-plus-x": "catalog:", "vue-router": "catalog:", "vue3-json-viewer": "^2.4.1", - "wicg-inert": "3.1.3" + "wicg-inert": "3.1.3", + "yaml": "^2.8.1" }, "devDependencies": { "@types/node-forge": "^1.3.14", diff --git a/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue b/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue index a9610035..d23f6786 100644 --- a/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue +++ b/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue @@ -73,6 +73,10 @@ const handleReset = () => { emit('search', ''); }; +defineExpose({ + reset: handleReset, +}); + const handleButtonClick = (button) => { emitButtonEvent({ type: 'button', @@ -133,6 +137,7 @@ const handleDropdownClick = (button) => { :type="button.type || 'default'" :icon="button.icon" :disabled="button.disabled" + :loading="button.loading" v-access:code="button.permission" @click="handleButtonClick(button)" > diff --git a/easyflow-ui-admin/app/src/components/page/PageData.test.ts b/easyflow-ui-admin/app/src/components/page/PageData.test.ts new file mode 100644 index 00000000..7707beb3 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/page/PageData.test.ts @@ -0,0 +1,28 @@ +import { flushPromises, mount } from '@vue/test-utils'; + +import { describe, expect, it, vi } from 'vitest'; + +import PageData from './PageData.vue'; + +describe('page data recovery', () => { + it('shows a retry action after a page request fails', async () => { + const get = vi + .fn() + .mockRejectedValueOnce(new Error('network unavailable')) + .mockResolvedValueOnce({ data: { records: [], totalRow: 0 } }); + const wrapper = mount(PageData, { + global: { + directives: { loading: {} }, + }, + props: { pageUrl: '/page', requestClient: { get } }, + }); + + await flushPromises(); + expect(wrapper.text()).toContain('数据加载失败,请重试'); + + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(get).toHaveBeenCalledTimes(2); + expect(wrapper.text()).not.toContain('数据加载失败,请重试'); + }); +}); diff --git a/easyflow-ui-admin/app/src/components/page/PageData.vue b/easyflow-ui-admin/app/src/components/page/PageData.vue index a0c84663..35a0b78b 100644 --- a/easyflow-ui-admin/app/src/components/page/PageData.vue +++ b/easyflow-ui-admin/app/src/components/page/PageData.vue @@ -3,7 +3,7 @@ import { onMounted, reactive, ref, watch } from 'vue'; import { preferences } from '@easyflow/preferences'; -import { ElEmpty, ElPagination } from 'element-plus'; +import { ElButton, ElEmpty, ElPagination } from 'element-plus'; import { api } from '#/api/request'; import { getEmptyStateImageUrl } from '#/utils/assets'; @@ -28,7 +28,9 @@ const props = withDefaults(defineProps(), { // 响应式数据 const pageList = ref([]); const loading = ref(false); +const loadError = ref(); const queryParams = ref>({}); +let pageRequest = 0; const pageInfo = reactive({ pageNumber: 1, @@ -38,22 +40,20 @@ const pageInfo = reactive({ // 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整 const doGet = async (params: Record) => { - loading.value = true; - try { - // 这里替换为你的实际 API 调用 - // 例如:return await api.get(props.pageUrl, { params }) - const response = await props.requestClient.get(`${props.pageUrl}`, { - params, - }); - const data = await response.data; - return { data }; - } finally { - loading.value = false; - } + // 这里替换为你的实际 API 调用 + // 例如:return await api.get(props.pageUrl, { params }) + const response = await props.requestClient.get(`${props.pageUrl}`, { + params, + }); + const data = await response.data; + return { data }; }; // 获取页面数据 const getPageList = async () => { + const request = ++pageRequest; + loading.value = true; + loadError.value = undefined; try { const res = await doGet({ pageNumber: pageInfo.pageNumber, @@ -61,12 +61,18 @@ const getPageList = async () => { ...props.extraQueryParams, ...queryParams.value, }); - pageList.value = res.data?.records || []; - pageInfo.total = res.data?.totalRow || 0; + if (request === pageRequest) { + pageList.value = res.data?.records || []; + pageInfo.total = res.data?.totalRow || 0; + } } catch (error) { - console.error('get data error:', error); - pageList.value = []; - pageInfo.total = 0; + if (request === pageRequest) { + loadError.value = error; + pageList.value = []; + pageInfo.total = 0; + } + } finally { + if (request === pageRequest) loading.value = false; } }; @@ -104,7 +110,6 @@ const setQuery = (newQueryParams: Record) => { pageInfo.pageNumber = 1; pageInfo.pageSize = props.pageSize; queryParams.value = newQueryParams; - getPageList(); }; // 暴露方法给父组件 @@ -116,7 +121,7 @@ defineExpose({ // 监听器 watch( - [() => pageInfo.pageNumber, () => pageInfo.pageSize], + [() => pageInfo.pageNumber, () => pageInfo.pageSize, () => queryParams.value], () => { getPageList(); }, @@ -150,6 +155,16 @@ onMounted(() => { /> + + + 重新加载 + + diff --git a/easyflow-ui-admin/app/src/components/page/PageSide.test.ts b/easyflow-ui-admin/app/src/components/page/PageSide.test.ts new file mode 100644 index 00000000..6e0eee71 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/page/PageSide.test.ts @@ -0,0 +1,141 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { describe, expect, it, vi } from 'vitest'; + +import PageSide from './PageSide.vue'; + +/* eslint-disable vue/one-component-per-file -- Inline dropdown stubs keep this component test focused. */ + +const DropdownStub = defineComponent({ + setup(_, { slots }) { + return () => + h('div', { class: 'dropdown-stub' }, [ + slots.default?.(), + slots.dropdown?.(), + ]); + }, +}); +const DropdownMenuStub = defineComponent({ + setup(_, { slots }) { + return () => h('div', { class: 'dropdown-menu-stub' }, slots.default?.()); + }, +}); +const DropdownItemStub = defineComponent({ + props: { disabled: Boolean }, + emits: ['click'], + setup(props, { emit, slots }) { + return () => + h( + 'div', + { + class: 'dropdown-item-stub', + 'data-disabled': String(props.disabled), + onClick: () => { + if (!props.disabled) emit('click'); + }, + }, + slots.default?.(), + ); + }, +}); + +describe('page side', () => { + it('keeps the existing flat menu selection and footer behavior', async () => { + const add = vi.fn(); + const wrapper = mount(PageSide, { + props: { + defaultSelected: '', + footerButton: { label: '添加', onClick: add }, + labelKey: 'name', + menus: [ + { id: '', name: '全部' }, + { id: 'docs', name: '文档' }, + ], + valueKey: 'id', + }, + }); + + expect(wrapper.findAll('.page-side__item')).toHaveLength(2); + expect(wrapper.findAll('.page-side__item')[0]?.classes()).toContain( + 'is-selected', + ); + + await wrapper.findAll('.page-side__item')[1]?.trigger('click'); + expect(wrapper.emitted('change')?.[0]?.[0]).toMatchObject({ id: 'docs' }); + + await wrapper.get('.page-side__footer').trigger('click'); + expect(add).toHaveBeenCalledOnce(); + }); + + it('renders nested menus and emits the selected child', async () => { + const wrapper = mount(PageSide, { + props: { + childrenKey: 'children', + defaultSelected: '', + labelKey: 'name', + menus: [ + { id: '', name: '全部' }, + { + children: [{ id: 2, name: '平台' }], + id: 1, + name: '研发', + }, + ], + valueKey: 'id', + }, + }); + await flushPromises(); + + expect(wrapper.findAll('.el-tree-node__content')).toHaveLength(3); + const all = wrapper + .findAll('.el-tree-node__content') + .find((node) => node.text().includes('全部')); + expect(all?.element.parentElement?.classList).toContain('is-current'); + const child = wrapper + .findAll('.el-tree-node__content') + .find((node) => node.text().includes('平台')); + expect(child).toBeTruthy(); + await child?.trigger('click'); + expect(wrapper.emitted('change')?.at(-1)?.[0]).toMatchObject({ id: 2 }); + }); + + it('supports per-node disabled action states', async () => { + const edit = vi.fn(); + const remove = vi.fn(); + const wrapper = mount(PageSide, { + global: { + stubs: { + ElDropdown: DropdownStub, + ElDropdownItem: DropdownItemStub, + ElDropdownMenu: DropdownMenuStub, + }, + }, + props: { + controlBtns: [ + { + disabled: () => true, + label: '编辑', + onClick: edit, + }, + { label: '删除', onClick: remove }, + ], + labelKey: 'name', + menus: [ + { id: '', name: '全部' }, + { id: 1, name: '研发' }, + ], + valueKey: 'id', + }, + }); + + const actions = wrapper.findAll('.dropdown-item-stub'); + expect(actions).toHaveLength(2); + expect(actions[0]?.attributes('data-disabled')).toBe('true'); + await actions[0]?.trigger('click'); + expect(edit).not.toHaveBeenCalled(); + + await actions[1]?.trigger('click'); + expect(remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/easyflow-ui-admin/app/src/components/page/PageSide.vue b/easyflow-ui-admin/app/src/components/page/PageSide.vue index 0bd88ee6..fe22fe97 100644 --- a/easyflow-ui-admin/app/src/components/page/PageSide.vue +++ b/easyflow-ui-admin/app/src/components/page/PageSide.vue @@ -1,10 +1,9 @@