refactor: router and menu generation (#651)

#### What type of PR is this?

/kind api-change
/kind improvement
/milestone 2.0

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

Ref https://github.com/halo-dev/halo/issues/2595

重构路由和侧边菜单生成的逻辑,**注意,此 PR 对插件的 Console 入口文件中的路由和菜单定义包含破坏性更新。**

1. 移除 `definePlugin` 方法的 `menus` 字段,改为在 route 的 meta 中定义。
2. 将 `RoutesMenu` 组件从 `@halo-dev/components` 包中移出。
3. 将 `BasicLayout` 组件从 `@halo-dev/console-shared` 包中移出。

定义路由的方式:

```ts
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import AttachmentList from "./AttachmentList.vue";
import AttachmentSelectorModal from "./components/AttachmentSelectorModal.vue";
import { IconFolder } from "@halo-dev/components";
import { markRaw } from "vue";

export default definePlugin({
  name: "attachmentModule",
  components: [AttachmentSelectorModal],
  routes: [
    {
      path: "/attachments",
      component: BasicLayout,
      children: [
        {
          path: "",
          name: "Attachments",
          component: AttachmentList,
          meta: {
            title: "附件",
            permissions: ["system:attachments:view"],
            menu: {
              name: "附件",
              group: "内容",
              icon: markRaw(IconFolder),
              priority: 4,
              mobile: true,
            },
          },
        },
      ],
    },
  ],
});
```

menu 字段类型:

```ts
interface RouteMeta {
  title?: string;
  searchable?: boolean;
  permissions?: string[];
  menu?: {
    name: string;
    group?: string;
    icon?: Component;
    priority: number;
    mobile?: true;
  };
}
```

插件适配需要做的改动:

1. 移除 `definePlugin` 中的 menus 字段。
2. 在需要添加到菜单的 route 中提供 `meta.menu` 对象,可参考上方的 menu 字段类型。

详细文档可查阅:https://github.com/ruibaby/halo-console/tree/refactor/route-map-setting/docs/routes-generation

todolist:

- [x] 完善预设的菜单分组定义。
- [x] 绑定权限,根据权限决定是否需要将路由添加到菜单。
- [x] 优化菜单排序的定义方式。

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

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

#### Special notes for your reviewer:

/cc @halo-dev/sig-halo-console 

测试方式:

1. 需要 `pnpm build:packages`
2. 测试后台的菜单及路由是否有异常。
3. 新建角色测试路由和菜单对权限的绑定。
4. 按照 https://github.com/ruibaby/halo-console/tree/refactor/route-map-setting/docs/routes-generation 文档,创建插件,测试插件添加路由和菜单是否正常。

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

```release-note
重构路由和侧边菜单生成的逻辑。
```
pull/652/head
Ryan Wang 2022-10-19 16:54:13 +08:00 committed by GitHub
parent dd17087b8c
commit 54755c5842
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 552 additions and 380 deletions

View File

@ -0,0 +1,126 @@
# 路由和 Console 端菜单的生成
## 简述
目前的路由以及菜单都是动态生成的,由 `基础路由`、`核心模块路由`、`插件模块路由` 三部分组成。
定义文件位置:
- 基础路由:`src/router/routes.config.ts`,
- 核心模块路由:`src/modules/**/module.ts`,
## 定义方式
统一由 `@halo-dev/console-shared` 包中的 `definePlugin` 方法配置。如:
```ts
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import AttachmentList from "./AttachmentList.vue";
import AttachmentSelectorModal from "./components/AttachmentSelectorModal.vue";
import { IconFolder } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "attachmentModule",
components: [AttachmentSelectorModal],
routes: [
{
path: "/attachments",
component: BasicLayout,
children: [
{
path: "",
name: "Attachments",
component: AttachmentList,
meta: {
title: "附件",
permissions: ["system:attachments:view"],
menu: {
name: "附件",
group: "content",
icon: markRaw(IconFolder),
priority: 3,
mobile: true,
},
},
},
],
},
],
});
```
其中,如果要将路由添加到侧边的菜单,那么需要在 `meta` 中定义好 `menu` 对象menu 对象类型详解如下:
```ts
interface RouteMeta {
title?: string;
searchable?: boolean;
permissions?: string[];
core?: boolean;
menu?: {
name: string; // 菜单名称
group?: CoreMenuGroupId; // 菜单分组 ID详见下方 CoreMenuGroupId 定义
icon?: Component; // 菜单图标,类型为 Vue 组件,可以使用 `@halo-dev/components` 包中的图标组件,或者自行接入 https://github.com/antfu/unplugin-icons
priority: number; // 排序字段,相对于 group插件中提供的菜单将始终放在最后
mobile?: boolean; // 是否添加到移动端底部的菜单
};
}
```
CoreMenuGroupId
```ts
declare type CoreMenuGroupId = "dashboard" | "content" | "interface" | "system" | "tool";
```
这是核心内置的菜单分组,但如果插件需要自定义分组,可以直接填写分组名,如:
```ts
{
name: "帖子",
group: "社区",
icon: markRaw(IconCummunity),
priority: 1,
mobile: false,
}
```
## 插件接入
定义方式与系统核心模块的定义方式一致,在 `definePlugin` 方法配置即可。主要额外注意的是,如果插件的路由需要基础布局(继承 BasicLayout需要配置 `parentName`,如:
```ts
export default definePlugin({
routes: [
{
parentName: "Root",
route: {
path: "/migrate",
children: [
{
path: "",
name: "Migrate",
component: MigrateView,
meta: {
title: "迁移",
searchable: true,
menu: {
name: "迁移",
group: "tool",
icon: markRaw(IconGrid),
priority: 0,
},
},
},
],
},
},
]
})
```
## 权限
`meta` 中配置 `permissions` 即可。类型为 UI 权限标识的数组,如 `["system:attachments:view"]`。如果当前用户没有对应权限,那么将不会注册路由和菜单。

22
env.d.ts vendored
View File

@ -1,8 +1,30 @@
/// <reference types="vite/client" />
export {};
import type { CoreMenuGroupId } from "@halo-dev/console-shared";
import "vue-router";
declare module "*.vue" {
import type { DefineComponent } from "vue";
// eslint-disable-next-line
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module "vue-router" {
interface RouteMeta {
title?: string;
searchable?: boolean;
permissions?: string[];
core?: boolean;
menu?: {
name: string;
group?: CoreMenuGroupId;
icon?: Component;
priority: number;
mobile?: boolean;
};
}
}

View File

@ -52,6 +52,7 @@
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.merge": "^4.6.2",
"lodash.sortby": "^4.7.0",
"path-browserify": "^1.0.1",
"pinia": "^2.0.23",
"pretty-bytes": "^6.0.0",

View File

@ -1,8 +0,0 @@
import { describe, expect, it } from "vitest";
import { VRoutesMenu } from "../RoutesMenu";
describe("RoutesMenu", () => {
it("should render", () => {
expect(VRoutesMenu).toBeDefined();
});
});

View File

@ -1,5 +1,3 @@
export { default as VMenu } from "./Menu.vue";
export { default as VMenuItem } from "./MenuItem.vue";
export { default as VMenuLabel } from "./MenuLabel.vue";
// @ts-ignore
export { VRoutesMenu } from "./RoutesMenu.tsx";

View File

@ -1,14 +0,0 @@
import type { Component } from "vue";
export interface MenuGroupType {
name?: string;
items: MenuItemType[];
}
export interface MenuItemType {
name: string;
path: string;
icon?: Component;
meta?: Record<string, unknown>;
children?: MenuItemType[];
}

View File

@ -1 +1,29 @@
/// <reference types="vite/client" />
export {};
import type { CoreMenuGroupId } from "./src/types/menus";
declare module "*.vue" {
import type { DefineComponent } from "vue";
// eslint-disable-next-line
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module "vue-router" {
interface RouteMeta {
title?: string;
searchable?: boolean;
permissions?: string[];
core?: boolean;
menu?: {
name: string;
group?: CoreMenuGroupId;
icon?: Component;
priority: number;
mobile?: boolean;
};
}
}

View File

@ -37,9 +37,6 @@
},
"homepage": "https://github.com/halo-dev/console/tree/main/packages/shared#readme",
"license": "MIT",
"dependencies": {
"@halo-dev/components": "workspace:*"
},
"devDependencies": {
"vite-plugin-dts": "^1.6.5"
},

View File

@ -1,3 +0,0 @@
module.exports = {
...require("../../postcss.config"),
};

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -3,4 +3,3 @@ export * from "./types/menus";
export * from "./core/plugins";
export * from "./states/pages";
export * from "./states/attachment-selector";
export * from "./layouts";

View File

@ -1,7 +0,0 @@
import { describe, expect, it } from "vitest";
describe("BasicLayout", () => {
it("renders", () => {
expect(true).toBe(true);
});
});

View File

@ -1,2 +0,0 @@
export { default as BlankLayout } from "./BlankLayout.vue";
export { default as BasicLayout } from "./BasicLayout.vue";

View File

@ -1,13 +1,23 @@
import type { Component } from "vue";
export type CoreMenuGroupId =
| "dashboard"
| "content"
| "interface"
| "system"
| "tool";
export interface MenuGroupType {
id: CoreMenuGroupId | string;
name?: string;
items: MenuItemType[];
priority: number;
items?: MenuItemType[];
}
export interface MenuItemType {
name: string;
path: string;
mobile?: boolean;
icon?: Component;
meta?: Record<string, unknown>;
children?: MenuItemType[];

View File

@ -1,6 +1,5 @@
import type { Component, Ref } from "vue";
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
import type { MenuGroupType } from "./menus";
import type { PagesPublicState } from "../states/pages";
import type { AttachmentSelectorPublicState } from "../states/attachment-selector";
@ -10,7 +9,7 @@ export type ExtensionPointState =
| PagesPublicState
| AttachmentSelectorPublicState;
interface RouteRecordAppend {
export interface RouteRecordAppend {
parentName: RouteRecordName;
route: RouteRecordRaw;
}
@ -35,8 +34,6 @@ export interface Plugin {
routes?: RouteRecordRaw[] | RouteRecordAppend[];
menus?: MenuGroupType[];
extensionPoints?: {
[key in ExtensionPointName]?: (state: Ref<ExtensionPointState>) => void;
};

View File

@ -1,3 +0,0 @@
module.exports = {
...require("../../tailwind.config"),
};

View File

@ -30,12 +30,11 @@ export default defineConfig({
fileName: (format) => `halo-console-shared.${format}.js`,
},
rollupOptions: {
external: ["vue", "vue-router", "@halo-dev/components"],
external: ["vue", "vue-router"],
output: {
globals: {
vue: "Vue",
"vue-router": "VueRouter",
"@halo-dev/components": "HaloComponents",
},
exports: "named",
generatedCode: "es5",

View File

@ -60,6 +60,7 @@ importers:
lodash.clonedeep: ^4.5.0
lodash.isequal: ^4.5.0
lodash.merge: ^4.6.2
lodash.sortby: ^4.7.0
path-browserify: ^1.0.1
pinia: ^2.0.23
postcss: ^8.4.17
@ -119,6 +120,7 @@ importers:
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
lodash.merge: 4.6.2
lodash.sortby: 4.7.0
path-browserify: 1.0.1
pinia: 2.0.23_rg374xhldfcyvjtaj3qktyfz5y
pretty-bytes: 6.0.0
@ -166,7 +168,7 @@ importers:
randomstring: 1.2.2
sass: 1.55.0
start-server-and-test: 1.14.0
tailwindcss: 3.1.8_postcss@8.4.17
tailwindcss: 3.1.8
tailwindcss-safe-area: 0.2.2
tailwindcss-themer: 2.0.2_tailwindcss@3.1.8
typescript: 4.7.4
@ -210,10 +212,7 @@ importers:
packages/shared:
specifiers:
'@halo-dev/components': workspace:*
vite-plugin-dts: ^1.6.5
dependencies:
'@halo-dev/components': link:../components
devDependencies:
vite-plugin-dts: 1.6.5
@ -1862,7 +1861,7 @@ packages:
optional: true
dependencies:
'@formkit/core': 1.0.0-beta.11
tailwindcss: 3.1.8_postcss@8.4.17
tailwindcss: 3.1.8
dev: false
/@formkit/utils/1.0.0-beta.11:
@ -2563,7 +2562,7 @@ packages:
peerDependencies:
tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
dependencies:
tailwindcss: 3.1.8_postcss@8.4.17
tailwindcss: 3.1.8
dev: true
/@tiptap/core/2.0.0-beta.195:
@ -6552,7 +6551,6 @@ packages:
/lodash.sortby/4.7.0:
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
dev: true
/lodash.startcase/4.4.0:
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
@ -8180,15 +8178,13 @@ packages:
just-unique: 4.1.1
lodash.merge: 4.6.2
lodash.mergewith: 4.6.2
tailwindcss: 3.1.8_postcss@8.4.17
tailwindcss: 3.1.8
dev: true
/tailwindcss/3.1.8_postcss@8.4.17:
/tailwindcss/3.1.8:
resolution: {integrity: sha512-YSneUCZSFDYMwk+TGq8qYFdCA3yfBRdBlS7txSq0LUmzyeqRe3a8fBQzbz9M3WS/iFT4BNf/nmw9mEzrnSaC0g==}
engines: {node: '>=12.13.0'}
hasBin: true
peerDependencies:
postcss: ^8.0.9
dependencies:
arg: 5.0.2
chokidar: 3.5.3

View File

@ -1,8 +1,7 @@
<script lang="ts" setup>
import { RouterView, useRoute } from "vue-router";
import { onMounted, provide, ref, watch, type Ref } from "vue";
import { watch } from "vue";
import { useTitle } from "@vueuse/core";
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
const AppName = "Halo";
const route = useRoute();
@ -19,29 +18,10 @@ watch(
title.value = AppName;
}
);
const globalSearchVisible = ref(false);
provide<Ref<boolean>>("globalSearchVisible", globalSearchVisible);
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
const handleKeybinding = (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = e;
if (key === "k" && ((ctrlKey && !isMac) || metaKey)) {
globalSearchVisible.value = true;
e.preventDefault();
}
};
onMounted(() => {
document.addEventListener("keydown", handleKeybinding);
});
</script>
<template>
<RouterView />
<GlobalSearchModal v-model:visible="globalSearchVisible" />
</template>
<style lang="scss">

View File

@ -12,7 +12,7 @@ import {
IconPages,
IconUserSettings,
} from "@halo-dev/components";
import { computed, markRaw, ref, watch, type Component } from "vue";
import { computed, markRaw, onMounted, ref, watch, type Component } from "vue";
import Fuse from "fuse.js";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
@ -356,8 +356,6 @@ watch(
() => props.visible,
(visible) => {
if (visible) {
handleBuildSearchIndex();
setTimeout(() => {
globalSearchInput.value?.focus();
}, 100);
@ -371,6 +369,10 @@ watch(
}
);
onMounted(() => {
handleBuildSearchIndex();
});
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
};
@ -381,7 +383,6 @@ const onVisibleChange = (visible: boolean) => {
:visible="visible"
:body-class="['!p-0']"
:mount-to-body="true"
class="items-start"
:width="650"
:centered="false"
@update:visible="onVisibleChange"

View File

@ -1,12 +1,12 @@
import type { Component, PropType } from "vue";
import { computed, defineComponent } from "vue";
import type { MenuGroupType, MenuItemType } from "./interface";
import { VMenu, VMenuItem, VMenuLabel } from "./index";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import { VMenu, VMenuItem, VMenuLabel } from "@halo-dev/components";
import type { RouteLocationMatched } from "vue-router";
import { useRoute, useRouter } from "vue-router";
const VRoutesMenu = defineComponent({
name: "VRoutesMenu",
const RoutesMenu = defineComponent({
name: "RoutesMenu",
props: {
menus: {
type: Object as PropType<MenuGroupType[]>,
@ -80,4 +80,4 @@ const VRoutesMenu = defineComponent({
},
});
export { VRoutesMenu };
export { RoutesMenu };

View File

@ -3,22 +3,30 @@ import {
IconMore,
IconSearch,
IconUserSettings,
VRoutesMenu,
VTag,
VAvatar,
VSpace,
VButton,
Dialog,
} from "@halo-dev/components";
import type { MenuGroupType, MenuItemType } from "../types/menus";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import type { User } from "@halo-dev/api-client";
import logo from "@/assets/logo.svg";
import { RouterView, useRoute, useRouter } from "vue-router";
import { computed, inject, ref, type Ref } from "vue";
import {
RouterView,
useRoute,
useRouter,
type RouteRecordRaw,
} from "vue-router";
import { computed, inject, onMounted, onUnmounted, ref } from "vue";
import axios from "axios";
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
import { coreMenuGroups } from "@/router/routes.config";
import sortBy from "lodash.sortby";
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
const menus = inject<MenuGroupType[]>("menus");
const minimenus = inject<MenuItemType[]>("minimenus");
const route = useRoute();
const router = useRouter();
@ -26,18 +34,13 @@ const moreMenuVisible = ref(false);
const moreMenuRootVisible = ref(false);
const currentUser = inject<User>("currentUser");
const apiUrl = inject<string>("apiUrl");
const handleRouteToProfile = () => {
router.push({ path: `/users/${currentUser?.metadata.name}/detail` });
};
const handleLogout = () => {
Dialog.warning({
title: "是否确认退出登录?",
onConfirm: async () => {
try {
await axios.post(`${apiUrl}/logout`, undefined, {
await axios.post(`${import.meta.env.VITE_API_URL}/logout`, undefined, {
withCredentials: true,
});
router.replace({ name: "Login" });
@ -56,12 +59,126 @@ const currentRole = computed(() => {
)[0];
});
const globalSearchVisible = inject<Ref<boolean>>(
"globalSearchVisible",
ref(false)
);
// Global Search
const globalSearchVisible = ref(false);
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
const handleGlobalSearchKeybinding = (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = e;
if (key === "k" && ((ctrlKey && !isMac) || metaKey)) {
globalSearchVisible.value = true;
e.preventDefault();
}
};
onMounted(() => {
document.addEventListener("keydown", handleGlobalSearchKeybinding);
});
onUnmounted(() => {
document.addEventListener("keydown", handleGlobalSearchKeybinding);
});
// Generate menus by routes
const menus = ref<MenuGroupType[]>([] as MenuGroupType[]);
const minimenus = ref<MenuItemType[]>([] as MenuItemType[]);
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
const generateMenus = () => {
// sort by menu.priority and meta.core
const currentRoutes = sortBy(
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;
}),
[
(route: RouteRecordRaw) => !route.meta?.core,
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
]
);
// group by menu.group
menus.value = 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[0];
const childMetaMenu = childRoute?.meta?.menu;
// 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,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
});
} else {
const menuGroup = coreMenuGroups.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;
}, [] as MenuGroupType[]);
// sort by menu.priority
menus.value = sortBy(menus.value, [
(menu: MenuGroupType) => {
return coreMenuGroups.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);
</script>
<template>
@ -84,7 +201,7 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
</div>
</div>
</div>
<VRoutesMenu :menus="menus" />
<RoutesMenu :menus="menus" />
<div class="current-profile">
<div v-if="currentUser?.spec.avatar" class="profile-avatar">
<VAvatar
@ -118,7 +235,10 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
v-close-popper
block
type="secondary"
@click="handleRouteToProfile"
:route="{
name: 'UserDetail',
params: { name: currentUser?.metadata.name },
}"
>
个人资料
</VButton>
@ -136,6 +256,7 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
</FloatingDropdown>
</div>
</aside>
<main class="content w-full overflow-y-auto pb-12 mb-safe md:pb-0">
<slot v-if="$slots.default" />
<RouterView v-else />
@ -144,7 +265,7 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
<!--bottom nav bar-->
<div
v-if="minimenus"
class="bottom-nav-bar fixed left-0 bottom-0 right-0 grid grid-cols-6 border-t-2 border-black drop-shadow-2xl mt-safe pb-safe md:hidden bg-secondary"
class="bottom-nav-bar fixed left-0 bottom-0 right-0 grid grid-cols-6 border-t-2 border-black bg-secondary drop-shadow-2xl mt-safe pb-safe md:hidden"
>
<div
v-for="(menu, index) in minimenus"
@ -217,7 +338,7 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
class="drawer-content relative flex h-3/4 w-screen flex-col items-stretch overflow-y-auto rounded-t-md bg-white shadow-xl"
>
<div class="drawer-body">
<VRoutesMenu
<RoutesMenu
:menus="menus"
class="p-0"
@select="moreMenuVisible = false"
@ -229,6 +350,7 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
</Teleport>
</div>
</div>
<GlobalSearchModal v-model:visible="globalSearchVisible" />
</template>
<style lang="scss">
@ -241,24 +363,24 @@ const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
.current-profile {
height: 70px;
@apply w-64
bg-white
p-3
flex
fixed
@apply fixed
left-0
bottom-0
gap-3;
flex
w-64
gap-3
bg-white
p-3;
.profile-avatar {
@apply self-center
flex
items-center;
@apply flex
items-center
self-center;
}
.profile-name {
@apply self-center
flex-1;
@apply flex-1
self-center;
}
.profile-control {

View File

@ -3,14 +3,9 @@ import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import type {
MenuGroupType,
MenuItemType,
Plugin,
} from "@halo-dev/console-shared";
import type { Plugin, RouteRecordAppend } from "@halo-dev/console-shared";
import { Toast } from "@halo-dev/components";
import { apiClient } from "@/utils/api-client";
import { menus, minimenus, registerMenu } from "./router/menus.config";
// setup
import "./setup/setupStyles";
import { setupComponents } from "./setup/setupComponents";
@ -21,6 +16,7 @@ import { usePluginStore } from "@/stores/plugin";
import type { User } from "@halo-dev/api-client";
import { hasPermission } from "@/utils/permission";
import { useRoleStore } from "@/stores/role";
import type { RouteRecordRaw } from "vue-router";
const app = createApp(App);
@ -28,7 +24,7 @@ setupComponents(app);
app.use(createPinia());
function registerModule(pluginModule: Plugin) {
function registerModule(pluginModule: Plugin, core: boolean) {
if (pluginModule.components) {
if (!Array.isArray(pluginModule.components)) {
console.error(`${pluginModule.name}: Plugin components must be an array`);
@ -46,6 +42,8 @@ function registerModule(pluginModule: Plugin) {
return;
}
resetRouteMeta(pluginModule.routes);
for (const route of pluginModule.routes) {
if ("parentName" in route) {
router.addRoute(route.parentName, route.route);
@ -55,22 +53,37 @@ function registerModule(pluginModule: Plugin) {
}
}
if (pluginModule.menus) {
if (!Array.isArray(pluginModule.menus)) {
console.error(`${pluginModule.name}: Plugin menus must be an array`);
return;
function resetRouteMeta(routes: RouteRecordRaw[] | RouteRecordAppend[]) {
for (const route of routes) {
if ("parentName" in route) {
if (route.route.meta?.menu) {
route.route.meta = {
...route.route.meta,
core,
};
}
if (route.route.children) {
resetRouteMeta(route.route.children);
}
} else {
if (route.meta?.menu) {
route.meta = {
...route.meta,
core,
};
}
if (route.children) {
resetRouteMeta(route.children);
}
for (const group of pluginModule.menus) {
for (const menu of group.items) {
registerMenu(group.name, menu);
}
}
}
}
function loadCoreModules() {
coreModules.forEach(registerModule);
coreModules.forEach((module) => {
registerModule(module, true);
});
}
const pluginStore = usePluginStore();
@ -133,7 +146,7 @@ async function loadPluginModules() {
if (pluginModule) {
// @ts-ignore
plugin.spec.module = pluginModule;
registerModule(pluginModule);
registerModule(pluginModule, false);
}
} catch (e) {
const message = `${plugin.metadata.name}: 加载插件入口文件失败`;
@ -215,10 +228,6 @@ async function initApp() {
} catch (e) {
console.error(e);
} finally {
app.provide<MenuGroupType[]>("menus", menus);
app.provide<MenuItemType[]>("minimenus", minimenus);
app.provide<string>("apiUrl", import.meta.env.VITE_API_URL);
app.use(router);
app.mount("#app");
}

View File

@ -1,7 +1,9 @@
import { BasicLayout, definePlugin } from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import AttachmentList from "./AttachmentList.vue";
import AttachmentSelectorModal from "./components/AttachmentSelectorModal.vue";
import { IconFolder } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "attachmentModule",
@ -18,19 +20,14 @@ export default definePlugin({
meta: {
title: "附件",
permissions: ["system:attachments:view"],
},
},
],
},
],
menus: [
{
name: "内容",
items: [
{
menu: {
name: "附件",
path: "/attachments",
icon: IconFolder,
group: "content",
icon: markRaw(IconFolder),
priority: 3,
mobile: true,
},
},
},
],
},

View File

@ -1,6 +1,8 @@
import { BasicLayout, definePlugin } from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { IconMessage } from "@halo-dev/components";
import CommentList from "./CommentList.vue";
import { markRaw } from "vue";
export default definePlugin({
name: "commentModule",
@ -18,19 +20,14 @@ export default definePlugin({
title: "评论",
searchable: true,
permissions: ["system:comments:view"],
},
},
],
},
],
menus: [
{
name: "内容",
items: [
{
menu: {
name: "评论",
path: "/comments",
icon: IconMessage,
group: "content",
icon: markRaw(IconMessage),
priority: 2,
mobile: true,
},
},
},
],
},

View File

@ -8,7 +8,7 @@ import {
IconPages,
VButton,
} from "@halo-dev/components";
import { BasicLayout } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();

View File

@ -1,13 +1,12 @@
import {
BasicLayout,
BlankLayout,
definePlugin,
} from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import BlankLayout from "@/layouts/BlankLayout.vue";
import PageLayout from "./layouts/PageLayout.vue";
import FunctionalPageList from "./FunctionalPageList.vue";
import SinglePageList from "./SinglePageList.vue";
import SinglePageEditor from "./SinglePageEditor.vue";
import { IconPages } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "pageModule",
@ -20,6 +19,14 @@ export default definePlugin({
redirect: {
name: "FunctionalPages",
},
meta: {
menu: {
name: "页面",
group: "content",
icon: markRaw(IconPages),
priority: 1,
},
},
children: [
{
path: "functional",
@ -77,16 +84,4 @@ export default definePlugin({
],
},
],
menus: [
{
name: "内容",
items: [
{
name: "页面",
path: "/pages",
icon: IconPages,
},
],
},
],
});

View File

@ -1,13 +1,12 @@
import {
BasicLayout,
BlankLayout,
definePlugin,
} from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import BlankLayout from "@/layouts/BlankLayout.vue";
import { IconBookRead } from "@halo-dev/components";
import PostList from "./PostList.vue";
import PostEditor from "./PostEditor.vue";
import CategoryList from "./categories/CategoryList.vue";
import TagList from "./tags/TagList.vue";
import { markRaw } from "vue";
export default definePlugin({
name: "postModule",
@ -25,6 +24,13 @@ export default definePlugin({
title: "文章",
searchable: true,
permissions: ["system:posts:view"],
menu: {
name: "文章",
group: "content",
icon: markRaw(IconBookRead),
priority: 0,
mobile: true,
},
},
},
{
@ -72,16 +78,4 @@ export default definePlugin({
],
},
],
menus: [
{
name: "内容",
items: [
{
name: "文章",
path: "/posts",
icon: IconBookRead,
},
],
},
],
});

View File

@ -1,4 +1,5 @@
import { BasicLayout, definePlugin } from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import Dashboard from "./Dashboard.vue";
import { IconDashboard } from "@halo-dev/components";
@ -9,6 +10,7 @@ import RecentLoginWidget from "./widgets/RecentLoginWidget.vue";
import RecentPublishedWidget from "./widgets/RecentPublishedWidget.vue";
import UserStatsWidget from "./widgets/UserStatsWidget.vue";
import ViewsStatsWidget from "./widgets/ViewsStatsWidget.vue";
import { markRaw } from "vue";
export default definePlugin({
name: "dashboardModule",
@ -25,6 +27,7 @@ export default definePlugin({
{
path: "/",
component: BasicLayout,
name: "Root",
redirect: "/dashboard",
children: [
{
@ -34,19 +37,14 @@ export default definePlugin({
meta: {
title: "仪表盘",
searchable: true,
},
},
],
},
],
menus: [
{
name: "",
items: [
{
menu: {
name: "仪表盘",
path: "/dashboard",
icon: IconDashboard,
group: "dashboard",
icon: markRaw(IconDashboard),
priority: 0,
mobile: true,
},
},
},
],
},

View File

@ -10,18 +10,32 @@ import userModule from "./system/users/module";
import roleModule from "./system/roles/module";
import settingModule from "./system/settings/module";
// const coreModules = [
// dashboardModule,
// postModule,
// pageModule,
// commentModule,
// attachmentModule,
// themeModule,
// menuModule,
// pluginModule,
// userModule,
// roleModule,
// settingModule,
// ];
const coreModules = [
dashboardModule,
postModule,
pageModule,
pluginModule,
settingModule,
dashboardModule,
menuModule,
commentModule,
attachmentModule,
pageModule,
themeModule,
menuModule,
pluginModule,
userModule,
roleModule,
settingModule,
];
export { coreModules };

View File

@ -1,6 +1,8 @@
import { BasicLayout, definePlugin } from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import Menus from "./Menus.vue";
import { IconListSettings } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "menuModule",
@ -18,19 +20,13 @@ export default definePlugin({
title: "菜单",
searchable: true,
permissions: ["system:menus:view"],
},
},
],
},
],
menus: [
{
name: "外观",
items: [
{
menu: {
name: "菜单",
path: "/menus",
icon: IconListSettings,
group: "interface",
icon: markRaw(IconListSettings),
priority: 1,
},
},
},
],
},

View File

@ -10,7 +10,7 @@ import cloneDeep from "lodash.clonedeep";
// hooks
import { useThemeLifeCycle } from "../composables/use-theme";
// types
import { BasicLayout } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { useSettingForm } from "@/composables/use-setting-form";
// components

View File

@ -1,9 +1,11 @@
import { BlankLayout, definePlugin } from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BlankLayout from "@/layouts/BlankLayout.vue";
import ThemeLayout from "./layouts/ThemeLayout.vue";
import ThemeDetail from "./ThemeDetail.vue";
import ThemeSetting from "./ThemeSetting.vue";
import Visual from "./Visual.vue";
import { IconPalette } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "themeModule",
@ -21,6 +23,12 @@ export default definePlugin({
title: "主题",
searchable: true,
permissions: ["system:themes:view"],
menu: {
name: "主题",
group: "interface",
icon: markRaw(IconPalette),
priority: 0,
},
},
},
{
@ -49,16 +57,4 @@ export default definePlugin({
],
},
],
menus: [
{
name: "外观",
items: [
{
name: "主题",
path: "/theme",
icon: IconPalette,
},
],
},
],
});

View File

@ -12,7 +12,7 @@ import { useSettingForm } from "@/composables/use-setting-form";
// components
import { VButton, VCard, VPageHeader, VTabbar } from "@halo-dev/components";
import { BasicLayout } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
// types
import type { Ref } from "vue";

View File

@ -1,13 +1,12 @@
import {
BasicLayout,
BlankLayout,
definePlugin,
} from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import BlankLayout from "@/layouts/BlankLayout.vue";
import PluginLayout from "./layouts/PluginLayout.vue";
import PluginList from "./PluginList.vue";
import PluginSetting from "./PluginSetting.vue";
import PluginDetail from "./PluginDetail.vue";
import { IconPlug } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "pluginModule",
@ -29,6 +28,12 @@ export default definePlugin({
title: "插件",
searchable: true,
permissions: ["system:plugins:view"],
menu: {
name: "插件",
group: "system",
icon: markRaw(IconPlug),
priority: 0,
},
},
},
],
@ -60,16 +65,4 @@ export default definePlugin({
],
},
],
menus: [
{
name: "系统",
items: [
{
name: "插件",
path: "/plugins",
icon: IconPlug,
},
],
},
],
});

View File

@ -1,4 +1,5 @@
import { BasicLayout, definePlugin } from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import RoleList from "./RoleList.vue";
import RoleDetail from "./RoleDetail.vue";
@ -32,5 +33,4 @@ export default definePlugin({
],
},
],
menus: [],
});

View File

@ -5,7 +5,7 @@ import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
// types
import { BasicLayout } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { useSettingForm } from "@/composables/use-setting-form";
// components

View File

@ -2,6 +2,7 @@ import { definePlugin } from "@halo-dev/console-shared";
import SystemSettingsLayout from "./layouts/SystemSettingsLayout.vue";
import SystemSetting from "./SystemSetting.vue";
import { IconSettings } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "settingModule",
@ -11,27 +12,21 @@ export default definePlugin({
path: "/settings",
component: SystemSettingsLayout,
redirect: "/settings/basic",
meta: {
title: "系统设置",
permissions: ["system:settings:view"],
menu: {
name: "设置",
group: "system",
icon: markRaw(IconSettings),
priority: 2,
},
},
children: [
{
path: ":group",
name: "SystemSetting",
component: SystemSetting,
meta: {
title: "系统设置",
permissions: ["system:settings:view"],
},
},
],
},
],
menus: [
{
name: "系统",
items: [
{
name: "设置",
path: "/settings",
icon: IconSettings,
},
],
},

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { BasicLayout } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { apiClient } from "@/utils/api-client";
import {
IconUpload,

View File

@ -1,14 +1,13 @@
import {
BasicLayout,
BlankLayout,
definePlugin,
} from "@halo-dev/console-shared";
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import BlankLayout from "@/layouts/BlankLayout.vue";
import UserProfileLayout from "./layouts/UserProfileLayout.vue";
import UserList from "./UserList.vue";
import UserDetail from "./UserDetail.vue";
import PersonalAccessTokens from "./PersonalAccessTokens.vue";
import Login from "./Login.vue";
import { IconUserSettings } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
name: "userModule",
@ -35,6 +34,13 @@ export default definePlugin({
title: "用户",
searchable: true,
permissions: ["system:users:view"],
menu: {
name: "用户",
group: "system",
icon: markRaw(IconUserSettings),
priority: 1,
mobile: true,
},
},
},
],
@ -65,16 +71,4 @@ export default definePlugin({
],
},
],
menus: [
{
name: "系统",
items: [
{
name: "用户",
path: "/users",
icon: IconUserSettings,
},
],
},
],
});

View File

@ -1,72 +0,0 @@
import {
IconBookRead,
IconDashboard,
IconFolder,
IconMessage,
IconUserSettings,
} from "@halo-dev/components";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
export const menus: MenuGroupType[] = [
{
name: "",
items: [],
},
{
name: "内容",
items: [],
},
{
name: "外观",
items: [],
},
{
name: "系统",
items: [],
},
];
export const minimenus: MenuItemType[] = [
{
name: "仪表盘",
path: "/dashboard",
icon: IconDashboard,
},
{
name: "文章",
path: "/posts",
icon: IconBookRead,
},
{
name: "评论",
path: "/comments",
icon: IconMessage,
},
{
name: "附件",
path: "/attachments",
icon: IconFolder,
},
{
name: "用户",
path: "/users/profile/detail",
icon: IconUserSettings,
},
];
export function registerMenu(group: string | undefined, menu: MenuItemType) {
const groupIndex = menus.findIndex((g) => g.name === group);
if (groupIndex !== -1) {
menus[groupIndex].items.push(menu);
return;
}
menus.push({
name: group,
items: [menu],
});
}
export type { MenuItemType, MenuGroupType };
export default menus;

View File

@ -1,8 +1,9 @@
import type { RouteRecordRaw } from "vue-router";
import NotFound from "@/views/exceptions/NotFound.vue";
import Forbidden from "@/views/exceptions/Forbidden.vue";
import { BasicLayout } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import Setup from "@/views/system/Setup.vue";
import type { MenuGroupType } from "@halo-dev/console-shared";
export const routes: Array<RouteRecordRaw> = [
{
@ -31,4 +32,32 @@ export const routes: Array<RouteRecordRaw> = [
},
];
export const coreMenuGroups: MenuGroupType[] = [
{
id: "dashboard",
name: undefined,
priority: 0,
},
{
id: "content",
name: "内容",
priority: 1,
},
{
id: "interface",
name: "外观",
priority: 2,
},
{
id: "system",
name: "系统",
priority: 3,
},
{
id: "tool",
name: "工具",
priority: 4,
},
];
export default routes;

View File

@ -1,4 +1,3 @@
import "@halo-dev/richtext-editor/dist/style.css";
import "@halo-dev/components/dist/style.css";
import "@/styles/tailwind.css";
import "@halo-dev/console-shared/dist/style.css";