feat: add breadcrumb navigation example

pull/48/MERGE
vince 2024-07-19 01:26:13 +08:00
parent 0c245665a9
commit 9ec91ac16d
24 changed files with 188 additions and 75 deletions

View File

@ -37,7 +37,12 @@
"features": { "features": {
"title": "Features", "title": "Features",
"hideChildrenInMenu": "Hide Menu Children", "hideChildrenInMenu": "Hide Menu Children",
"loginExpired": "Login Expired" "loginExpired": "Login Expired",
"breadcrumbNavigation": "Breadcrumb Navigation",
"breadcrumbLateral": "Lateral Mode",
"breadcrumbLateralDetail": "Lateral Mode Detail",
"breadcrumbLevel": "Level Mode",
"breadcrumbLevelDetail": "Level Mode Detail"
} }
} }
} }

View File

@ -39,7 +39,12 @@
"features": { "features": {
"title": "功能", "title": "功能",
"hideChildrenInMenu": "隐藏子菜单", "hideChildrenInMenu": "隐藏子菜单",
"loginExpired": "登录过期" "loginExpired": "登录过期",
"breadcrumbNavigation": "面包屑导航",
"breadcrumbLateral": "平级模式",
"breadcrumbLevel": "层级模式",
"breadcrumbLevelDetail": "层级模式详情",
"breadcrumbLateralDetail": "平级模式详情"
} }
} }
} }

View File

@ -127,6 +127,60 @@ const routes: RouteRecordRaw[] = [
title: $t('page.demos.features.loginExpired'), title: $t('page.demos.features.loginExpired'),
}, },
}, },
{
name: 'BreadcrumbDemos',
path: 'breadcrumb',
meta: {
icon: 'lucide:navigation',
title: $t('page.demos.features.breadcrumbNavigation'),
},
children: [
{
name: 'BreadcrumbLateral',
path: 'lateral',
component: () =>
import('#/views/demos/features/breadcrumb/lateral.vue'),
meta: {
icon: 'lucide:navigation',
title: $t('page.demos.features.breadcrumbLateral'),
},
},
{
name: 'BreadcrumbLateralDetail',
path: 'lateral-detail',
component: () =>
import(
'#/views/demos/features/breadcrumb/lateral-detail.vue'
),
meta: {
activePath: '/demos/features/breadcrumb/lateral',
hideInMenu: true,
title: $t('page.demos.features.breadcrumbLateralDetail'),
},
},
{
name: 'BreadcrumbLevel',
path: 'level',
meta: {
icon: 'lucide:navigation',
title: $t('page.demos.features.breadcrumbLevel'),
},
children: [
{
name: 'BreadcrumbLevelDetail',
path: 'detail',
component: () =>
import(
'#/views/demos/features/breadcrumb/level-detail.vue'
),
meta: {
title: $t('page.demos.features.breadcrumbLevelDetail'),
},
},
],
},
],
},
], ],
}, },
{ {
@ -179,6 +233,8 @@ const routes: RouteRecordRaw[] = [
}, },
{ {
meta: { meta: {
badgeType: 'dot',
badgeVariants: 'destructive',
icon: 'lucide:circle-dot', icon: 'lucide:circle-dot',
title: $t('page.demos.badge.title'), title: $t('page.demos.badge.title'),
}, },
@ -201,7 +257,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/demos/badge/index.vue'), component: () => import('#/views/demos/badge/index.vue'),
path: 'text', path: 'text',
meta: { meta: {
badge: 'New', badge: '10',
icon: 'lucide:square-dot', icon: 'lucide:square-dot',
title: $t('page.demos.badge.text'), title: $t('page.demos.badge.text'),
}, },

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { Fallback } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
defineOptions({ name: 'BreadcrumbLateralDetail' });
const router = useRouter();
</script>
<template>
<Fallback
description="面包屑导航-平级模式-详情页"
status="coming-soon"
title="注意观察面包屑导航变化"
>
<template #action>
<Button @click="router.go(-1)"></Button>
</template>
</Fallback>
</template>

View File

@ -0,0 +1,27 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { Fallback } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
defineOptions({ name: 'BreadcrumbLateral' });
const router = useRouter();
function details() {
router.push({ name: 'BreadcrumbLateralDetail' });
}
</script>
<template>
<Fallback
description="点击查看详情,并观察面包屑导航变化"
status="coming-soon"
title="面包屑导航-平级模式"
>
<template #action>
<Button type="primary" @click="details"></Button>
</template>
</Fallback>
</template>

View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'BreadcrumbLevelDetail' });
</script>
<template>
<Fallback
description="面包屑导航-层级模式-详情页"
status="coming-soon"
title="注意观察面包屑导航变化"
/>
</template>

View File

@ -53,6 +53,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/home', path: '/home',
show: true,
children: [], children: [],
}, },
{ {
@ -65,6 +66,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/about', path: '/about',
show: true,
children: [], children: [],
}, },
]; ];
@ -94,6 +96,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/profile', path: '/profile',
show: true,
children: [], children: [],
}, },
]); ]);
@ -120,6 +123,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/users/:userId', path: '/users/:userId',
show: true,
children: [], children: [],
}, },
]); ]);
@ -155,6 +159,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/old-path', path: '/old-path',
show: true,
children: [], children: [],
}, },
{ {
@ -167,6 +172,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/new-path', path: '/new-path',
show: true,
children: [], children: [],
}, },
]); ]);
@ -203,6 +209,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/about', path: '/about',
show: true,
children: [], children: [],
}, },
{ {
@ -215,6 +222,7 @@ describe('generateMenus', () => {
parent: undefined, parent: undefined,
parents: undefined, parents: undefined,
path: '/', path: '/',
show: true,
children: [], children: [],
}, },
]; ];

