Refactor menu generation strategy to support sub-menu items. (#5177)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.12.x

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

重构 Console 和 UC 的菜单生成逻辑,支持配置二级菜单项。

<img width="557" alt="图片" src="https://github.com/halo-dev/halo/assets/21301288/0f1717ce-bd30-448b-9625-24bfd5e1c5ae">

配置方式:

```ts
export default definePlugin({
  components: {},
  routes: [
    {
      parentName: "AttachmentsRoot",
      route: {
        name: "S3Link",
        path: "s3-link",
        component: markRaw(HomeView),
        meta: {
          title: "S3 关联",
          searchable: true,
          menu: {
            name: "S3 关联",
            icon: markRaw(IconAddCircle),
            priority: 0,
            mobile: true,
          },
        },
      },
    },
  ],
});
```

只需要指定 parentName 并在其下 route 需要配置 meta.menu 即可。

最终文档会补充在:https://github.com/halo-dev/docs/pull/291

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

Fixes https://github.com/halo-dev/halo/issues/4807

#### Special notes for your reviewer:

1. 可以按照上述配置方式测试。
2. 可以安装 [plugin-s3-1.5.0-SNAPSHOT.jar.zip](https://github.com/halo-dev/halo/files/13959977/plugin-s3-1.5.0-SNAPSHOT.jar.zip) 进行测试。

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

```release-note
重构 Console 和 UC 的菜单生成逻辑,支持配置二级菜单项。
```
pull/5121/head^2
Ryan Wang 2024-01-19 13:34:10 +08:00 committed by GitHub
parent b42e046d54
commit 3ebb45c266
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 278 additions and 208 deletions

View File

@ -12,23 +12,24 @@ export default definePlugin({
routes: [
{
path: "/attachments",
name: "AttachmentsRoot",
component: BasicLayout,
meta: {
title: "core.attachment.title",
permissions: ["system:attachments:view"],
menu: {
name: "core.sidebar.menu.items.attachments",
group: "content",
icon: markRaw(IconFolder),
priority: 3,
mobile: true,
},
},
children: [
{
path: "",
name: "Attachments",
component: AttachmentList,
meta: {
title: "core.attachment.title",
permissions: ["system:attachments:view"],
menu: {
name: "core.sidebar.menu.items.attachments",
group: "content",
icon: markRaw(IconFolder),
priority: 3,
mobile: true,
},
},
},
],
},

View File

@ -12,24 +12,25 @@ export default definePlugin({
routes: [
{
path: "/comments",
name: "CommentsRoot",
component: BasicLayout,
meta: {
title: "core.comment.title",
searchable: true,
permissions: ["system:comments:view"],
menu: {
name: "core.sidebar.menu.items.comments",
group: "content",
icon: markRaw(IconMessage),
priority: 2,
mobile: true,
},
},
children: [
{
path: "",
name: "Comments",
component: CommentList,
meta: {
title: "core.comment.title",
searchable: true,
permissions: ["system:comments:view"],
menu: {
name: "core.sidebar.menu.items.comments",
group: "content",
icon: markRaw(IconMessage),
priority: 2,
mobile: true,
},
},
},
],
},

View File

@ -14,23 +14,24 @@ export default definePlugin({
routes: [
{
path: "/single-pages",
name: "SinglePagesRoot",
component: BasicLayout,
meta: {
title: "core.page.title",
searchable: true,
permissions: ["system:singlepages:view"],
menu: {
name: "core.sidebar.menu.items.single_pages",
group: "content",
icon: markRaw(IconPages),
priority: 1,
},
},
children: [
{
path: "",
name: "SinglePages",
component: SinglePageList,
meta: {
title: "core.page.title",
searchable: true,
permissions: ["system:singlepages:view"],
menu: {
name: "core.sidebar.menu.items.single_pages",
group: "content",
icon: markRaw(IconPages),
priority: 1,
},
},
},
{
path: "deleted",

View File

@ -19,24 +19,25 @@ export default definePlugin({
routes: [
{
path: "/posts",
name: "PostsRoot",
component: BasicLayout,
meta: {
title: "core.post.title",
searchable: true,
permissions: ["system:posts:view"],
menu: {
name: "core.sidebar.menu.items.posts",
group: "content",
icon: markRaw(IconBookRead),
priority: 0,
mobile: true,
},
},
children: [
{
path: "",
name: "Posts",
component: PostList,
meta: {
title: "core.post.title",
searchable: true,
permissions: ["system:posts:view"],
menu: {
name: "core.sidebar.menu.items.posts",
group: "content",
icon: markRaw(IconBookRead),
priority: 0,
mobile: true,
},
},
},
{
path: "deleted",

View File

@ -9,23 +9,24 @@ export default definePlugin({
routes: [
{
path: "/menus",
name: "MenusRoot",
component: BasicLayout,
meta: {
title: "core.menu.title",
searchable: true,
permissions: ["system:menus:view"],
menu: {
name: "core.sidebar.menu.items.menus",
group: "interface",
icon: markRaw(IconListSettings),
priority: 1,
},
},
children: [
{
path: "",
name: "Menus",
component: Menus,
meta: {
title: "core.menu.title",
searchable: true,
permissions: ["system:menus:view"],
menu: {
name: "core.sidebar.menu.items.menus",
group: "interface",
icon: markRaw(IconListSettings),
priority: 1,
},
},
},
],
},

View File

@ -10,23 +10,24 @@ export default definePlugin({
routes: [
{
path: "/theme",
name: "ThemeRoot",
component: ThemeLayout,
meta: {
title: "core.theme.title",
searchable: true,
permissions: ["system:themes:view"],
menu: {
name: "core.sidebar.menu.items.themes",
group: "interface",
icon: markRaw(IconPalette),
priority: 0,
},
},
children: [
{
path: "",
name: "ThemeDetail",
component: ThemeDetail,
meta: {
title: "core.theme.title",
searchable: true,
permissions: ["system:themes:view"],
menu: {
name: "core.sidebar.menu.items.themes",
group: "interface",
icon: markRaw(IconPalette),
priority: 0,
},
},
},
{
path: "settings/:group",

View File

@ -9,22 +9,24 @@ export default definePlugin({
routes: [
{
path: "/actuator",
name: "OverviewRoot", // fixme: actuator will be renamed to overview in the future
component: BasicLayout,
meta: {
title: "core.actuator.title",
searchable: true,
permissions: ["system:actuator:manage"],
menu: {
name: "core.sidebar.menu.items.actuator",
group: "system",
icon: markRaw(IconTerminalBoxLine),
priority: 3,
},
},
children: [
{
path: "",
name: "Actuator",
component: Actuator,
meta: {
title: "core.actuator.title",
searchable: true,
permissions: ["system:actuator:manage"],
menu: {
name: "core.sidebar.menu.items.actuator",
group: "system",
icon: markRaw(IconTerminalBoxLine),
priority: 3,
},
},
},
],
},

View File

@ -9,23 +9,24 @@ export default definePlugin({
routes: [
{
path: "/backup",
name: "BackupRoot",
component: BasicLayout,
meta: {
title: "core.backup.title",
searchable: true,
permissions: ["system:migrations:manage"],
menu: {
name: "core.sidebar.menu.items.backup",
group: "system",
icon: markRaw(IconServerLine),
priority: 4,
},
},
children: [
{
path: "",
name: "Backup",
component: Backups,
meta: {
title: "core.backup.title",
searchable: true,
permissions: ["system:migrations:manage"],
menu: {
name: "core.sidebar.menu.items.backup",
group: "system",
icon: markRaw(IconServerLine),
priority: 4,
},
},
},
],
},

View File

@ -1,6 +1,5 @@
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@console/layouts/BasicLayout.vue";
import BlankLayout from "@console/layouts/BlankLayout.vue";
import PluginList from "./PluginList.vue";
import PluginDetail from "./PluginDetail.vue";
import { IconPlug } from "@halo-dev/components";
@ -11,44 +10,33 @@ export default definePlugin({
routes: [
{
path: "/plugins",
component: BlankLayout,
name: "PluginsRoot",
component: BasicLayout,
meta: {
title: "core.plugin.title",
searchable: true,
permissions: ["system:plugins:view"],
menu: {
name: "core.sidebar.menu.items.plugins",
group: "system",
icon: markRaw(IconPlug),
priority: 0,
},
},
children: [
{
path: "",
component: BasicLayout,
children: [
{
path: "",
name: "Plugins",
component: PluginList,
meta: {
title: "core.plugin.title",
searchable: true,
permissions: ["system:plugins:view"],
menu: {
name: "core.sidebar.menu.items.plugins",
group: "system",
icon: markRaw(IconPlug),
priority: 0,
},
},
},
],
name: "Plugins",
component: PluginList,
},
{
path: ":name",
component: BasicLayout,
children: [
{
path: "",
name: "PluginDetail",
component: PluginDetail,
meta: {
title: "core.plugin.detail.title",
permissions: ["system:plugins:view"],
},
},
],
name: "PluginDetail",
component: PluginDetail,
meta: {
title: "core.plugin.detail.title",
permissions: ["system:plugins:view"],
},
},
],
},

View File

@ -9,22 +9,23 @@ export default definePlugin({
routes: [
{
path: "/settings",
name: "SettingsRoot",
component: BasicLayout,
meta: {
title: "core.setting.title",
permissions: ["system:settings:view"],
menu: {
name: "core.sidebar.menu.items.settings",
group: "system",
icon: markRaw(IconSettings),
priority: 2,
},
},
children: [
{
path: "",
name: "SystemSetting",
component: SystemSettings,
meta: {
title: "core.setting.title",
permissions: ["system:settings:view"],
menu: {
name: "core.sidebar.menu.items.settings",
group: "system",
icon: markRaw(IconSettings),
priority: 2,
},
},
},
],
},

View File

@ -33,24 +33,25 @@ export default definePlugin({
},
{
path: "/users",
name: "UsersRoot",
component: BasicLayout,
meta: {
title: "core.user.title",
searchable: true,
permissions: ["system:users:view"],
menu: {
name: "core.sidebar.menu.items.users",
group: "system",
icon: markRaw(IconUserSettings),
priority: 1,
mobile: true,
},
},
children: [
{
path: "",
name: "Users",
component: UserList,
meta: {
title: "core.user.title",
searchable: true,
permissions: ["system:users:view"],
menu: {
name: "core.sidebar.menu.items.users",
group: "system",
icon: markRaw(IconUserSettings),
priority: 1,
mobile: true,
},
},
},
{
path: ":name",

View File

@ -73,7 +73,14 @@ function registerModule(app: App, pluginModule: PluginModule, core: boolean) {
for (const route of pluginModule.routes) {
if ("parentName" in route) {
router.addRoute(route.parentName, route.route);
const parentRoute = router
.getRoutes()
.find((item) => item.name === route.parentName);
if (parentRoute) {
router.removeRoute(route.parentName);
parentRoute.children = [...parentRoute.children, route.route];
router.addRoute(parentRoute);
}
} else {
router.addRoute(route);
}

3
console/env.d.ts vendored
View File

@ -18,8 +18,11 @@ declare module "*.vue" {
}
declare module "vue-router" {
import type { Component } from "vue";
interface RouteMeta {
title?: string;
description?: string;
searchable?: boolean;
permissions?: string[];
core?: boolean;

View File

@ -6,6 +6,7 @@ import {
IconFolder,
IconMessage,
IconPages,
IconAddCircle,
} from "@/icons/icons";
const meta: Meta<typeof VMenu> = {
@ -22,6 +23,7 @@ const meta: Meta<typeof VMenu> = {
IconMessage,
IconFolder,
IconPages,
IconAddCircle,
},
setup() {
return {
@ -41,6 +43,11 @@ const meta: Meta<typeof VMenu> = {
<template #icon>
<IconBookRead />
</template>
<VMenuItem title="新文章">
<template #icon>
<IconBookRead />
</template>
</VMenuItem>
</VMenuItem>
<VMenuItem title="页面">
<template #icon>

View File

@ -36,7 +36,6 @@ const hasSubmenus = computed(() => {
function handleClick() {
if (hasSubmenus.value) {
open.value = !open.value;
return;
}
emit("select", props.id);
}
@ -83,7 +82,8 @@ function handleClick() {
flex
select-none
relative
p-2
px-2
py-[0.4rem]
font-normal
rounded-base;
@ -109,17 +109,21 @@ function handleClick() {
transform: rotate(90deg);
}
.submenus-show-enter-active {
transition: all 0.1s ease-out;
}
.submenus-show-enter-active,
.submenus-show-leave-active {
transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
transition: all 0.1s ease;
}
.submenus-show-enter-from,
.submenus-show-enter-to {
transform: translateY(-10px);
opacity: 0;
}
.sub-menu-items {
@apply pl-5 my-1;
.menu-item-title {
@apply p-1.5 text-sm;
}
}
</style>

View File

@ -88,7 +88,7 @@ describe("Menu", () => {
// has sub menu
if (item.props().id === "3") {
item.trigger("click");
expect(item.emitted().select).toBeUndefined();
expect(item.emitted().select).toBeDefined();
expect(item.vm.open).toBe(false);

View File

@ -31,7 +31,7 @@ const RoutesMenu = defineComponent({
function renderIcon(icon: Component | undefined) {
if (!icon) return undefined;
return <icon height="20px" width="20px" />;
return <icon />;
}
const { t } = useI18n();
@ -48,6 +48,8 @@ const RoutesMenu = defineComponent({
v-slots={{
icon: () => renderIcon(item.icon),
}}
onSelect={handleSelect}
active={openIds.value.includes(item.path)}
>
{renderItems(item.children)}
</VMenuItem>

View File

@ -25,29 +25,67 @@ export function useRouteMenuGenerator(
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
function flattenRoutes(route: RouteRecordNormalized | RouteRecordRaw) {
let routes: (RouteRecordNormalized | RouteRecordRaw)[] = [route];
if (route.children) {
route.children.forEach((child) => {
routes = routes.concat(flattenRoutes(child));
});
}
return routes;
}
function isRouteValid(route?: RouteRecordNormalized) {
if (!route) return false;
const { meta } = route;
if (!meta?.menu) return false;
return (
!meta.permissions || hasPermission(uiPermissions, meta.permissions, true)
);
}
const generateMenus = () => {
// sort by menu.priority and meta.core
const currentRoutes = sortBy<RouteRecordNormalized>(
router.getRoutes().filter((route) => {
const { meta } = route;
if (!meta?.menu) {
return false;
}
if (meta.permissions) {
return hasPermission(
uiPermissions,
meta.permissions as string[],
true
);
}
return true;
}),
// Filter and sort routes based on menu and permissions
let currentRoutes = sortBy<RouteRecordNormalized>(
router.getRoutes().filter((route) => isRouteValid(route)),
[
(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])
);
const flattenedAndValidChildren = route.children
.flatMap((child) => flattenRoutes(child))
.map((flattenedChild) => {
const validRoute = routesMap.get(flattenedChild.name);
if (validRoute && isRouteValid(validRoute)) {
return validRoute;
}
})
.filter(Boolean); // 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
menus.value = currentRoutes.reduce((acc, route) => {
const { menu } = route.meta;
@ -55,19 +93,20 @@ export function useRouteMenuGenerator(
return acc;
}
const group = acc.find((item) => item.id === menu.group);
const childRoute = route.children[0];
const childMetaMenu = childRoute?.meta?.menu;
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) as MenuItemType[];
// only support one level
const menuChildren = childMetaMenu
? [
{
name: childMetaMenu.name,
path: childRoute.path,
icon: childMetaMenu.icon,
},
]
: undefined;
if (group) {
group.items?.push({
name: menu.name,

View File

@ -9,24 +9,25 @@ export default definePlugin({
ucRoutes: [
{
path: "/posts",
name: "PostsRoot",
component: BasicLayout,
meta: {
title: "core.uc_post.title",
searchable: true,
permissions: ["uc:posts:manage"],
menu: {
name: "core.uc_sidebar.menu.items.posts",
group: "content",
icon: markRaw(IconBookRead),
priority: 0,
mobile: true,
},
},
children: [
{
path: "",
name: "Posts",
component: PostList,
meta: {
title: "core.uc_post.title",
searchable: true,
permissions: ["uc:posts:manage"],
menu: {
name: "core.uc_sidebar.menu.items.posts",
group: "content",
icon: markRaw(IconBookRead),
priority: 0,
mobile: true,
},
},
},
{
path: "editor",

View File

@ -8,23 +8,24 @@ export default definePlugin({
ucRoutes: [
{
path: "/notifications",
name: "NotificationsRoot",
component: BasicLayout,
meta: {
title: "core.uc_notification.title",
searchable: true,
menu: {
name: "core.uc_sidebar.menu.items.notification",
group: "dashboard",
icon: markRaw(IconNotificationBadgeLine),
priority: 1,
mobile: true,
},
},
children: [
{
path: "",
name: "Notifications",
component: Notifications,
meta: {
title: "core.uc_notification.title",
searchable: true,
menu: {
name: "core.uc_sidebar.menu.items.notification",
group: "dashboard",
icon: markRaw(IconNotificationBadgeLine),
priority: 1,
mobile: true,
},
},
},
],
},

View File

@ -73,7 +73,14 @@ function registerModule(app: App, pluginModule: PluginModule, core: boolean) {
for (const route of pluginModule.ucRoutes) {
if ("parentName" in route) {
router.addRoute(route.parentName, route.route);
const parentRoute = router
.getRoutes()
.find((item) => item.name === route.parentName);
if (parentRoute) {
router.removeRoute(route.parentName);
parentRoute.children = [...parentRoute.children, route.route];
router.addRoute(parentRoute);
}
} else {
router.addRoute(route);
}