发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
3 changed files with 201 additions and 7 deletions
Showing only changes of commit df9fe4c2fe - Show all commits

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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 };