View File

@ -1,7 +1,7 @@
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings'; import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
import type { RouteRecordRaw, Router } from 'vue-router'; import type { RouteRecordRaw, Router } from 'vue-router';
import { mapTree } from '@vben-core/toolkit'; import { filterTree, mapTree } from '@vben-core/toolkit';
/** /**
* routes * routes
@ -61,13 +61,18 @@ async function generateMenus(
parent: route.parent, parent: route.parent,
parents: route.parents, parents: route.parents,
path: resultPath as string, path: resultPath as string,
show: !route?.meta?.hideInMenu,
children: resultChildren || [], children: resultChildren || [],
}; };
}); });
// 对菜单进行排序 // 对菜单进行排序
menus = menus.sort((a, b) => (a.order || 999) - (b.order || 999)); menus = menus.sort((a, b) => (a.order || 999) - (b.order || 999));
return menus;
const finalMenus = filterTree(menus, (menu) => {
return !!menu.show;
});
return finalMenus;
} }
export { generateMenus }; export { generateMenus };

View File

@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest';
import { import {
generateRoutesByFrontend, generateRoutesByFrontend,
hasAuthority, hasAuthority,
hasVisible,
} from './generate-routes-frontend'; } from './generate-routes-frontend';
// Mock 路由数据 // Mock 路由数据
@ -51,37 +50,7 @@ describe('hasAuthority', () => {
}); });
}); });
describe('hasVisible', () => {
it('should return true if hideInMenu is not set or false', () => {
expect(hasVisible(mockRoutes[0])).toBe(true);
expect(hasVisible(mockRoutes[2])).toBe(true);
});
it('should return false if hideInMenu is true', () => {
expect(hasVisible(mockRoutes[0].children?.[1])).toBe(false);
});
});
describe('generateRoutesByFrontend', () => { describe('generateRoutesByFrontend', () => {
it('should filter routes based on authority and visibility', async () => {
const generatedRoutes = await generateRoutesByFrontend(mockRoutes, [
'user',
]);
// The user should have access to /dashboard/stats, but it should be filtered out because it's not visible
expect(generatedRoutes).toEqual([
{
meta: { authority: ['admin', 'user'], hideInMenu: false },
path: '/dashboard',
children: [],
},
// Note: We expect /settings to be filtered out because the user does not have 'admin' authority
{
meta: { hideInMenu: false },
path: '/profile',
},
]);
});
it('should handle routes without children', async () => { it('should handle routes without children', async () => {
const generatedRoutes = await generateRoutesByFrontend(mockRoutes, [ const generatedRoutes = await generateRoutesByFrontend(mockRoutes, [
'user', 'user',

View File

@ -12,7 +12,7 @@ async function generateRoutesByFrontend(
): Promise<RouteRecordRaw[]> { ): Promise<RouteRecordRaw[]> {
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限 // 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
const finalRoutes = filterTree(routes, (route) => { const finalRoutes = filterTree(routes, (route) => {
return hasVisible(route) && hasAuthority(route, roles); return hasAuthority(route, roles);
}); });
if (!forbiddenComponent) { if (!forbiddenComponent) {
@ -43,14 +43,6 @@ function hasAuthority(route: RouteRecordRaw, access: string[]) {
return canAccess || (!canAccess && menuHasVisibleWithForbidden(route)); return canAccess || (!canAccess && menuHasVisibleWithForbidden(route));
} }
/**
*
* @param route
*/
function hasVisible(route?: RouteRecordRaw) {
return !route?.meta?.hideInMenu;
}
/** /**
* 访403 * 访403
* @param route * @param route
@ -63,4 +55,4 @@ function menuHasVisibleWithForbidden(route: RouteRecordRaw) {
); );
} }
export { generateRoutesByFrontend, hasAuthority, hasVisible }; export { generateRoutesByFrontend, hasAuthority };

View File

@ -169,7 +169,7 @@
"width": "宽度", "width": "宽度",
"visible": "显示侧边栏", "visible": "显示侧边栏",
"collapsed": "折叠菜单", "collapsed": "折叠菜单",
"collapsedShowTitle": "显示菜单名" "collapsedShowTitle": "折叠显示菜单名"
}, },
"tabbar": { "tabbar": {
"title": "标签栏", "title": "标签栏",

View File

@ -122,7 +122,7 @@
--background: 20 14.3% 4.1%; --background: 20 14.3% 4.1%;
--background-deep: var(--background); --background-deep: var(--background);
--foreground: 0 0% 95%; --foreground: 0 0% 95%;
--card: 24 9.8% 10%; --card: 0 0% 9%;
--card-foreground: 0 0% 95%; --card-foreground: 0 0% 95%;
--popover: 0 0% 9%; --popover: 0 0% 9%;
--popover-foreground: 0 0% 95%; --popover-foreground: 0 0% 95%;
@ -222,7 +222,7 @@
--background: 20 14.3% 4.1%; --background: 20 14.3% 4.1%;
--background-deep: var(--background); --background-deep: var(--background);
--foreground: 0 0% 95%; --foreground: 0 0% 95%;
--card: 24 9.8% 10%; --card: 24 9.8% 6%;
--card-foreground: 0 0% 95%; --card-foreground: 0 0% 95%;
--popover: 0 0% 9%; --popover: 0 0% 9%;
--popover-foreground: 0 0% 95%; --popover-foreground: 0 0% 95%;
@ -247,7 +247,7 @@
--background: 20 14.3% 4.1%; --background: 20 14.3% 4.1%;
--background-deep: var(--background); --background-deep: var(--background);
--foreground: 0 0% 95%; --foreground: 0 0% 95%;
--card: 24 9.8% 10%; --card: 24 9.8% 6%;
--card-foreground: 0 0% 95%; --card-foreground: 0 0% 95%;
--popover: 0 0% 9%; --popover: 0 0% 9%;
--popover-foreground: 0 0% 95%; --popover-foreground: 0 0% 95%;

View File

@ -3,6 +3,11 @@ import type { RouteRecordRaw, Router } from 'vue-router';
import type { Component } from 'vue'; import type { Component } from 'vue';
interface RouteMeta { interface RouteMeta {
/**
* 使
* @default false
*/
activePath?: string;
/** /**
* *
* @default false * @default false
@ -88,7 +93,6 @@ interface RouteMeta {
* -> * ->
*/ */
order?: number; order?: number;
/** /**
* *
*/ */

