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>
|
<script lang="ts" setup>
|
||||||
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
|
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
|
||||||
|
import MenuLoading from "@/components/menu/MenuLoading.vue";
|
||||||
import { RoutesMenu } from "@/components/menu/RoutesMenu";
|
import { RoutesMenu } from "@/components/menu/RoutesMenu";
|
||||||
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
|
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
|
||||||
import MobileMenu from "@/layouts/MobileMenu.vue";
|
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
|
// aside scroll
|
||||||
const navbarScroller = ref();
|
const navbarScroller = ref();
|
||||||
|
@ -95,7 +96,8 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<RoutesMenu :menus="menus" />
|
<MenuLoading v-if="isLoading" />
|
||||||
|
<RoutesMenu v-else :menus="data?.menus || []" />
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar__profile">
|
<div class="sidebar__profile">
|
||||||
<UserProfileBanner platform="console" />
|
<UserProfileBanner platform="console" />
|
||||||
|
@ -112,7 +114,11 @@ onMounted(() => {
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
<MobileMenu :menus="menus" :minimenus="minimenus" platform="console" />
|
<MobileMenu
|
||||||
|
:menus="data?.menus || []"
|
||||||
|
:minimenus="data?.minimenus || []"
|
||||||
|
platform="console"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<GlobalSearchModal
|
<GlobalSearchModal
|
||||||
v-if="globalSearchVisible"
|
v-if="globalSearchVisible"
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
VEntityField,
|
VEntityField,
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import { computed } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import type { RouteRecordRaw } from "vue-router";
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
@ -19,24 +19,55 @@ const router = useRouter();
|
||||||
const roleStore = useRoleStore();
|
const roleStore = useRoleStore();
|
||||||
|
|
||||||
const { uiPermissions } = roleStore.permissions;
|
const { uiPermissions } = roleStore.permissions;
|
||||||
|
const routes = ref<RouteRecordRaw[]>([]);
|
||||||
|
|
||||||
function isRouteValid(route?: RouteRecordRaw) {
|
async function isRouteValid(route?: RouteRecordRaw) {
|
||||||
if (!route) return false;
|
if (!route) return false;
|
||||||
const { meta } = route;
|
const { meta } = route;
|
||||||
if (!meta?.menu) return false;
|
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 matchedRoute = router.currentRoute.value.matched[0];
|
||||||
|
const childRoutes =
|
||||||
return router
|
router
|
||||||
.getRoutes()
|
.getRoutes()
|
||||||
.find((route) => route.name === matchedRoute.name)
|
.find((route) => route.name === matchedRoute.name)
|
||||||
?.children.filter((route) => route.path !== "")
|
?.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>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,9 @@ declare module "vue-router" {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
permissions?: string[];
|
permissions?:
|
||||||
|
| string[]
|
||||||
|
| ((uiPermissions: string[]) => boolean | Promise<boolean>);
|
||||||
core?: boolean;
|
core?: boolean;
|
||||||
hideFooter?: boolean;
|
hideFooter?: boolean;
|
||||||
menu?: {
|
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 { useRoleStore } from "@/stores/role";
|
||||||
import { hasPermission } from "@/utils/permission";
|
import { hasPermission } from "@/utils/permission";
|
||||||
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
|
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { sortBy } from "lodash-es";
|
import { sortBy } from "lodash-es";
|
||||||
import { onMounted, ref, type Ref } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import {
|
import {
|
||||||
useRouter,
|
useRouter,
|
||||||
type RouteRecordNormalized,
|
type RouteRecordNormalized,
|
||||||
type RouteRecordRaw,
|
type RouteRecordRaw,
|
||||||
} from "vue-router";
|
} from "vue-router";
|
||||||
|
|
||||||
interface useRouteMenuGeneratorReturn {
|
export function useRouteMenuGenerator(menuGroups: MenuGroupType[]) {
|
||||||
menus: Ref<MenuGroupType[]>;
|
|
||||||
minimenus: Ref<MenuItemType[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRouteMenuGenerator(
|
|
||||||
menuGroups: MenuGroupType[]
|
|
||||||
): useRouteMenuGeneratorReturn {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const menus = ref<MenuGroupType[]>([] as MenuGroupType[]);
|
|
||||||
const minimenus = ref<MenuItemType[]>([] as MenuItemType[]);
|
|
||||||
|
|
||||||
const roleStore = useRoleStore();
|
const roleStore = useRoleStore();
|
||||||
|
|
||||||
const { uiPermissions } = roleStore.permissions;
|
const { uiPermissions } = roleStore.permissions;
|
||||||
|
|
||||||
function flattenRoutes(route: RouteRecordNormalized | RouteRecordRaw) {
|
function flattenRoutes(route: RouteRecordNormalized | RouteRecordRaw) {
|
||||||
|
@ -35,41 +27,74 @@ export function useRouteMenuGenerator(
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRouteValid(route?: RouteRecordNormalized) {
|
async function isRouteValid(route?: RouteRecordNormalized) {
|
||||||
if (!route) return false;
|
if (!route) return false;
|
||||||
const { meta } = route;
|
const { meta } = route;
|
||||||
if (!meta?.menu) return false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateMenus = () => {
|
// Default behavior for array of permissions
|
||||||
// Filter and sort routes based on menu and permissions
|
return hasPermission(uiPermissions, meta.permissions as string[], true);
|
||||||
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?.core,
|
||||||
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
|
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
|
||||||
]
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
// Flatten and filter child routes
|
// Flatten and filter child routes
|
||||||
currentRoutes.forEach((route) => {
|
for (const route of currentRoutes) {
|
||||||
if (route.children.length) {
|
if (route.children.length) {
|
||||||
const routesMap = new Map(
|
const routesMap = new Map(
|
||||||
currentRoutes.map((route) => [route.name, route])
|
currentRoutes.map((route) => [route.name, route])
|
||||||
);
|
);
|
||||||
|
|
||||||
const flattenedAndValidChildren = route.children
|
const childRoutesPromises = route.children
|
||||||
.flatMap((child) => flattenRoutes(child))
|
.flatMap((child) => flattenRoutes(child))
|
||||||
.map((flattenedChild) => {
|
.map(async (flattenedChild) => {
|
||||||
const validRoute = routesMap.get(flattenedChild.name);
|
const validRoute = routesMap.get(flattenedChild.name);
|
||||||
if (validRoute && isRouteValid(validRoute)) {
|
if (validRoute && (await isRouteValid(validRoute))) {
|
||||||
return validRoute;
|
return validRoute;
|
||||||
}
|
}
|
||||||
})
|
return null;
|
||||||
.filter(Boolean); // filters out falsy values
|
});
|
||||||
|
|
||||||
|
// 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
|
// Sorting the routes
|
||||||
// @ts-ignore children must be RouteRecordRaw[], but it is RouteRecordNormalized[]
|
// @ts-ignore children must be RouteRecordRaw[], but it is RouteRecordNormalized[]
|
||||||
|
@ -78,16 +103,17 @@ export function useRouteMenuGenerator(
|
||||||
(route) => route?.meta?.menu?.priority || 0,
|
(route) => route?.meta?.menu?.priority || 0,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Remove duplicate routes
|
// Remove duplicate routes
|
||||||
const allChildren = currentRoutes.flatMap((route) => route.children);
|
const allChildren = currentRoutes.flatMap((route) => route.children);
|
||||||
|
|
||||||
currentRoutes = currentRoutes.filter(
|
currentRoutes = currentRoutes.filter(
|
||||||
(route) => !allChildren.find((child) => child.name === route.name)
|
(route) => !allChildren.find((child) => child.name === route.name)
|
||||||
);
|
);
|
||||||
|
|
||||||
// group by menu.group
|
// group by menu.group
|
||||||
menus.value = currentRoutes.reduce((acc, route) => {
|
const groupedMenus = currentRoutes.reduce((acc, route) => {
|
||||||
const { menu } = route.meta;
|
const { menu } = route.meta;
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
return acc;
|
return acc;
|
||||||
|
@ -142,14 +168,14 @@ export function useRouteMenuGenerator(
|
||||||
}, [] as MenuGroupType[]);
|
}, [] as MenuGroupType[]);
|
||||||
|
|
||||||
// sort by menu.priority
|
// sort by menu.priority
|
||||||
menus.value = sortBy(menus.value, [
|
const menus = sortBy(groupedMenus, [
|
||||||
(menu: MenuGroupType) => {
|
(menu: MenuGroupType) => {
|
||||||
return menuGroups.findIndex((item) => item.id === menu.id) < 0;
|
return menuGroups.findIndex((item) => item.id === menu.id) < 0;
|
||||||
},
|
},
|
||||||
(menu: MenuGroupType) => menu.priority || 0,
|
(menu: MenuGroupType) => menu.priority || 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
minimenus.value = menus.value
|
const minimenus = menus
|
||||||
.reduce((acc, group) => {
|
.reduce((acc, group) => {
|
||||||
if (group?.items) {
|
if (group?.items) {
|
||||||
acc.push(...group.items);
|
acc.push(...group.items);
|
||||||
|
@ -157,12 +183,37 @@ export function useRouteMenuGenerator(
|
||||||
return acc;
|
return acc;
|
||||||
}, [] as MenuItemType[])
|
}, [] as MenuItemType[])
|
||||||
.filter((item) => item.mobile);
|
.filter((item) => item.mobile);
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(generateMenus);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
menus,
|
menus,
|
||||||
minimenus,
|
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 {
|
||||||
|
clearTimeout(delayLoadingTimer);
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import MenuLoading from "@/components/menu/MenuLoading.vue";
|
||||||
import { RoutesMenu } from "@/components/menu/RoutesMenu";
|
import { RoutesMenu } from "@/components/menu/RoutesMenu";
|
||||||
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
|
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
|
||||||
import MobileMenu from "@/layouts/MobileMenu.vue";
|
import MobileMenu from "@/layouts/MobileMenu.vue";
|
||||||
|
@ -15,7 +16,7 @@ import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const { menus, minimenus } = useRouteMenuGenerator(coreMenuGroups);
|
const { data, isLoading } = useRouteMenuGenerator(coreMenuGroups);
|
||||||
|
|
||||||
// aside scroll
|
// aside scroll
|
||||||
const navbarScroller = ref();
|
const navbarScroller = ref();
|
||||||
|
@ -68,7 +69,8 @@ onMounted(() => {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div ref="navbarScroller" class="sidebar__content">
|
<div ref="navbarScroller" class="sidebar__content">
|
||||||
<RoutesMenu :menus="menus" />
|
<MenuLoading v-if="isLoading" />
|
||||||
|
<RoutesMenu :menus="data?.menus || []" />
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar__profile">
|
<div class="sidebar__profile">
|
||||||
<UserProfileBanner platform="uc" />
|
<UserProfileBanner platform="uc" />
|
||||||
|
@ -89,7 +91,11 @@ onMounted(() => {
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
<MobileMenu :menus="menus" :minimenus="minimenus" platform="uc" />
|
<MobileMenu
|
||||||
|
:menus="data?.menus || []"
|
||||||
|
:minimenus="data?.minimenus || []"
|
||||||
|
platform="uc"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue