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 @@