fix: 修复页签路由切换页面未更新
- 拆分页签分组与页面渲染身份,确保列表和详情正确切换 - 让禁用缓存的路由绕过 KeepAlive,并补充资源切换回归测试
This commit is contained in:
@@ -9,9 +9,10 @@ import { computed } from 'vue';
|
|||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
|
|
||||||
import { preferences, usePreferences } from '@easyflow/preferences';
|
import { preferences, usePreferences } from '@easyflow/preferences';
|
||||||
import { getTabKey, storeToRefs, useTabbarStore } from '@easyflow/stores';
|
import { storeToRefs, useTabbarStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { IFrameRouterView } from '../../iframe';
|
import { IFrameRouterView } from '../../iframe';
|
||||||
|
import { getRouteViewKey, shouldUseKeepAlive } from './route-view';
|
||||||
|
|
||||||
defineOptions({ name: 'LayoutContent' });
|
defineOptions({ name: 'LayoutContent' });
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ function transformComponent(
|
|||||||
appear
|
appear
|
||||||
>
|
>
|
||||||
<KeepAlive
|
<KeepAlive
|
||||||
v-if="keepAlive"
|
v-if="shouldUseKeepAlive(route, keepAlive)"
|
||||||
:exclude="getExcludeCachedTabs"
|
:exclude="getExcludeCachedTabs"
|
||||||
:include="getCachedTabs"
|
:include="getCachedTabs"
|
||||||
>
|
>
|
||||||
@@ -114,18 +115,18 @@ function transformComponent(
|
|||||||
:is="transformComponent(Component, route)"
|
:is="transformComponent(Component, route)"
|
||||||
v-if="renderRouteView"
|
v-if="renderRouteView"
|
||||||
v-show="!route.meta.iframeSrc"
|
v-show="!route.meta.iframeSrc"
|
||||||
:key="getTabKey(route)"
|
:key="getRouteViewKey(route)"
|
||||||
/>
|
/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
<component
|
<component
|
||||||
:is="Component"
|
:is="Component"
|
||||||
v-else-if="renderRouteView"
|
v-else-if="renderRouteView"
|
||||||
:key="getTabKey(route)"
|
:key="getRouteViewKey(route)"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<KeepAlive
|
<KeepAlive
|
||||||
v-if="keepAlive"
|
v-if="shouldUseKeepAlive(route, keepAlive)"
|
||||||
:exclude="getExcludeCachedTabs"
|
:exclude="getExcludeCachedTabs"
|
||||||
:include="getCachedTabs"
|
:include="getCachedTabs"
|
||||||
>
|
>
|
||||||
@@ -133,13 +134,13 @@ function transformComponent(
|
|||||||
:is="transformComponent(Component, route)"
|
:is="transformComponent(Component, route)"
|
||||||
v-if="renderRouteView"
|
v-if="renderRouteView"
|
||||||
v-show="!route.meta.iframeSrc"
|
v-show="!route.meta.iframeSrc"
|
||||||
:key="getTabKey(route)"
|
:key="getRouteViewKey(route)"
|
||||||
/>
|
/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
<component
|
<component
|
||||||
:is="Component"
|
:is="Component"
|
||||||
v-else-if="renderRouteView"
|
v-else-if="renderRouteView"
|
||||||
:key="getTabKey(route)"
|
:key="getRouteViewKey(route)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
|
|||||||
@@ -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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user