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,12 +12,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/attachments", path: "/attachments",
name: "AttachmentsRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Attachments",
component: AttachmentList,
meta: { meta: {
title: "core.attachment.title", title: "core.attachment.title",
permissions: ["system:attachments:view"], permissions: ["system:attachments:view"],
@ -29,6 +25,11 @@ export default definePlugin({
mobile: true, mobile: true,
}, },
}, },
children: [
{
path: "",
name: "Attachments",
component: AttachmentList,
}, },
], ],
}, },

View File

@ -12,12 +12,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/comments", path: "/comments",
name: "CommentsRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Comments",
component: CommentList,
meta: { meta: {
title: "core.comment.title", title: "core.comment.title",
searchable: true, searchable: true,
@ -30,6 +26,11 @@ export default definePlugin({
mobile: true, mobile: true,
}, },
}, },
children: [
{
path: "",
name: "Comments",
component: CommentList,
}, },
], ],
}, },

View File

@ -14,12 +14,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/single-pages", path: "/single-pages",
name: "SinglePagesRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "SinglePages",
component: SinglePageList,
meta: { meta: {
title: "core.page.title", title: "core.page.title",
searchable: true, searchable: true,
@ -31,6 +27,11 @@ export default definePlugin({
priority: 1, priority: 1,
}, },
}, },
children: [
{
path: "",
name: "SinglePages",
component: SinglePageList,
}, },
{ {
path: "deleted", path: "deleted",

View File

@ -19,12 +19,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/posts", path: "/posts",
name: "PostsRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Posts",
component: PostList,
meta: { meta: {
title: "core.post.title", title: "core.post.title",
searchable: true, searchable: true,
@ -37,6 +33,11 @@ export default definePlugin({
mobile: true, mobile: true,
}, },
}, },
children: [
{
path: "",
name: "Posts",
component: PostList,
}, },
{ {
path: "deleted", path: "deleted",

View File

@ -9,12 +9,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/menus", path: "/menus",
name: "MenusRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Menus",
component: Menus,
meta: { meta: {
title: "core.menu.title", title: "core.menu.title",
searchable: true, searchable: true,
@ -26,6 +22,11 @@ export default definePlugin({
priority: 1, priority: 1,
}, },
}, },
children: [
{
path: "",
name: "Menus",
component: Menus,
}, },
], ],
}, },

View File

@ -10,12 +10,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/theme", path: "/theme",
name: "ThemeRoot",
component: ThemeLayout, component: ThemeLayout,
children: [
{
path: "",
name: "ThemeDetail",
component: ThemeDetail,
meta: { meta: {
title: "core.theme.title", title: "core.theme.title",
searchable: true, searchable: true,
@ -27,6 +23,11 @@ export default definePlugin({
priority: 0, priority: 0,
}, },
}, },
children: [
{
path: "",
name: "ThemeDetail",
component: ThemeDetail,
}, },
{ {
path: "settings/:group", path: "settings/:group",

View File

@ -9,11 +9,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/actuator", path: "/actuator",
name: "OverviewRoot", // fixme: actuator will be renamed to overview in the future
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
component: Actuator,
meta: { meta: {
title: "core.actuator.title", title: "core.actuator.title",
searchable: true, searchable: true,
@ -25,6 +22,11 @@ export default definePlugin({
priority: 3, priority: 3,
}, },
}, },
children: [
{
path: "",
name: "Actuator",
component: Actuator,
}, },
], ],
}, },

View File

@ -9,12 +9,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/backup", path: "/backup",
name: "BackupRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Backup",
component: Backups,
meta: { meta: {
title: "core.backup.title", title: "core.backup.title",
searchable: true, searchable: true,
@ -26,6 +22,11 @@ export default definePlugin({
priority: 4, priority: 4,
}, },
}, },
children: [
{
path: "",
name: "Backup",
component: Backups,
}, },
], ],
}, },

View File

