Support async permission checks in route menu generator (#7688)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.21.x

#### What this PR does / why we need it:

Support async permission checks in route menu generator

example:

```ts
{
  path: "",
  name: "Foo",
  component: Foo,
  meta: {
    title: "Foo",
    searchable: true,
    permissions: async () => {
      const { data } = await checkPermission();
      return data;
    },
    menu: {
      name: "Foo",
      group: "content",
      icon: markRaw(MingcuteBook2Line),
      priority: 4,
      mobile: false,
    },
  },
}
```

#### Which issue(s) this PR fixes:

Fixes #

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
开发者相关:路由的权限检查支持函数
```
pull/7700/head
Ryan Wang 2025-08-17 23:43:10 +08:00 committed by GitHub
parent e6f8783389
commit 3f5b69d5d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 258 additions and 141 deletions

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
import MenuLoading from "@/components/menu/MenuLoading.vue";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
import MobileMenu from "@/layouts/MobileMenu.vue";
@ -29,7 +30,7 @@ useEventListener(document, "keydown", (e: KeyboardEvent) => {
}
});
const { menus, minimenus } = useRouteMenuGenerator(coreMenuGroups);
const { data, isLoading } = useRouteMenuGenerator(coreMenuGroups);
// aside scroll
const navbarScroller = ref();
@ -95,7 +96,8 @@ onMounted(() => {
</div>
</div>
</div>
<RoutesMenu :menus="menus" />
<MenuLoading v-if="isLoading" />
<RoutesMenu v-else :menus="data?.menus || []" />
</div>
<div class="sidebar__profile">
<UserProfileBanner platform="console" />
@ -112,7 +114,11 @@ onMounted(() => {
</RouterLink>
</footer>
</main>
<MobileMenu :menus="menus" :minimenus="minimenus" platform="console" />
<MobileMenu
:menus="data?.menus || []"
:minimenus="data?.minimenus || []"
platform="console"
/>
</div>
<GlobalSearchModal
v-if="globalSearchVisible"

View File

@ -11,7 +11,7 @@ import {
VEntityField,
VPageHeader,
} from "@halo-dev/components";
import { computed } from "vue";
import { onMounted, ref } from "vue";
import type { RouteRecordRaw } from "vue-router";
import { useRouter } from "vue-router";
@ -19,24 +19,55 @@ const router = useRouter();
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
const routes = ref<RouteRecordRaw[]>([]);
function isRouteValid(route?: RouteRecordRaw) {
async function isRouteValid(route?: RouteRecordRaw) {
if (!route) return false;
const { meta } = route;
if (!meta?.menu) return false;
return (
!meta.permissions || hasPermission(uiPermissions, meta.permissions, true)
);
// If permissions doesn't exist or is empty
if (!meta.permissions) return true;
// Check if permissions is a function
if (typeof meta.permissions === "function") {
try {
return await meta.permissions(uiPermissions);
} catch (e) {
console.error(
`Error checking permissions for route ${String(route.name)}:`,
e
);
return false;
}
}
// Default behavior for array of permissions
return hasPermission(uiPermissions, meta.permissions as string[], true);
}
const routes = computed(() => {
// Use async function to set routes
const fetchRoutes = async () => {
const matchedRoute = router.currentRoute.value.matched[0];
const childRoutes =
router
.getRoutes()
.find((route) => route.name === matchedRoute.name)
?.children.filter((route) => route.path !== "") || [];
return router
.getRoutes()
.find((route) => route.name === matchedRoute.name)
?.children.filter((route) => route.path !== "")
.filter((route) => isRouteValid(route));
const validRoutes: RouteRecordRaw[] = [];
for (const route of childRoutes) {
if (await isRouteValid(route)) {
validRoutes.push(route);
}
}
routes.value = validRoutes;
};
// Fetch routes on component mount
onMounted(() => {
fetchRoutes();
});
</script>

4
ui/env.d.ts vendored
View File

@ -24,7 +24,9 @@ declare module "vue-router" {
title?: string;
description?: string;
searchable?: boolean;
permissions?: string[];
permissions?:
| string[]
| ((uiPermissions: string[]) => boolean | Promise<boolean>);
core?: boolean;
hideFooter?: boolean;
menu?: {

View File

@ -0,0 +1,21 @@
<script lang="ts" setup></script>
<template>
<div class="p-3">
<ul v-for="y in 2" :key="y" class="mt-5 first:mt-0">
<li
v-for="x in 5"
:key="x"
class="flex h-[36.8px] items-center rounded-base py-[0.4rem]"
>
<div class="mr-3 size-5 animate-pulse rounded-lg bg-gray-200"></div>
<div
class="h-5 animate-pulse rounded-lg bg-gray-200"
:style="{
width: `${Math.random() * 50 + 10}%`,
}"
></div>
</li>
</ul>
</div>
</template>

View File

@ -1,28 +1,20 @@
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import { useQuery } from "@tanstack/vue-query";
import { sortBy } from "lodash-es";
import { onMounted, ref, type Ref } from "vue";
import { ref, watch } from "vue";
import {
useRouter,
type RouteRecordNormalized,
type RouteRecordRaw,
} from "vue-router";
interface useRouteMenuGeneratorReturn {
menus: Ref<MenuGroupType[]>;
minimenus: Ref<MenuItemType[]>;
}
export function useRouteMenuGenerator(
menuGroups: MenuGroupType[]
): useRouteMenuGeneratorReturn {
export function useRouteMenuGenerator(menuGroups: MenuGroupType[]) {
const router = useRouter();
const menus = ref<MenuGroupType[]>([] as MenuGroupType[]);
const minimenus = ref<MenuItemType[]>([] as MenuItemType[]);
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
function flattenRoutes(route: RouteRecordNormalized | RouteRecordRaw) {
@ -35,134 +27,193 @@ export function useRouteMenuGenerator(
return routes;
}
function isRouteValid(route?: RouteRecordNormalized) {
async function isRouteValid(route?: RouteRecordNormalized) {
if (!route) return false;
const { meta } = route;
if (!meta?.menu) return false;
return (
!meta.permissions || hasPermission(uiPermissions, meta.permissions, true)
);
// If permissions doesn't exist or is empty
if (!meta.permissions) return true;
// Check if permissions is a function
if (typeof meta.permissions === "function") {
try {
return await meta.permissions(uiPermissions);
} catch (e) {
console.error(
`Error checking permissions for route ${String(route.name)}:`,
e
);
return false;
}
}
// Default behavior for array of permissions
return hasPermission(uiPermissions, meta.permissions as string[], true);
}
const generateMenus = () => {
// Filter and sort routes based on menu and permissions
let currentRoutes = sortBy<RouteRecordNormalized>(
router.getRoutes().filter((route) => isRouteValid(route)),
[
const { data, isLoading: isDataLoading } = useQuery({
queryKey: ["core:sidebar:menus"],
queryFn: async () => {
const allRoutes = router.getRoutes();
// Filter routes based on permissions (async)
const validRoutePromises = allRoutes.map(async (route) => {
const isValid = await isRouteValid(route);
return isValid ? route : null;
});
// Wait for all permission checks to complete
const validRoutes = (await Promise.all(validRoutePromises)).filter(
Boolean
) as RouteRecordNormalized[];
// Sort the valid routes
let currentRoutes = sortBy<RouteRecordNormalized>(validRoutes, [
(route: RouteRecordRaw) => !route.meta?.core,
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
]
);
]);
// Flatten and filter child routes
currentRoutes.forEach((route) => {
if (route.children.length) {
const routesMap = new Map(
currentRoutes.map((route) => [route.name, route])
);
// Flatten and filter child routes
for (const route of currentRoutes) {
if (route.children.length) {
const routesMap = new Map(
currentRoutes.map((route) => [route.name, route])
);
const flattenedAndValidChildren = route.children
.flatMap((child) => flattenRoutes(child))
.map((flattenedChild) => {
const validRoute = routesMap.get(flattenedChild.name);
if (validRoute && isRouteValid(validRoute)) {
return validRoute;
}
const childRoutesPromises = route.children
.flatMap((child) => flattenRoutes(child))
.map(async (flattenedChild) => {
const validRoute = routesMap.get(flattenedChild.name);
if (validRoute && (await isRouteValid(validRoute))) {
return validRoute;
}
return null;
});
// Wait for all child permission checks to complete
const flattenedAndValidChildren = (
await Promise.all(childRoutesPromises)
).filter(Boolean) as RouteRecordNormalized[]; // filters out falsy values
// Sorting the routes
// @ts-ignore children must be RouteRecordRaw[], but it is RouteRecordNormalized[]
route.children = sortBy(flattenedAndValidChildren, [
(route) => !route?.meta?.core,
(route) => route?.meta?.menu?.priority || 0,
]);
}
}
// Remove duplicate routes
const allChildren = currentRoutes.flatMap((route) => route.children);
currentRoutes = currentRoutes.filter(
(route) => !allChildren.find((child) => child.name === route.name)
);
// group by menu.group
const groupedMenus = currentRoutes.reduce((acc, route) => {
const { menu } = route.meta;
if (!menu) {
return acc;
}
const group = acc.find((item) => item.id === menu.group);
const childRoute = route.children;
const menuChildren: MenuItemType[] = childRoute
.map((child) => {
if (!child.meta?.menu) return;
return {
name: child.meta.menu.name,
path: child.path,
icon: child.meta.menu.icon,
mobile: child.meta.menu.mobile,
};
})
.filter(Boolean); // filters out falsy values
.filter(Boolean) as MenuItemType[];
// Sorting the routes
// @ts-ignore children must be RouteRecordRaw[], but it is RouteRecordNormalized[]
route.children = sortBy(flattenedAndValidChildren, [
(route) => !route?.meta?.core,
(route) => route?.meta?.menu?.priority || 0,
]);
}
});
// Remove duplicate routes
const allChildren = currentRoutes.flatMap((route) => route.children);
currentRoutes = currentRoutes.filter(
(route) => !allChildren.find((child) => child.name === route.name)
);
// group by menu.group
menus.value = currentRoutes.reduce((acc, route) => {
const { menu } = route.meta;
if (!menu) {
if (group) {
group.items?.push({
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
});
} else {
const menuGroup = menuGroups.find((item) => item.id === menu.group);
let name = "";
if (!menuGroup) {
name = menu.group || "";
} else if (menuGroup.name) {
name = menuGroup.name;
}
acc.push({
id: menuGroup?.id || menu.group || "",
name: name,
priority: menuGroup?.priority || 0,
items: [
{
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
},
],
});
}
return acc;
}
const group = acc.find((item) => item.id === menu.group);
const childRoute = route.children;
}, [] as MenuGroupType[]);
const menuChildren: MenuItemType[] = childRoute
.map((child) => {
if (!child.meta?.menu) return;
return {
name: child.meta.menu.name,
path: child.path,
icon: child.meta.menu.icon,
mobile: child.meta.menu.mobile,
};
})
.filter(Boolean) as MenuItemType[];
// sort by menu.priority
const menus = sortBy(groupedMenus, [
(menu: MenuGroupType) => {
return menuGroups.findIndex((item) => item.id === menu.id) < 0;
},
(menu: MenuGroupType) => menu.priority || 0,
]);
if (group) {
group.items?.push({
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
});
const minimenus = menus
.reduce((acc, group) => {
if (group?.items) {
acc.push(...group.items);
}
return acc;
}, [] as MenuItemType[])
.filter((item) => item.mobile);
return {
menus,
minimenus,
};
},
});
const isLoading = ref(false);
// Make loading more user-friendly
watch(
() => isDataLoading.value,
(value) => {
let delayLoadingTimer: ReturnType<typeof setTimeout> | undefined;
if (value) {
delayLoadingTimer = setTimeout(() => {
isLoading.value = isDataLoading.value;
}, 200);
} else {
const menuGroup = menuGroups.find((item) => item.id === menu.group);
let name = "";
if (!menuGroup) {
name = menu.group || "";
} else if (menuGroup.name) {
name = menuGroup.name;
}
acc.push({
id: menuGroup?.id || menu.group || "",
name: name,
priority: menuGroup?.priority || 0,
items: [
{
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
},
],
});
clearTimeout(delayLoadingTimer);
isLoading.value = false;
}
return acc;
}, [] as MenuGroupType[]);
// sort by menu.priority
menus.value = sortBy(menus.value, [
(menu: MenuGroupType) => {
return menuGroups.findIndex((item) => item.id === menu.id) < 0;
},
(menu: MenuGroupType) => menu.priority || 0,
]);
minimenus.value = menus.value
.reduce((acc, group) => {
if (group?.items) {
acc.push(...group.items);
}
return acc;
}, [] as MenuItemType[])
.filter((item) => item.mobile);
};
onMounted(generateMenus);
},
{
immediate: true,
}
);
return {
menus,
minimenus,
data,
isLoading,
};
}

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import MenuLoading from "@/components/menu/MenuLoading.vue";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
import MobileMenu from "@/layouts/MobileMenu.vue";
@ -15,7 +16,7 @@ import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
const route = useRoute();
const { menus, minimenus } = useRouteMenuGenerator(coreMenuGroups);
const { data, isLoading } = useRouteMenuGenerator(coreMenuGroups);
// aside scroll
const navbarScroller = ref();
@ -68,7 +69,8 @@ onMounted(() => {
</a>
</div>
<div ref="navbarScroller" class="sidebar__content">
<RoutesMenu :menus="menus" />
<MenuLoading v-if="isLoading" />
<RoutesMenu :menus="data?.menus || []" />
</div>
<div class="sidebar__profile">
<UserProfileBanner platform="uc" />
@ -89,7 +91,11 @@ onMounted(() => {
</a>
</footer>
</main>
<MobileMenu :menus="menus" :minimenus="minimenus" platform="uc" />
<MobileMenu
:menus="data?.menus || []"
:minimenus="data?.minimenus || []"
platform="uc"
/>
</div>
</template>