View File

@ -40,7 +40,6 @@
"@vben-core/hooks": "workspace:*", "@vben-core/hooks": "workspace:*",
"@vben-core/icons": "workspace:*", "@vben-core/icons": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*", "@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*", "@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.11.0", "@vueuse/core": "^10.11.0",
"vue": "^3.4.32" "vue": "^3.4.32"

View File

@ -253,7 +253,7 @@ function handleMouseleave() {
}, },
]" ]"
:style="style" :style="style"
class="border-border fixed left-0 top-0 h-full border-r transition-all duration-150" class="fixed left-0 top-0 h-full transition-all duration-150"
@mouseenter="handleMouseenter" @mouseenter="handleMouseenter"
@mouseleave="handleMouseleave" @mouseleave="handleMouseleave"
> >
@ -277,10 +277,10 @@ function handleMouseleave() {
v-if="isSidebarMixed" v-if="isSidebarMixed"
ref="asideRef" ref="asideRef"
:class="{ :class="{
'border-r': extraVisible, 'border-l': extraVisible,
}" }"
:style="extraStyle" :style="extraStyle"
class="border-border bg-sidebar fixed top-0 h-full overflow-hidden transition-all duration-200" class="border-border bg-sidebar fixed top-0 h-full overflow-hidden border-r transition-all duration-200"
> >
<SidebarCollapseButton <SidebarCollapseButton
v-if="isSidebarMixed && expandOnHover" v-if="isSidebarMixed && expandOnHover"

View File

@ -106,6 +106,7 @@ onBeforeUnmount(() => {
<div v-show="!showTooltip" :class="[e('content')]"> <div v-show="!showTooltip" :class="[e('content')]">
<VbenMenuBadge <VbenMenuBadge
v-if="rootMenu.props.mode !== 'horizontal'" v-if="rootMenu.props.mode !== 'horizontal'"
class="right-2"
v-bind="props" v-bind="props"
/> />
<VbenIcon :class="nsMenu.e('icon')" :icon="icon" fallback /> <VbenIcon :class="nsMenu.e('icon')" :icon="icon" fallback />

View File

@ -507,12 +507,12 @@ $namespace: vben;
} }
&.is-light { &.is-light {
--menu-item-active-color: hsl(var(--primary)); --menu-item-active-color: hsl(var(--primary-foreground));
--menu-item-active-background-color: hsl(var(--primary) / 15%); --menu-item-active-background-color: hsl(var(--primary));
--menu-item-hover-background-color: hsl(var(--accent)); --menu-item-hover-background-color: hsl(var(--accent));
--menu-item-hover-color: hsl(var(--primary)); --menu-item-hover-color: hsl(var(--primary));
--menu-submenu-active-color: hsl(var(--primary)); --menu-submenu-active-color: hsl(var(--primary-foreground));
--menu-submenu-active-background-color: hsl(var(--primary) / 15%); --menu-submenu-active-background-color: hsl(var(--primary));
--menu-submenu-hover-color: hsl(var(--primary)); --menu-submenu-hover-color: hsl(var(--primary));
--menu-submenu-hover-background-color: hsl(var(--accent)); --menu-submenu-hover-background-color: hsl(var(--accent));
} }

View File

@ -69,16 +69,17 @@ $namespace: vben;
&.is-dark { &.is-dark {
.#{$namespace}-normal-menu__item { .#{$namespace}-normal-menu__item {
color: hsl(var(--foreground) / 80%); @apply text-foreground/80;
// color: hsl(var(--foreground) / 80%);
&:not(.is-active):hover { &:not(.is-active):hover {
color: hsl(var(--primary-foreground)); @apply text-foreground;
} }
&.is-active { &.is-active {
.#{$namespace}-normal-menu__name, .#{$namespace}-normal-menu__name,
.#{$namespace}-normal-menu__icon { .#{$namespace}-normal-menu__icon {
color: hsl(var(--primary-foreground)); @apply text-foreground;
} }
} }
} }
@ -117,11 +118,11 @@ $namespace: vben;
border-color 0.15s ease; border-color 0.15s ease;
&.is-active { &.is-active {
@apply text-primary bg-primary/15 dark:bg-accent; @apply text-primary bg-primary dark:bg-accent;
.#{$namespace}-normal-menu__name, .#{$namespace}-normal-menu__name,
.#{$namespace}-normal-menu__icon { .#{$namespace}-normal-menu__icon {
@apply text-primary font-semibold; @apply text-primary-foreground font-semibold;
} }
} }