@ -1,6 +1,5 @@
import { definePlugin } from "@halo-dev/console-shared"; import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@console/layouts/BasicLayout.vue"; import BasicLayout from "@console/layouts/BasicLayout.vue";
import BlankLayout from "@console/layouts/BlankLayout.vue";
import PluginList from "./PluginList.vue"; import PluginList from "./PluginList.vue";
import PluginDetail from "./PluginDetail.vue"; import PluginDetail from "./PluginDetail.vue";
import { IconPlug } from "@halo-dev/components"; import { IconPlug } from "@halo-dev/components";
@ -11,16 +10,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/plugins", path: "/plugins",
component: BlankLayout, name: "PluginsRoot",
children: [
{
path: "",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Plugins",
component: PluginList,
meta: { meta: {
title: "core.plugin.title", title: "core.plugin.title",
searchable: true, searchable: true,
@ -32,15 +23,14 @@ export default definePlugin({
priority: 0, priority: 0,
}, },
}, },
},
],
},
{
path: ":name",
component: BasicLayout,
children: [ children: [
{ {
path: "", path: "",
name: "Plugins",
component: PluginList,
},
{
path: ":name",
name: "PluginDetail", name: "PluginDetail",
component: PluginDetail, component: PluginDetail,
meta: { meta: {
@ -51,6 +41,4 @@ export default definePlugin({
], ],
}, },
], ],
},
],
}); });

View File

@ -9,12 +9,8 @@ export default definePlugin({
routes: [ routes: [
{ {
path: "/settings", path: "/settings",
name: "SettingsRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "SystemSetting",
component: SystemSettings,
meta: { meta: {
title: "core.setting.title", title: "core.setting.title",
permissions: ["system:settings:view"], permissions: ["system:settings:view"],
@ -25,6 +21,11 @@ export default definePlugin({
priority: 2, priority: 2,
}, },
}, },
children: [
{
path: "",
name: "SystemSetting",
component: SystemSettings,
}, },
], ],
}, },

View File

@ -33,12 +33,8 @@ export default definePlugin({
}, },
{ {
path: "/users", path: "/users",
name: "UsersRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Users",
component: UserList,
meta: { meta: {
title: "core.user.title", title: "core.user.title",
searchable: true, searchable: true,
@ -51,6 +47,11 @@ export default definePlugin({
mobile: true, mobile: true,
}, },
}, },
children: [
{
path: "",
name: "Users",
component: UserList,
}, },
{ {
path: ":name", path: ":name",

View File

@ -73,7 +73,14 @@ function registerModule(app: App, pluginModule: PluginModule, core: boolean) {
for (const route of pluginModule.routes) { for (const route of pluginModule.routes) {
if ("parentName" in route) { 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 { } else {
router.addRoute(route); router.addRoute(route);
} }

3
console/env.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,12 +9,8 @@ export default definePlugin({
ucRoutes: [ ucRoutes: [
{ {
path: "/posts", path: "/posts",
name: "PostsRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Posts",
component: PostList,
meta: { meta: {
title: "core.uc_post.title", title: "core.uc_post.title",
searchable: true, searchable: true,
@ -27,6 +23,11 @@ export default definePlugin({
mobile: true, mobile: true,
}, },
}, },
children: [
{
path: "",
name: "Posts",
component: PostList,
}, },
{ {
path: "editor", path: "editor",

View File

@ -8,12 +8,8 @@ export default definePlugin({
ucRoutes: [ ucRoutes: [
{ {
path: "/notifications", path: "/notifications",
name: "NotificationsRoot",
component: BasicLayout, component: BasicLayout,
children: [
{
path: "",
name: "Notifications",
component: Notifications,
meta: { meta: {
title: "core.uc_notification.title", title: "core.uc_notification.title",
searchable: true, searchable: true,
@ -25,6 +21,11 @@ export default definePlugin({
mobile: true, mobile: true,
}, },
}, },
children: [
{
path: "",
name: "Notifications",
component: Notifications,
}, },
], ],
}, },

View File

@ -73,7 +73,14 @@ function registerModule(app: App, pluginModule: PluginModule, core: boolean) {
for (const route of pluginModule.ucRoutes) { for (const route of pluginModule.ucRoutes) {
if ("parentName" in route) { 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 { } else {
router.addRoute(route); router.addRoute(route);
} }