From c9db279916e2a6fbf11b638b5ef3690c535c62e7 Mon Sep 17 00:00:00 2001 From: dingwenbin Date: Tue, 1 Sep 2026 10:57:22 +0800 Subject: [PATCH] feat(@vben/layouts): support activePath breadcrumb hierarchy --- .changeset/kind-cats-breadcrumb.md | 6 ++ docs/src/en/guide/essentials/route.md | 12 ++++ docs/src/guide/essentials/route.md | 12 ++++ .../@core/base/typings/src/vue-router.d.ts | 5 ++ .../__tests__/breadcrumb-routes.test.ts | 69 +++++++++++++++++++ .../layouts/src/widgets/breadcrumb-routes.ts | 41 +++++++++++ .../layouts/src/widgets/breadcrumb.vue | 6 +- 7 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 .changeset/kind-cats-breadcrumb.md create mode 100644 packages/effects/layouts/src/widgets/__tests__/breadcrumb-routes.test.ts create mode 100644 packages/effects/layouts/src/widgets/breadcrumb-routes.ts diff --git a/.changeset/kind-cats-breadcrumb.md b/.changeset/kind-cats-breadcrumb.md new file mode 100644 index 000000000..68fe309f7 --- /dev/null +++ b/.changeset/kind-cats-breadcrumb.md @@ -0,0 +1,6 @@ +--- +'@vben-core/typings': minor +'@vben/layouts': minor +--- + +feat: support using the activePath route hierarchy in breadcrumbs diff --git a/docs/src/en/guide/essentials/route.md b/docs/src/en/guide/essentials/route.md index 1a17aaf2c..d24cd6eaf 100644 --- a/docs/src/en/guide/essentials/route.md +++ b/docs/src/en/guide/essentials/route.md @@ -314,6 +314,11 @@ interface RouteMeta { * The currently active menu, sometimes you don't want to activate the existing menu, use this to activate the parent menu */ activePath?: string; + /** + * Whether to use the route hierarchy resolved from activePath in breadcrumbs + * @default false + */ + breadcrumbUseActivePath?: boolean; /** * Whether to fix the tab * @default false @@ -508,6 +513,13 @@ Used to configure the badge color of the page. Used to configure the currently active menu. Sometimes the page is not displayed in the menu, and this is used to activate the parent menu. +### breadcrumbUseActivePath + +- Type: `boolean` +- Default: `false` + +When set to `true`, the breadcrumb supplements the current route matches with the route hierarchy resolved from `activePath`. This is useful for detail pages and other routes that are not nested under the target menu route but still need to display the complete breadcrumb hierarchy. + ### affixTab - Type: `boolean` diff --git a/docs/src/guide/essentials/route.md b/docs/src/guide/essentials/route.md index ce468d742..53ee30391 100644 --- a/docs/src/guide/essentials/route.md +++ b/docs/src/guide/essentials/route.md @@ -307,6 +307,11 @@ interface RouteMeta { * 当前激活的菜单,有时候不想激活现有菜单,需要激活父级菜单时使用 */ activePath?: string; + /** + * 是否使用 activePath 对应的路由层级生成面包屑 + * @default false + */ + breadcrumbUseActivePath?: boolean; /** * 是否固定标签页 * @default false @@ -516,6 +521,13 @@ interface RouteMeta { 用于配置当前激活的菜单,有时候页面没有显示在菜单内,需要激活父级菜单时使用。 +### breadcrumbUseActivePath + +- 类型:`boolean` +- 默认值:`false` + +设置为 `true` 时,面包屑会使用 `activePath` 对应的路由层级补充当前路由的匹配记录。适用于详情页等未嵌套在目标菜单路由下,但仍需要展示完整面包屑层级的页面。 + ### affixTab - 类型:`boolean` diff --git a/packages/@core/base/typings/src/vue-router.d.ts b/packages/@core/base/typings/src/vue-router.d.ts index d7a80fe4b..3e117d52c 100644 --- a/packages/@core/base/typings/src/vue-router.d.ts +++ b/packages/@core/base/typings/src/vue-router.d.ts @@ -10,6 +10,11 @@ interface RouteMeta { * 当前激活的菜单,有时候不想激活现有菜单,需要激活父级菜单时使用 */ activePath?: string; + /** + * 是否使用 activePath 对应的路由层级生成面包屑 + * @default false + */ + breadcrumbUseActivePath?: boolean; /** * 是否固定标签页 * @default false diff --git a/packages/effects/layouts/src/widgets/__tests__/breadcrumb-routes.test.ts b/packages/effects/layouts/src/widgets/__tests__/breadcrumb-routes.test.ts new file mode 100644 index 000000000..102ecfd36 --- /dev/null +++ b/packages/effects/layouts/src/widgets/__tests__/breadcrumb-routes.test.ts @@ -0,0 +1,69 @@ +import type { RouteLocationMatched } from 'vue-router'; + +import { describe, expect, it, vi } from 'vitest'; + +import { resolveBreadcrumbMatches } from '../breadcrumb-routes'; + +function createMatch(name: string, path: string): RouteLocationMatched { + return { name, path } as RouteLocationMatched; +} + +describe('resolveBreadcrumbMatches', () => { + it('preserves the current matches when activePath breadcrumbs are disabled', () => { + const matches = [createMatch('Detail', '/detail')]; + const resolveRoute = vi.fn(); + + expect( + resolveBreadcrumbMatches( + { + matched: matches, + meta: { + activePath: '/list', + breadcrumbUseActivePath: false, + title: 'Detail', + }, + }, + resolveRoute, + ), + ).toBe(matches); + expect(resolveRoute).not.toHaveBeenCalled(); + }); + + it('preserves the current matches when activePath cannot be resolved', () => { + const matches = [createMatch('Detail', '/detail')]; + + expect( + resolveBreadcrumbMatches( + { + matched: matches, + meta: { + activePath: '/missing', + breadcrumbUseActivePath: true, + title: 'Detail', + }, + }, + () => ({ matched: [] }), + ), + ).toBe(matches); + }); + + it('prepends activePath matches and removes duplicate route records', () => { + const root = createMatch('Root', '/'); + const list = createMatch('List', '/list'); + const detail = createMatch('Detail', '/detail'); + + expect( + resolveBreadcrumbMatches( + { + matched: [root, detail], + meta: { + activePath: '/list', + breadcrumbUseActivePath: true, + title: 'Detail', + }, + }, + () => ({ matched: [root, list] }), + ), + ).toEqual([root, list, detail]); + }); +}); diff --git a/packages/effects/layouts/src/widgets/breadcrumb-routes.ts b/packages/effects/layouts/src/widgets/breadcrumb-routes.ts new file mode 100644 index 000000000..5c65b22ff --- /dev/null +++ b/packages/effects/layouts/src/widgets/breadcrumb-routes.ts @@ -0,0 +1,41 @@ +import type { RouteLocationNormalizedLoaded } from 'vue-router'; + +type MatchedRoutes = RouteLocationNormalizedLoaded['matched']; + +interface BreadcrumbRoute { + matched: MatchedRoutes; + meta: RouteLocationNormalizedLoaded['meta']; +} + +type BreadcrumbRouteResolver = (path: string) => { matched: MatchedRoutes }; + +/** + * 解析用于渲染当前面包屑的路由匹配记录。 + */ +export function resolveBreadcrumbMatches( + route: BreadcrumbRoute, + resolveRoute: BreadcrumbRouteResolver, +): MatchedRoutes { + const { activePath, breadcrumbUseActivePath } = route.meta; + + if (!breadcrumbUseActivePath || !activePath) { + return route.matched; + } + + const activeMatches = resolveRoute(activePath).matched; + if (activeMatches.length === 0) { + return route.matched; + } + + const seen = new Set(activeMatches.map((match) => match.name ?? match.path)); + const currentMatches = route.matched.filter((match) => { + const key = match.name ?? match.path; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + return [...activeMatches, ...currentMatches]; +} diff --git a/packages/effects/layouts/src/widgets/breadcrumb.vue b/packages/effects/layouts/src/widgets/breadcrumb.vue index c04196065..390bd1f17 100644 --- a/packages/effects/layouts/src/widgets/breadcrumb.vue +++ b/packages/effects/layouts/src/widgets/breadcrumb.vue @@ -10,6 +10,8 @@ import { $t } from '@vben/locales'; import { VbenBreadcrumbView } from '@vben-core/shadcn-ui'; +import { resolveBreadcrumbMatches } from './breadcrumb-routes'; + interface Props { hideWhenOnlyOne?: boolean; showHome?: boolean; @@ -27,7 +29,9 @@ const route = useRoute(); const router = useRouter(); const breadcrumbs = computed((): IBreadcrumb[] => { - const matched = route.matched; + const matched = resolveBreadcrumbMatches(route, (path) => + router.resolve(path), + ); const resultBreadcrumb: IBreadcrumb[] = [];