mirror of https://github.com/halo-dev/halo
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
parent
e6f8783389
commit
3f5b69d5d0
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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?: {
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Reference in New Issue