View File

@ -56,6 +56,7 @@ const hasChildren = computed(() => {
:badge="menu.badge" :badge="menu.badge"
:badge-type="menu.badgeType" :badge-type="menu.badgeType"
:badge-variants="menu.badgeVariants" :badge-variants="menu.badgeVariants"
class="right-6"
/> />
</template> </template>
<template #title>{{ menu.name }}</template> <template #title>{{ menu.name }}</template>

View File

@ -43,13 +43,13 @@ const badgeStyle = computed(() => {
}); });
</script> </script>
<template> <template>
<span v-if="isDot || badge" :class="$attrs.class" class="absolute right-6"> <span v-if="isDot || badge" :class="$attrs.class" class="absolute">
<BadgeDot v-if="isDot" :dot-class="badgeClass" :dot-style="badgeStyle" /> <BadgeDot v-if="isDot" :dot-class="badgeClass" :dot-style="badgeStyle" />
<div <div
v-else v-else
:class="badgeClass" :class="badgeClass"
:style="badgeStyle" :style="badgeStyle"
class="text-primary-foreground rounded-xl px-1.5 py-0.5 text-xs" class="text-primary-foreground flex-center rounded-xl px-1.5 py-0.5 text-[10px]"
> >
{{ badge }} {{ badge }}
</div> </div>

