diff --git a/ui/console-src/layouts/BasicLayout.vue b/ui/console-src/layouts/BasicLayout.vue index 402e922d0..9b84389d3 100644 --- a/ui/console-src/layouts/BasicLayout.vue +++ b/ui/console-src/layouts/BasicLayout.vue @@ -1,5 +1,6 @@ diff --git a/ui/env.d.ts b/ui/env.d.ts index 58ad046f7..d08eb4e6f 100644 --- a/ui/env.d.ts +++ b/ui/env.d.ts @@ -24,7 +24,9 @@ declare module "vue-router" { title?: string; description?: string; searchable?: boolean; - permissions?: string[]; + permissions?: + | string[] + | ((uiPermissions: string[]) => boolean | Promise); core?: boolean; hideFooter?: boolean; menu?: { diff --git a/ui/src/components/menu/MenuLoading.vue b/ui/src/components/menu/MenuLoading.vue new file mode 100644 index 000000000..71a28397d --- /dev/null +++ b/ui/src/components/menu/MenuLoading.vue @@ -0,0 +1,21 @@ + + + diff --git a/ui/src/composables/use-route-menu-generator.ts b/ui/src/composables/use-route-menu-generator.ts index da4ead0f5..7c5324729 100644 --- a/ui/src/composables/use-route-menu-generator.ts +++ b/ui/src/composables/use-route-menu-generator.ts @@ -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; - minimenus: Ref; -} - -export function useRouteMenuGenerator( - menuGroups: MenuGroupType[] -): useRouteMenuGeneratorReturn { +export function useRouteMenuGenerator(menuGroups: MenuGroupType[]) { const router = useRouter(); - const menus = ref([] as MenuGroupType[]); - const minimenus = ref([] 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( - 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(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 | 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, }; } diff --git a/ui/uc-src/layouts/BasicLayout.vue b/ui/uc-src/layouts/BasicLayout.vue index 4a27ebaff..38ce032a5 100644 --- a/ui/uc-src/layouts/BasicLayout.vue +++ b/ui/uc-src/layouts/BasicLayout.vue @@ -1,4 +1,5 @@