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)"
/>
@@ -133,13 +134,13 @@ 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 };