View File

@ -80,14 +80,19 @@ function useExtraMenu() {
watch( watch(
() => route.path, () => route.path,
() => { (path) => {
const currentPath = path;
// if (preferences.sidebar.expandOnHover) {
// return;
// }
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath( const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
menus.value, menus.value,
route.path, currentPath,
); );
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? ''; extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
extraMenus.value = rootMenu?.children ?? []; extraMenus.value = rootMenu?.children ?? [];
}, },
{ immediate: true },
); );
return { return {

View File

@ -57,7 +57,7 @@ function useMixedMenu() {
* *
*/ */
const sidebarActive = computed(() => { const sidebarActive = computed(() => {
return route.path; return route?.meta?.activePath ?? route.path;
}); });
/** /**
@ -104,9 +104,11 @@ function useMixedMenu() {
watch( watch(
() => route.path, () => route.path,
(path: string) => { (path) => {
calcSideMenus(path); const currentPath = (route?.meta?.activePath as string) ?? path;
calcSideMenus(currentPath);
}, },
{ immediate: true },
); );
// 初始化计算侧边菜单 // 初始化计算侧边菜单

View File

@ -27,7 +27,7 @@ const sidebarCollapsed = defineModel<boolean>('sidebarCollapsed');
</SwitchItem> </SwitchItem>
<SwitchItem <SwitchItem
v-model="sidebarCollapsedShowTitle" v-model="sidebarCollapsedShowTitle"
:disabled="!sidebarEnable || disabled" :disabled="!sidebarEnable || disabled || !sidebarCollapsed"
> >
{{ $t('preferences.sidebar.collapsedShowTitle') }} {{ $t('preferences.sidebar.collapsedShowTitle') }}
</SwitchItem> </SwitchItem>

View File

@ -741,9 +741,6 @@ importers:
'@vben-core/shadcn-ui': '@vben-core/shadcn-ui':
specifier: workspace:* specifier: workspace:*
version: link:../shadcn-ui version: link:../shadcn-ui
'@vben-core/toolkit':
specifier: workspace:*
version: link:../../shared/toolkit
'@vben-core/typings': '@vben-core/typings':
specifier: workspace:* specifier: workspace:*
version: link:../../shared/typings version: link:../../shared/typings