From df9fe4c2feddce9b5dc1e89e0bf2f9b1820aeaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Thu, 23 Jul 2026 18:36:12 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=88=87=E6=8D=A2=E9=A1=B5=E9=9D=A2=E6=9C=AA?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分页签分组与页面渲染身份,确保列表和详情正确切换 - 让禁用缓存的路由绕过 KeepAlive,并补充资源切换回归测试 --- .../layouts/src/basic/content/content.vue | 15 +- .../src/basic/content/route-view.test.ts | 150 ++++++++++++++++++ .../layouts/src/basic/content/route-view.ts | 43 +++++ 3 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.test.ts create mode 100644 easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.ts diff --git a/easyflow-ui-admin/packages/effects/layouts/src/basic/content/content.vue b/easyflow-ui-admin/packages/effects/layouts/src/basic/content/content.vue index c27660a0..c6fd6eb7 100644 --- a/easyflow-ui-admin/packages/effects/layouts/src/basic/content/content.vue +++ b/easyflow-ui-admin/packages/effects/layouts/src/basic/content/content.vue @@ -9,9 +9,10 @@ import { computed } from 'vue'; import { RouterView } from 'vue-router'; import { preferences, usePreferences } from '@easyflow/preferences'; -import { getTabKey, storeToRefs, useTabbarStore } from '@easyflow/stores'; +import { storeToRefs, useTabbarStore } from '@easyflow/stores'; import { IFrameRouterView } from '../../iframe'; +import { getRouteViewKey, shouldUseKeepAlive } from './route-view'; defineOptions({ name: 'LayoutContent' }); @@ -106,7 +107,7 @@ function transformComponent( appear > @@ -114,18 +115,18 @@ function transformComponent( :is="transformComponent(Component, route)" v-if="renderRouteView" v-show="!route.meta.iframeSrc" - :key="getTabKey(route)" + :key="getRouteViewKey(route)" /> diff --git a/easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.test.ts b/easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.test.ts new file mode 100644 index 00000000..5b8ab948 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.test.ts @@ -0,0 +1,150 @@ +import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'; + +import { getTabKey } from '@easyflow/stores'; + +import { describe, expect, it } from 'vitest'; + +import { getRouteViewKey, shouldUseKeepAlive } from './route-view'; + +interface RouteOptions { + fullPath: string; + id?: string; + keepAlive?: boolean; + name: string; + navTitle?: string; + pageKey?: string; + paramId?: string; + path: string; +} + +function createRoute( + options: RouteOptions, +): RouteLocationNormalizedLoadedGeneric { + const query: Record = {}; + if (options.id) { + query.id = options.id; + } + if (options.navTitle) { + query.navTitle = options.navTitle; + } + if (options.pageKey) { + query.pageKey = options.pageKey; + } + return { + fullPath: options.fullPath, + hash: '', + matched: [], + meta: { + keepAlive: options.keepAlive, + title: options.name, + }, + name: options.name, + params: options.paramId ? { id: options.paramId } : {}, + path: options.path, + query, + redirectedFrom: undefined, + } as RouteLocationNormalizedLoadedGeneric; +} + +describe('route view identity', () => { + it('separates list and detail rendering while preserving one tab key', () => { + const listRoute = createRoute({ + fullPath: '/ai/workflow', + keepAlive: false, + name: 'WorkflowList', + path: '/ai/workflow', + }); + const designRoute = createRoute({ + fullPath: + '/ai/workflow/design?id=1&pageKey=/ai/workflow&navTitle=Workflow', + id: '1', + name: 'WorkflowDesign', + navTitle: 'Workflow', + pageKey: '/ai/workflow', + path: '/ai/workflow/design', + }); + + expect(getTabKey(listRoute)).toBe('/ai/workflow'); + expect(getTabKey(designRoute)).toBe('/ai/workflow'); + expect(getRouteViewKey(listRoute)).not.toBe(getRouteViewKey(designRoute)); + }); + + it('keeps title-only query changes on the same component instance', () => { + const firstRoute = createRoute({ + fullPath: '/ai/workflow/design?id=1&pageKey=/ai/workflow&navTitle=First', + id: '1', + name: 'WorkflowDesign', + navTitle: 'First', + pageKey: '/ai/workflow', + path: '/ai/workflow/design', + }); + const renamedRoute = createRoute({ + fullPath: + '/ai/workflow/design?id=1&pageKey=/ai/workflow&navTitle=Renamed', + id: '1', + name: 'WorkflowDesign', + navTitle: 'Renamed', + pageKey: '/ai/workflow', + path: '/ai/workflow/design', + }); + + expect(getRouteViewKey(firstRoute)).toBe(getRouteViewKey(renamedRoute)); + }); + + it('replaces the component instance when the resource id changes', () => { + const firstRoute = createRoute({ + fullPath: '/ai/workflow/design?id=1&pageKey=/ai/workflow', + id: '1', + name: 'WorkflowDesign', + pageKey: '/ai/workflow', + path: '/ai/workflow/design', + }); + const secondRoute = createRoute({ + fullPath: '/ai/workflow/design?id=2&pageKey=/ai/workflow', + id: '2', + name: 'WorkflowDesign', + pageKey: '/ai/workflow', + path: '/ai/workflow/design', + }); + + expect(getRouteViewKey(firstRoute)).not.toBe(getRouteViewKey(secondRoute)); + }); + + it('separates path-param resources that share a tab key', () => { + const firstRoute = createRoute({ + fullPath: '/ai/agents/designer/1?pageKey=/ai/agents', + name: 'AgentDesigner', + pageKey: '/ai/agents', + paramId: '1', + path: '/ai/agents/designer/1', + }); + const secondRoute = createRoute({ + fullPath: '/ai/agents/designer/2?pageKey=/ai/agents', + name: 'AgentDesigner', + pageKey: '/ai/agents', + paramId: '2', + path: '/ai/agents/designer/2', + }); + + expect(getRouteViewKey(firstRoute)).not.toBe(getRouteViewKey(secondRoute)); + }); + + it('bypasses KeepAlive only when caching is explicitly disabled', () => { + const uncachedRoute = createRoute({ + fullPath: '/ai/workflow', + keepAlive: false, + name: 'WorkflowList', + path: '/ai/workflow', + }); + const defaultRoute = createRoute({ + fullPath: '/ai/workflow/design?id=1', + id: '1', + name: 'WorkflowDesign', + path: '/ai/workflow/design', + }); + + expect(shouldUseKeepAlive(uncachedRoute, true)).toBe(false); + expect(shouldUseKeepAlive(defaultRoute, true)).toBe(true); + expect(shouldUseKeepAlive(defaultRoute, false)).toBe(false); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.ts b/easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.ts new file mode 100644 index 00000000..ccc23ec6 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/layouts/src/basic/content/route-view.ts @@ -0,0 +1,43 @@ +import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'; + +import { getTabKey } from '@easyflow/stores'; + +function resolveRouteResourceId( + route: RouteLocationNormalizedLoadedGeneric, +): string { + const rawId = route.params?.id ?? route.query?.id; + const resourceId = Array.isArray(rawId) ? rawId[0] : rawId; + return resourceId === null || resourceId === undefined + ? '' + : String(resourceId); +} + +/** + * 生成独立于页签分组语义的页面渲染 key。 + * + * 同一个 pageKey 可以继续合并到同一页签,但不同路由组件或业务资源 + * 必须拥有不同的虚拟节点身份,避免 KeepAlive 复用错误页面。 + */ +function getRouteViewKey(route: RouteLocationNormalizedLoadedGeneric): string { + return JSON.stringify([ + getTabKey(route), + route.name === null || route.name === undefined + ? route.path + : String(route.name), + resolveRouteResourceId(route), + ]); +} + +/** + * 判断当前路由是否应进入 KeepAlive 容器。 + * + * 仅显式关闭缓存的路由绕过 KeepAlive,保留其余路由的既有行为。 + */ +function shouldUseKeepAlive( + route: RouteLocationNormalizedLoadedGeneric, + keepAliveEnabled: boolean | undefined, +): boolean { + return keepAliveEnabled === true && route.meta.keepAlive !== false; +} + +export { getRouteViewKey, shouldUseKeepAlive };