feat: menus management (halo-dev/console#595)

Signed-off-by: Ryan Wang <i@ryanc.cc>
pull/3445/head
Ryan Wang 2022-08-12 16:20:21 +08:00 committed by GitHub
parent 0826f51f67
commit 9fef47f291
14 changed files with 1733 additions and 189 deletions

View File

@ -34,11 +34,12 @@
"@formkit/vue": "1.0.0-beta.10", "@formkit/vue": "1.0.0-beta.10",
"@halo-dev/admin-api": "^1.1.0", "@halo-dev/admin-api": "^1.1.0",
"@halo-dev/admin-shared": "workspace:*", "@halo-dev/admin-shared": "workspace:*",
"@halo-dev/api-client": "^0.0.11", "@halo-dev/api-client": "^0.0.10",
"@halo-dev/components": "workspace:*", "@halo-dev/components": "workspace:*",
"@halo-dev/richtext-editor": "0.0.0-alpha.1", "@halo-dev/richtext-editor": "0.0.0-alpha.1",
"@vueuse/components": "^8.9.4", "@vueuse/components": "^8.9.4",
"@vueuse/core": "^8.9.4", "@vueuse/core": "^8.9.4",
"@vueuse/router": "^9.1.0",
"axios": "^0.27.2", "axios": "^0.27.2",
"filepond": "^4.30.4", "filepond": "^4.30.4",
"filepond-plugin-image-preview": "^4.6.11", "filepond-plugin-image-preview": "^4.6.11",
@ -52,6 +53,7 @@
"vue-filepond": "^7.0.3", "vue-filepond": "^7.0.3",
"vue-grid-layout": "3.0.0-beta1", "vue-grid-layout": "3.0.0-beta1",
"vue-router": "^4.1.3", "vue-router": "^4.1.3",
"vuedraggable": "^4.1.0",
"yaml": "^2.1.1" "yaml": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -36,7 +36,7 @@
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme", "homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@halo-dev/api-client": "^0.0.11", "@halo-dev/api-client": "^0.0.10",
"@halo-dev/components": "workspace:*", "@halo-dev/components": "workspace:*",
"axios": "^0.27.2" "axios": "^0.27.2"
}, },

View File

@ -9,6 +9,8 @@ import {
V1alpha1RoleBindingApi, V1alpha1RoleBindingApi,
V1alpha1SettingApi, V1alpha1SettingApi,
V1alpha1UserApi, V1alpha1UserApi,
V1alpha1MenuApi,
V1alpha1MenuItemApi,
ThemeHaloRunV1alpha1ThemeApi, ThemeHaloRunV1alpha1ThemeApi,
ApiHaloRunV1alpha1ThemeApi, ApiHaloRunV1alpha1ThemeApi,
} from "@halo-dev/api-client"; } from "@halo-dev/api-client";
@ -61,10 +63,8 @@ function setupApiClient(axios: AxiosInstance) {
plugin: new PluginHaloRunV1alpha1PluginApi(undefined, apiUrl, axios), plugin: new PluginHaloRunV1alpha1PluginApi(undefined, apiUrl, axios),
user: new V1alpha1UserApi(undefined, apiUrl, axios), user: new V1alpha1UserApi(undefined, apiUrl, axios),
theme: new ThemeHaloRunV1alpha1ThemeApi(undefined, apiUrl, axios), theme: new ThemeHaloRunV1alpha1ThemeApi(undefined, apiUrl, axios),
menu: new V1alpha1MenuApi(undefined, apiUrl, axios),
// TODO optional menuItem: new V1alpha1MenuItemApi(undefined, apiUrl, axios),
// link: new CoreHaloRunV1alpha1LinkApi(undefined, apiUrl, axios),
// linkGroup: new CoreHaloRunV1alpha1LinkGroupApi(undefined, apiUrl, axios),
}, },
user: new ApiHaloRunV1alpha1UserApi(undefined, apiUrl, axios), user: new ApiHaloRunV1alpha1UserApi(undefined, apiUrl, axios),
plugin: new ApiHaloRunV1alpha1PluginApi(undefined, apiUrl, axios), plugin: new ApiHaloRunV1alpha1PluginApi(undefined, apiUrl, axios),

View File

@ -14,7 +14,7 @@ importers:
'@formkit/vue': 1.0.0-beta.10 '@formkit/vue': 1.0.0-beta.10
'@halo-dev/admin-api': ^1.1.0 '@halo-dev/admin-api': ^1.1.0
'@halo-dev/admin-shared': workspace:* '@halo-dev/admin-shared': workspace:*
'@halo-dev/api-client': ^0.0.11 '@halo-dev/api-client': ^0.0.10
'@halo-dev/components': workspace:* '@halo-dev/components': workspace:*
'@halo-dev/richtext-editor': 0.0.0-alpha.1 '@halo-dev/richtext-editor': 0.0.0-alpha.1
'@rushstack/eslint-patch': ^1.1.4 '@rushstack/eslint-patch': ^1.1.4
@ -35,6 +35,7 @@ importers:
'@vue/tsconfig': ^0.1.3 '@vue/tsconfig': ^0.1.3
'@vueuse/components': ^8.9.4 '@vueuse/components': ^8.9.4
'@vueuse/core': ^8.9.4 '@vueuse/core': ^8.9.4
'@vueuse/router': ^9.1.0
autoprefixer: ^10.4.8 autoprefixer: ^10.4.8
axios: ^0.27.2 axios: ^0.27.2
c8: ^7.12.0 c8: ^7.12.0
@ -74,6 +75,7 @@ importers:
vue-grid-layout: 3.0.0-beta1 vue-grid-layout: 3.0.0-beta1
vue-router: ^4.1.3 vue-router: ^4.1.3
vue-tsc: ^0.39.5 vue-tsc: ^0.39.5
vuedraggable: ^4.1.0
yaml: ^2.1.1 yaml: ^2.1.1
dependencies: dependencies:
'@formkit/addons': 1.0.0-beta.10_vue@3.2.37 '@formkit/addons': 1.0.0-beta.10_vue@3.2.37
@ -85,11 +87,12 @@ importers:
'@formkit/vue': 1.0.0-beta.10_wwmyxdjqen5bmh3tr2meig5lki '@formkit/vue': 1.0.0-beta.10_wwmyxdjqen5bmh3tr2meig5lki
'@halo-dev/admin-api': 1.1.0 '@halo-dev/admin-api': 1.1.0
'@halo-dev/admin-shared': link:packages/shared '@halo-dev/admin-shared': link:packages/shared
'@halo-dev/api-client': 0.0.11 '@halo-dev/api-client': 0.0.10
'@halo-dev/components': link:packages/components '@halo-dev/components': link:packages/components
'@halo-dev/richtext-editor': 0.0.0-alpha.1_vue@3.2.37 '@halo-dev/richtext-editor': 0.0.0-alpha.1_vue@3.2.37
'@vueuse/components': 8.9.4_vue@3.2.37 '@vueuse/components': 8.9.4_vue@3.2.37
'@vueuse/core': 8.9.4_vue@3.2.37 '@vueuse/core': 8.9.4_vue@3.2.37
'@vueuse/router': 9.1.0_45jhv7el6g2ztux7wgm2ofrf4e
axios: 0.27.2 axios: 0.27.2
filepond: 4.30.4 filepond: 4.30.4
filepond-plugin-image-preview: 4.6.11_filepond@4.30.4 filepond-plugin-image-preview: 4.6.11_filepond@4.30.4
@ -103,6 +106,7 @@ importers:
vue-filepond: 7.0.3_filepond@4.30.4+vue@3.2.37 vue-filepond: 7.0.3_filepond@4.30.4+vue@3.2.37
vue-grid-layout: 3.0.0-beta1 vue-grid-layout: 3.0.0-beta1
vue-router: 4.1.3_vue@3.2.37 vue-router: 4.1.3_vue@3.2.37
vuedraggable: 4.1.0_vue@3.2.37
yaml: 2.1.1 yaml: 2.1.1
devDependencies: devDependencies:
'@changesets/cli': 2.24.2 '@changesets/cli': 2.24.2
@ -176,12 +180,12 @@ importers:
packages/shared: packages/shared:
specifiers: specifiers:
'@halo-dev/api-client': ^0.0.11 '@halo-dev/api-client': ^0.0.10
'@halo-dev/components': workspace:* '@halo-dev/components': workspace:*
axios: ^0.27.2 axios: ^0.27.2
vite-plugin-dts: ^1.4.1 vite-plugin-dts: ^1.4.1
dependencies: dependencies:
'@halo-dev/api-client': 0.0.11 '@halo-dev/api-client': 0.0.10
'@halo-dev/components': link:../components '@halo-dev/components': link:../components
axios: 0.27.2 axios: 0.27.2
devDependencies: devDependencies:
@ -2126,8 +2130,8 @@ packages:
- debug - debug
dev: false dev: false
/@halo-dev/api-client/0.0.11: /@halo-dev/api-client/0.0.10:
resolution: {integrity: sha512-67e7lrWPoHVKF4XItT6OWr7rX96CR9s/3SOVXYD3xCyAMIgLyFULfKVI7JGxisFMKvadTx7rm5RK4zzZ/CbXIw==} resolution: {integrity: sha512-DKQKkEAKMR/rbopI6jbjbzLiYUZeY6dOcgqGoDGG8MAcwkWOI6iWaZnuR5z+X8vd51XjiPhnekAphfcO6PaWEQ==}
dev: false dev: false
/@halo-dev/logger/1.1.0: /@halo-dev/logger/1.1.0:
@ -3653,6 +3657,19 @@ packages:
resolution: {integrity: sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==} resolution: {integrity: sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==}
dev: false dev: false
/@vueuse/router/9.1.0_45jhv7el6g2ztux7wgm2ofrf4e:
resolution: {integrity: sha512-ub2B8XfHCQXX4Migab9bBnFjJI+NmqeLbQox1UdhWhy9cZIpgKx40aW7eq/LLUMGVIKAdriDI6YsD0LZrZFeaA==}
peerDependencies:
vue-router: '>=4.0.0-rc.1'
dependencies:
'@vueuse/shared': 9.1.0_vue@3.2.37
vue-demi: 0.12.1_vue@3.2.37
vue-router: 4.1.3_vue@3.2.37
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/@vueuse/shared/8.9.4_vue@3.2.37: /@vueuse/shared/8.9.4_vue@3.2.37:
resolution: {integrity: sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==} resolution: {integrity: sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==}
peerDependencies: peerDependencies:
@ -3668,6 +3685,15 @@ packages:
vue-demi: 0.12.1_vue@3.2.37 vue-demi: 0.12.1_vue@3.2.37
dev: false dev: false
/@vueuse/shared/9.1.0_vue@3.2.37:
resolution: {integrity: sha512-pB/3njQu4tfJJ78ajELNda0yMG6lKfpToQW7Soe09CprF1k3QuyoNi1tBNvo75wBDJWD+LOnr+c4B5HZ39jY/Q==}
dependencies:
vue-demi: 0.12.1_vue@3.2.37
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/abab/2.0.6: /abab/2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
dev: true dev: true
@ -8195,6 +8221,10 @@ packages:
yargs: 15.4.1 yargs: 15.4.1
dev: true dev: true
/sortablejs/1.14.0:
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
dev: false
/source-map-js/1.0.2: /source-map-js/1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -9279,6 +9309,15 @@ packages:
'@vue/server-renderer': 3.2.37_vue@3.2.37 '@vue/server-renderer': 3.2.37_vue@3.2.37
'@vue/shared': 3.2.37 '@vue/shared': 3.2.37
/vuedraggable/4.1.0_vue@3.2.37:
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
peerDependencies:
vue: ^3.0.1
dependencies:
sortablejs: 1.14.0
vue: 3.2.37
dev: false
/w3c-hr-time/1.0.2: /w3c-hr-time/1.0.2:
resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==}
dependencies: dependencies:

View File

@ -1,175 +0,0 @@
<script lang="ts" setup>
import {
IconDeleteBin,
IconListSettings,
VButton,
VCard,
VMenu,
VMenuItem,
VPageHeader,
VSpace,
VTag,
} from "@halo-dev/components";
import { ref } from "vue";
const menus = ref([
{
id: 1,
name: "首页",
url: "https://halo.run",
priority: 0,
target: "_self",
icon: "",
parentId: 0,
team: "main",
children: [],
},
{
id: 4,
name: "官方文档",
url: "https://docs.halo.run",
priority: 1,
target: "_blank",
icon: "",
parentId: 0,
team: "main",
children: [],
},
{
id: 3,
name: "主题仓库",
url: "/themes.html",
priority: 2,
target: "_self",
icon: "",
parentId: 0,
team: "main",
children: [],
},
{
id: 36,
name: "博客",
url: "/blog.html",
priority: 3,
target: "_self",
icon: "",
parentId: 0,
team: "main",
children: [],
},
{
id: 40,
name: "论坛",
url: "https://bbs.halo.run",
priority: 4,
target: "_blank",
icon: "",
parentId: 0,
team: "main",
children: [],
},
{
id: 168,
name: "在线体验",
url: "https://docs.halo.run/#%E5%9C%A8%E7%BA%BF%E4%BD%93%E9%AA%8C",
priority: 5,
target: "_blank",
icon: "",
parentId: 0,
team: "main",
children: [],
},
]);
</script>
<template>
<VPageHeader title="菜单">
<template #icon>
<IconListSettings class="mr-2 self-center" />
</template>
<template #actions>
<VButton type="secondary">重建索引</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<div class="flex flex-col gap-4 sm:flex-row">
<div class="w-96">
<VCard title="分组">
<VMenu class="!p-0">
<VMenuItem id="default" active title="未分组"></VMenuItem>
<VMenuItem id="community" title="社区"></VMenuItem>
</VMenu>
<template #footer>
<VButton block type="secondary">新增</VButton>
</template>
</VCard>
</div>
<div class="flex-1">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium">未分组</span>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace>
<VButton size="xs" type="primary">保存</VButton>
<VButton size="xs" type="default">新增</VButton>
</VSpace>
</div>
</div>
</div>
</template>
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(menu, index) in menus" :key="index">
<div
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div class="relative flex flex-row items-center">
<div class="mr-4 hidden cursor-move items-center sm:flex">
<IconListSettings />
</div>
<div class="flex-1">
<div class="flex flex-row items-center">
<span
class="mr-2 truncate text-sm font-medium text-gray-900"
>
{{ menu.name }}
</span>
<VTag class="sm:hidden">asd</VTag>
</div>
<div class="mt-1 flex">
<VSpace align="start" direction="column" spacing="xs">
<a
:href="menu.url"
class="text-xs text-gray-500 hover:text-gray-900"
target="_blank"
>
{{ menu.url }}
</a>
</VSpace>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<span class="cursor-pointer text-sm hover:text-red-600">
<IconDeleteBin />
</span>
</div>
</div>
</div>
</div>
</li>
</ul>
</VCard>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,194 @@
<script lang="ts" setup>
import {
IconListSettings,
useDialog,
VButton,
VCard,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import MenuItemEditingModal from "./components/MenuItemEditingModal.vue";
import MenuItemListItem from "./components/MenuItemListItem.vue";
import MenuList from "./components/MenuList.vue";
import { ref } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
import type { Menu, MenuItem } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import type { MenuTreeItem } from "./utils";
import {
buildMenuItemsTree,
convertMenuTreeItemToMenuItem,
convertTreeToMenuItems,
getChildrenNames,
resetMenuItemsTreePriority,
} from "./utils";
import { useDebounceFn } from "@vueuse/core";
const menuItems = ref<MenuItem[]>([] as MenuItem[]);
const menuTreeItems = ref<MenuTreeItem[]>([] as MenuTreeItem[]);
const selectedMenu = ref<Menu | undefined>();
const selectedMenuItem = ref<MenuItem | null>(null);
const menuListRef = ref();
const menuItemEditingModal = ref();
const dialog = useDialog();
const handleFetchMenuItems = async () => {
try {
if (!selectedMenu.value?.spec.menuItems) {
return;
}
const menuItemNames = Array.from(selectedMenu.value.spec.menuItems)?.map(
(item) => item
);
const { data } = await apiClient.extension.menuItem.listv1alpha1MenuItem(
0,
0,
[],
[`name=(${menuItemNames.join(",")})`]
);
menuItems.value = data.items;
// Build the menu tree
menuTreeItems.value = buildMenuItemsTree(data.items);
} catch (e) {
console.error("Failed to fetch menu items", e);
}
};
const handleOpenEditingModal = (menuItem: MenuTreeItem) => {
selectedMenuItem.value = convertMenuTreeItemToMenuItem(menuItem);
menuItemEditingModal.value = true;
};
const onMenuItemSaved = async (menuItem: MenuItem) => {
const menuToUpdate = cloneDeep(selectedMenu.value);
if (menuToUpdate) {
const menuItemsToUpdate = Array.from(
cloneDeep(menuToUpdate.spec.menuItems) || new Set<string>()
);
menuItemsToUpdate.push(menuItem.metadata.name);
// @ts-ignore
menuToUpdate.spec.menuItems = Array.from(new Set(menuItemsToUpdate));
await apiClient.extension.menu.updatev1alpha1Menu(
menuToUpdate.metadata.name,
menuToUpdate
);
}
await menuListRef.value.handleFetchMenus();
await handleFetchMenuItems();
};
const handleUpdateInBatch = useDebounceFn(async () => {
const menuTreeItemsToUpdate = resetMenuItemsTreePriority(menuTreeItems.value);
const menuItemsToUpdate = convertTreeToMenuItems(menuTreeItemsToUpdate);
try {
const promises = menuItemsToUpdate.map((menuItem) =>
apiClient.extension.menuItem.updatev1alpha1MenuItem(
menuItem.metadata.name,
menuItem
)
);
await Promise.all(promises);
} catch (e) {
console.log("Failed to update menu items", e);
} finally {
await menuListRef.value.handleFetchMenus();
await handleFetchMenuItems();
}
}, 500);
const handleDelete = async (menuItem: MenuTreeItem) => {
dialog.info({
title: "是否确定删除该菜单?",
description: "删除后将无法恢复",
confirmType: "danger",
onConfirm: async () => {
await apiClient.extension.menuItem.deletev1alpha1MenuItem(
menuItem.metadata.name
);
const childrenNames = getChildrenNames(menuItem);
if (childrenNames.length) {
setTimeout(() => {
dialog.info({
title: "检查到当前菜单下包含子菜单,是否删除?",
description: "如果选择否,那么所有子菜单将转移到一级菜单",
confirmType: "danger",
onConfirm: async () => {
const promises = childrenNames.map((name) =>
apiClient.extension.menuItem.deletev1alpha1MenuItem(name)
);
await Promise.all(promises);
},
});
}, 200);
}
await menuListRef.value.handleFetchMenus();
await handleFetchMenuItems();
},
});
};
</script>
<template>
<MenuItemEditingModal
v-model:visible="menuItemEditingModal"
:menu-item="selectedMenuItem"
@close="selectedMenuItem = null"
@saved="onMenuItemSaved"
/>
<VPageHeader title="菜单">
<template #icon>
<IconListSettings class="mr-2 self-center" />
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<div class="flex flex-col gap-4 sm:flex-row">
<div class="w-96">
<MenuList
ref="menuListRef"
v-model:selected-menu="selectedMenu"
@select="handleFetchMenuItems"
/>
</div>
<div class="flex-1">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium">
{{ selectedMenu?.spec.displayName }}
</span>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace>
<VButton
size="xs"
type="default"
@click="menuItemEditingModal = true"
>
新增
</VButton>
</VSpace>
</div>
</div>
</div>
</template>
<MenuItemListItem
:menu-tree-items="menuTreeItems"
@change="handleUpdateInBatch"
@delete="handleDelete"
@open-editing="handleOpenEditingModal"
/>
</VCard>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,122 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace } from "@halo-dev/components";
import type { Menu } from "@halo-dev/api-client";
import { v4 as uuid } from "uuid";
import type { PropType } from "vue";
import { computed, ref, watch } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
import { submitForm } from "@formkit/core";
import cloneDeep from "lodash.clonedeep";
import { useMagicKeys } from "@vueuse/core";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
menu: {
type: Object as PropType<Menu | null>,
default: null,
},
});
const emit = defineEmits(["update:visible", "close"]);
const initialFormState: Menu = {
spec: {
displayName: "",
// @ts-ignore
menuItems: [],
},
apiVersion: "v1alpha1",
kind: "Menu",
metadata: {
name: uuid(),
},
};
const formState = ref<Menu>(cloneDeep(initialFormState));
const saving = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const handleCreateMenu = async () => {
try {
saving.value = true;
if (isUpdateMode.value) {
await apiClient.extension.menu.updatev1alpha1Menu(
formState.value.metadata.name,
formState.value
);
} else {
await apiClient.extension.menu.createv1alpha1Menu(formState.value);
}
onVisibleChange(false);
} catch (e) {
console.error("Failed to create menu", e);
} finally {
saving.value = false;
}
};
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
watch(props, (newVal) => {
const { Command_Enter } = useMagicKeys();
let keyboardWatcher;
if (newVal.visible) {
keyboardWatcher = watch(Command_Enter, (v) => {
if (v) {
submitForm("menu-form");
}
});
} else {
keyboardWatcher?.unwatch();
}
if (newVal.visible && props.menu) {
formState.value = cloneDeep(props.menu);
return;
}
formState.value = cloneDeep(initialFormState);
formState.value.metadata.name = uuid();
});
</script>
<template>
<VModal
:visible="visible"
:width="500"
title="编辑菜单"
@update:visible="onVisibleChange"
>
<FormKit
id="menu-form"
:classes="{ form: 'w-full' }"
type="form"
@submit="handleCreateMenu"
>
<FormKit
v-model="formState.spec.displayName"
help="可根据此名称查询菜单项"
label="菜单名称"
type="text"
validation="required"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton type="secondary" @click="$formkit.submit('menu-form')">
提交 +
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,137 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace } from "@halo-dev/components";
import type { PropType } from "vue";
import { computed, ref, watch, watchEffect } from "vue";
import type { MenuItem } from "@halo-dev/api-client";
import { v4 as uuid } from "uuid";
import { apiClient } from "@halo-dev/admin-shared";
import { submitForm } from "@formkit/core";
import cloneDeep from "lodash.clonedeep";
import { useMagicKeys } from "@vueuse/core";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
menuItem: {
type: Object as PropType<MenuItem | null>,
default: null,
},
});
const emit = defineEmits(["update:visible", "close", "saved"]);
const initialFormState: MenuItem = {
spec: {
displayName: "",
href: "",
children: new Set([]),
priority: 0,
},
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
name: uuid(),
},
};
const formState = ref<MenuItem>(cloneDeep(initialFormState));
const saving = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const handleSaveMenuItem = async () => {
try {
saving.value = true;
// TODO Array
// @ts-ignore
formState.value.spec.children = Array.from(formState.value.spec.children);
if (isUpdateMode.value) {
const { data } =
await apiClient.extension.menuItem.updatev1alpha1MenuItem(
formState.value.metadata.name,
formState.value
);
onVisibleChange(false);
emit("saved", data);
} else {
const { data } =
await apiClient.extension.menuItem.createv1alpha1MenuItem(
formState.value
);
onVisibleChange(false);
emit("saved", data);
}
} catch (e) {
console.error("Failed to create menu item", e);
} finally {
saving.value = false;
}
};
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
watch(props, (newVal) => {
if (newVal.visible && props.menuItem) {
formState.value = cloneDeep(props.menuItem);
return;
}
formState.value = cloneDeep(initialFormState);
formState.value.metadata.name = uuid();
});
watchEffect(() => {
let keyboardWatcher;
const { Command_Enter } = useMagicKeys();
if (props.visible) {
keyboardWatcher = watch(Command_Enter, (v) => {
if (v) {
submitForm("menuitem-form");
}
});
} else {
keyboardWatcher?.unwatch();
}
});
</script>
<template>
<VModal
:visible="visible"
:width="500"
title="编辑菜单项"
@update:visible="onVisibleChange"
>
<FormKit id="menuitem-form" type="form" @submit="handleSaveMenuItem">
<FormKit
v-model="formState.spec.displayName"
label="名称"
type="text"
validation="required"
></FormKit>
<FormKit
v-model="formState.spec.href"
label="链接地址"
type="text"
validation="required"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton type="secondary" @click="$formkit.submit('menuitem-form')">
提交 +
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,127 @@
<script lang="ts" setup>
import { IconList, IconSettings, VButton, VSpace } from "@halo-dev/components";
import Draggable from "vuedraggable";
import type { PropType } from "vue";
import { ref } from "vue";
import type { MenuTreeItem } from "@/modules/interface/menus/utils";
defineProps({
menuTreeItems: {
type: Array as PropType<MenuTreeItem[]>,
default: () => [],
},
});
const emit = defineEmits(["change", "open-editing", "delete"]);
const isDragging = ref(false);
function onChange() {
emit("change");
}
function onOpenEditingModal(menuItem: MenuTreeItem) {
emit("open-editing", menuItem);
}
function onDelete(menuItem: MenuTreeItem) {
emit("delete", menuItem);
}
</script>
<template>
<draggable
:list="menuTreeItems"
class="box-border h-full w-full divide-y divide-gray-100"
ghost-class="opacity-50"
group="menu-item"
handle=".drag-element"
item-key="metadata.name"
tag="ul"
@change="onChange"
@end="isDragging = false"
@start="isDragging = true"
>
<template #item="{ element: menuItem }">
<li>
<div
class="group relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div
class="drag-element absolute inset-y-0 left-0 flex hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex"
>
<IconList class="h-3.5 w-3.5" />
</div>
<div class="relative flex flex-row items-center">
<div class="flex-1">
<div class="flex flex-row items-center">
<span class="truncate text-sm font-medium text-gray-900">
{{ menuItem.spec.displayName }}
</span>
</div>
<div class="mt-1 flex">
<VSpace align="start" direction="column" spacing="xs">
<a
:href="menuItem.spec.href"
class="text-xs text-gray-500 hover:text-gray-900"
target="_blank"
>
{{ menuItem.spec.href }}
</a>
</VSpace>
</div>
</div>
<FloatingTooltip
v-if="menuItem.metadata.deletionTimestamp"
class="mr-4 hidden items-center sm:flex"
>
<div class="inline-flex h-1.5 w-1.5 rounded-full bg-red-600">
<span
class="inline-block h-1.5 w-1.5 animate-ping rounded-full bg-red-600"
></span>
</div>
<template #popper> 删除中</template>
</FloatingTooltip>
<div class="self-center">
<FloatingDropdown>
<IconSettings
class="cursor-pointer transition-all hover:text-blue-600"
/>
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-close-popper
block
type="secondary"
@click="onOpenEditingModal(menuItem)"
>
修改
</VButton>
<VButton
v-close-popper
block
type="danger"
@click="onDelete(menuItem)"
>
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</div>
</div>
</div>
<MenuItemListItem
:menu-tree-items="menuItem.spec.children"
class="pl-10 transition-all duration-300"
@change="onChange"
@delete="onDelete"
@open-editing="onOpenEditingModal"
/>
</li>
</template>
</draggable>
</template>

View File

@ -0,0 +1,184 @@
<script lang="ts" setup>
import {
IconSettings,
useDialog,
VButton,
VCard,
VSpace,
} from "@halo-dev/components";
import MenuEditingModal from "./MenuEditingModal.vue";
import type { PropType } from "vue";
import { defineExpose, onMounted, ref } from "vue";
import type { Menu } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
import { useRouteQuery } from "@vueuse/router";
const props = defineProps({
selectedMenu: {
type: Object as PropType<Menu | null>,
default: null,
},
});
const emit = defineEmits(["select", "update:selectedMenu"]);
const menus = ref<Menu[]>([] as Menu[]);
const selectedMenuToUpdate = ref<Menu | null>(null);
const menuEditingModal = ref<boolean>(false);
const dialog = useDialog();
const handleFetchMenus = async () => {
selectedMenuToUpdate.value = null;
try {
const { data } = await apiClient.extension.menu.listv1alpha1Menu();
menus.value = data.items;
// update selected menu
if (props.selectedMenu) {
const updatedMenu = menus.value.find(
(menu) => menu.metadata.name === props.selectedMenu?.metadata.name
);
if (updatedMenu) {
emit("update:selectedMenu", updatedMenu);
}
}
} catch (e) {
console.error("Failed to fetch menus", e);
}
};
const menuQuery = useRouteQuery("menu");
const handleSelect = (menu: Menu) => {
emit("update:selectedMenu", menu);
emit("select", menu);
menuQuery.value = menu.metadata.name;
};
const handleDeleteMenu = async (menu: Menu) => {
dialog.warning({
title: "确定要删除该菜单吗?",
description: "将同时删除该菜单下的所有菜单项,该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
await apiClient.extension.menu.deletev1alpha1Menu(menu.metadata.name);
const deleteItemsPromises = Array.from(menu.spec.menuItems || []).map(
(item) => apiClient.extension.menuItem.deletev1alpha1MenuItem(item)
);
await Promise.all(deleteItemsPromises);
} catch (e) {
console.error("Failed to delete menu", e);
} finally {
await handleFetchMenus();
}
},
});
};
const handleOpenEditingModal = (menu: Menu | null) => {
selectedMenuToUpdate.value = menu;
menuEditingModal.value = true;
};
onMounted(async () => {
await handleFetchMenus();
if (menuQuery.value) {
const menu = menus.value.find((m) => m.metadata.name === menuQuery.value);
if (menu) {
handleSelect(menu);
}
return;
}
if (menus.value.length > 0) {
handleSelect(menus.value[0]);
}
});
defineExpose({
handleFetchMenus,
});
</script>
<template>
<MenuEditingModal
v-model:visible="menuEditingModal"
:menu="selectedMenuToUpdate"
@close="handleFetchMenus"
/>
<VCard :bodyClass="['!p-0']" title="菜单">
<div class="divide-y divide-gray-100 bg-white">
<div
v-for="(menu, index) in menus"
:key="index"
:class="{
'bg-gray-50': selectedMenu?.metadata.name === menu.metadata.name,
}"
class="relative flex items-center p-4"
@click="handleSelect(menu)"
>
<div
v-if="selectedMenu?.metadata.name === menu.metadata.name"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<span class="flex flex-1 cursor-pointer flex-col gap-y-1">
<span class="block text-sm font-medium">
{{ menu.spec?.displayName }}
</span>
<span class="block text-xs text-gray-400">
{{ Array.from(menu.spec.menuItems || new Set()).length }}
个菜单项
</span>
</span>
<FloatingTooltip
v-if="menu.metadata.deletionTimestamp"
class="mr-4 hidden items-center sm:flex"
>
<div class="inline-flex h-1.5 w-1.5 rounded-full bg-red-600">
<span
class="inline-block h-1.5 w-1.5 animate-ping rounded-full bg-red-600"
></span>
</div>
<template #popper> 删除中</template>
</FloatingTooltip>
<div class="self-center">
<FloatingDropdown>
<IconSettings
class="cursor-pointer transition-all hover:text-blue-600"
/>
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-close-popper
block
type="secondary"
@click="handleOpenEditingModal(menu)"
>
修改
</VButton>
<VButton
v-close-popper
block
type="danger"
@click="handleDeleteMenu(menu)"
>
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</div>
</div>
</div>
<template #footer>
<VButton block type="secondary" @click="handleOpenEditingModal(null)">
新增
</VButton>
</template>
</VCard>
</template>

View File

@ -1,5 +1,5 @@
import { BasicLayout, definePlugin } from "@halo-dev/admin-shared"; import { BasicLayout, definePlugin } from "@halo-dev/admin-shared";
import MenuList from "./MenuList.vue"; import Menus from "./Menus.vue";
import { IconListSettings } from "@halo-dev/components"; import { IconListSettings } from "@halo-dev/components";
export default definePlugin({ export default definePlugin({
@ -13,7 +13,7 @@ export default definePlugin({
{ {
path: "", path: "",
name: "Menus", name: "Menus",
component: MenuList, component: Menus,
}, },
], ],
}, },

View File

@ -0,0 +1,378 @@
// Vitest Snapshot v1
exports[`buildMenuItemsTree > should match snapshot 1`] = `
[
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "411a3639-bf0d-4266-9cfb-14184259dab5",
"version": 1,
},
"spec": {
"children": [],
"displayName": "首页",
"href": "https://ryanc.cc/",
"priority": 0,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:19:37.252228Z",
"name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
"version": 12,
},
"spec": {
"children": [
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-07-28T06:50:32.777556Z",
"name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
"version": 4,
},
"spec": {
"children": [],
"displayName": "Halo",
"href": "https://ryanc.cc/categories/halo",
"priority": 0,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
"version": 1,
},
"spec": {
"children": [
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
"version": 1,
},
"spec": {
"children": [],
"displayName": "Spring Boot",
"href": "https://ryanc.cc/categories/spring-boot",
"priority": 0,
},
},
],
"displayName": "Java",
"href": "https://ryanc.cc/categories/java",
"priority": 1,
},
},
],
"displayName": "文章分类",
"href": "https://ryanc.cc/categories",
"priority": 1,
},
},
]
`;
exports[`convertMenuTreeItemToMenuItem > should match snapshot 1`] = `
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:19:37.252228Z",
"name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
"version": 12,
},
"spec": {
"children": Set {
"caeef383-3828-4039-9114-6f9ad3b4a37e",
"ded1943d-9fdb-4563-83ee-2f04364872e0",
},
"displayName": "文章分类",
"href": "https://ryanc.cc/categories",
"priority": 1,
},
}
`;
exports[`convertMenuTreeItemToMenuItem > should match snapshot 2`] = `
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
"version": 1,
},
"spec": {
"children": Set {
"96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
},
"displayName": "Java",
"href": "https://ryanc.cc/categories/java",
"priority": 1,
},
}
`;
exports[`convertTreeToMenuItems > will match snapshot 1`] = `
[
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "411a3639-bf0d-4266-9cfb-14184259dab5",
"version": 1,
},
"spec": {
"children": [],
"displayName": "首页",
"href": "https://ryanc.cc/",
"priority": 0,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:19:37.252228Z",
"name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
"version": 12,
},
"spec": {
"children": [
"caeef383-3828-4039-9114-6f9ad3b4a37e",
"ded1943d-9fdb-4563-83ee-2f04364872e0",
],
"displayName": "文章分类",
"href": "https://ryanc.cc/categories",
"priority": 1,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-07-28T06:50:32.777556Z",
"name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
"version": 4,
},
"spec": {
"children": [],
"displayName": "Halo",
"href": "https://ryanc.cc/categories/halo",
"priority": 0,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
"version": 1,
},
"spec": {
"children": [
"96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
],
"displayName": "Java",
"href": "https://ryanc.cc/categories/java",
"priority": 1,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
"version": 1,
},
"spec": {
"children": [],
"displayName": "Spring Boot",
"href": "https://ryanc.cc/categories/spring-boot",
"priority": 0,
},
},
]
`;
exports[`resetMenuItemsTreePriority > should match snapshot 1`] = `
[
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "411a3639-bf0d-4266-9cfb-14184259dab5",
"version": 1,
},
"spec": {
"children": [],
"displayName": "首页",
"href": "https://ryanc.cc/",
"priority": 0,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:19:37.252228Z",
"name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
"version": 12,
},
"spec": {
"children": [
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-07-28T06:50:32.777556Z",
"name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
"version": 4,
},
"spec": {
"children": [],
"displayName": "Halo",
"href": "https://ryanc.cc/categories/halo",
"priority": 0,
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
"version": 1,
},
"spec": {
"children": [
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
"version": 1,
},
"spec": {
"children": [],
"displayName": "Spring Boot",
"href": "https://ryanc.cc/categories/spring-boot",
"priority": 0,
},
},
],
"displayName": "Java",
"href": "https://ryanc.cc/categories/java",
"priority": 1,
},
},
],
"displayName": "文章分类",
"href": "https://ryanc.cc/categories",
"priority": 1,
},
},
]
`;
exports[`sortMenuItemsTree > will match snapshot 1`] = `
[
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:19:37.252228Z",
"name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
"version": 12,
},
"spec": {
"categoryRef": {
"name": "",
},
"children": [
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-08-05T04:22:03.377364Z",
"name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
"version": 0,
},
"spec": {
"categoryRef": {
"name": "",
},
"children": [],
"displayName": "Java",
"href": "https://ryanc.cc/categories/java",
"pageRef": {
"name": "",
},
"postRef": {
"name": "",
},
"priority": 0,
"tagRef": {
"name": "",
},
},
},
{
"apiVersion": "v1alpha1",
"kind": "MenuItem",
"metadata": {
"creationTimestamp": "2022-07-28T06:50:32.777556Z",
"name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
"version": 4,
},
"spec": {
"categoryRef": {
"name": "",
},
"children": [],
"displayName": "Halo",
"href": "https://ryanc.cc/categories/halo",
"pageRef": {
"name": "",
},
"postRef": {
"name": "",
},
"priority": 1,
"tagRef": {
"name": "",
},
},
},
],
"displayName": "文章分类",
"href": "https://ryanc.cc/categories",
"pageRef": {
"name": "",
},
"postRef": {
"name": "",
},
"priority": 0,
"tagRef": {
"name": "",
},
},
},
]
`;

View File

@ -0,0 +1,269 @@
import { describe, expect, it } from "vitest";
import type { MenuTreeItem } from "../index";
import {
buildMenuItemsTree,
convertMenuTreeItemToMenuItem,
convertTreeToMenuItems,
getChildrenNames,
resetMenuItemsTreePriority,
sortMenuItemsTree,
} from "../index";
import type { MenuItem } from "@halo-dev/api-client";
const rawMenuItems: MenuItem[] = [
{
spec: {
displayName: "文章分类",
href: "https://ryanc.cc/categories",
// @ts-ignore
children: [
"caeef383-3828-4039-9114-6f9ad3b4a37e",
"ded1943d-9fdb-4563-83ee-2f04364872e0",
],
priority: 1,
},
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
name: "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
version: 12,
creationTimestamp: "2022-08-05T04:19:37.252228Z",
},
},
{
spec: {
displayName: "Halo",
href: "https://ryanc.cc/categories/halo",
// @ts-ignore
children: [],
priority: 0,
},
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
name: "caeef383-3828-4039-9114-6f9ad3b4a37e",
version: 4,
creationTimestamp: "2022-07-28T06:50:32.777556Z",
},
},
{
spec: {
displayName: "Java",
href: "https://ryanc.cc/categories/java",
// @ts-ignore
children: ["96b636bb-3e4a-44d1-8ea7-f9da9e876f45"],
priority: 1,
},
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
name: "ded1943d-9fdb-4563-83ee-2f04364872e0",
version: 1,
creationTimestamp: "2022-08-05T04:22:03.377364Z",
},
},
{
spec: {
displayName: "Spring Boot",
href: "https://ryanc.cc/categories/spring-boot",
// @ts-ignore
children: [],
priority: 0,
},
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
name: "96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
version: 1,
creationTimestamp: "2022-08-05T04:22:03.377364Z",
},
},
{
spec: {
displayName: "首页",
href: "https://ryanc.cc/",
// @ts-ignore
children: [],
priority: 0,
},
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
name: "411a3639-bf0d-4266-9cfb-14184259dab5",
version: 1,
creationTimestamp: "2022-08-05T04:22:03.377364Z",
},
},
];
describe("buildMenuItemsTree", () => {
it("should match snapshot", () => {
expect(buildMenuItemsTree(rawMenuItems)).toMatchSnapshot();
});
it("should be sorted correctly", () => {
const menuItems = buildMenuItemsTree(rawMenuItems);
expect(menuItems[0].spec.priority).toBe(0);
expect(menuItems[1].spec.priority).toBe(1);
// children should be sorted
expect(menuItems[1].spec.children[0].spec.priority).toBe(0);
expect(menuItems[1].spec.children[1].spec.priority).toBe(1);
expect(menuItems[1].spec.children[1].spec.children[0].spec.priority).toBe(
0
);
expect(menuItems[0].spec.displayName).toBe("首页");
expect(menuItems[1].spec.displayName).toBe("文章分类");
expect(menuItems[1].spec.children[0].spec.displayName).toBe("Halo");
expect(menuItems[1].spec.children[1].spec.displayName).toBe("Java");
expect(
menuItems[1].spec.children[1].spec.children[0].spec.displayName
).toBe("Spring Boot");
});
});
describe("convertTreeToMenuItems", () => {
it("will match snapshot", function () {
const menuTreeItems = buildMenuItemsTree(rawMenuItems);
expect(convertTreeToMenuItems(menuTreeItems)).toMatchSnapshot();
});
});
describe("sortMenuItemsTree", () => {
it("will match snapshot", () => {
const tree: MenuTreeItem[] = [
{
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
creationTimestamp: "2022-08-05T04:19:37.252228Z",
name: "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
version: 12,
},
spec: {
categoryRef: {
name: "",
},
children: [
{
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
creationTimestamp: "2022-07-28T06:50:32.777556Z",
name: "caeef383-3828-4039-9114-6f9ad3b4a37e",
version: 4,
},
spec: {
categoryRef: {
name: "",
},
children: [],
priority: 1,
displayName: "Halo",
href: "https://ryanc.cc/categories/halo",
pageRef: {
name: "",
},
postRef: {
name: "",
},
tagRef: {
name: "",
},
},
},
{
apiVersion: "v1alpha1",
kind: "MenuItem",
metadata: {
creationTimestamp: "2022-08-05T04:22:03.377364Z",
name: "ded1943d-9fdb-4563-83ee-2f04364872e0",
version: 0,
},
spec: {
categoryRef: {
name: "",
},
children: [],
priority: 0,
displayName: "Java",
href: "https://ryanc.cc/categories/java",
pageRef: {
name: "",
},
postRef: {
name: "",
},
tagRef: {
name: "",
},
},
},
],
priority: 0,
displayName: "文章分类",
href: "https://ryanc.cc/categories",
pageRef: {
name: "",
},
postRef: {
name: "",
},
tagRef: {
name: "",
},
},
},
];
expect(sortMenuItemsTree(tree)).toMatchSnapshot();
});
});
describe("resetMenuItemsTreePriority", () => {
it("should match snapshot", function () {
expect(
resetMenuItemsTreePriority(buildMenuItemsTree(rawMenuItems))
).toMatchSnapshot();
});
});
describe("getChildrenNames", () => {
it("should return correct names", () => {
const menuTreeItems = buildMenuItemsTree(rawMenuItems);
expect(getChildrenNames(menuTreeItems[0])).toEqual([]);
expect(getChildrenNames(menuTreeItems[1])).toEqual([
"caeef383-3828-4039-9114-6f9ad3b4a37e",
"ded1943d-9fdb-4563-83ee-2f04364872e0",
"96b636bb-3e4a-44d1-8ea7-f9da9e876f45",
]);
expect(getChildrenNames(menuTreeItems[1].spec.children[0])).toEqual([]);
});
});
describe("convertMenuTreeItemToMenuItem", () => {
it("should match snapshot", () => {
const menuTreeItems = buildMenuItemsTree(rawMenuItems);
expect(convertMenuTreeItemToMenuItem(menuTreeItems[1])).toMatchSnapshot();
expect(
convertMenuTreeItemToMenuItem(menuTreeItems[1].spec.children[1])
).toMatchSnapshot();
});
it("should return correct MenuItem", () => {
const menuTreeItems = buildMenuItemsTree(rawMenuItems);
expect(
convertMenuTreeItemToMenuItem(menuTreeItems[1]).spec.displayName
).toBe("文章分类");
expect(
convertMenuTreeItemToMenuItem(menuTreeItems[1]).spec.children
).toStrictEqual(
new Set([
"caeef383-3828-4039-9114-6f9ad3b4a37e",
"ded1943d-9fdb-4563-83ee-2f04364872e0",
])
);
});
});

View File

@ -0,0 +1,267 @@
import type { MenuItem, MenuItemSpec } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
export interface MenuTreeItemSpec extends Omit<MenuItemSpec, "children"> {
children: MenuTreeItem[];
}
export interface MenuTreeItem extends Omit<MenuItem, "spec"> {
spec: MenuTreeItemSpec;
}
/**
* Convert a flat array of menu items into flattens a menu tree.
*
* Example:
*
* ```json
* [
* {
* "spec": {
* "displayName": "文章分类",
* "href": "https://ryanc.cc/categories",
* "children": [
* "caeef383-3828-4039-9114-6f9ad3b4a37e",
* "ded1943d-9fdb-4563-83ee-2f04364872e0"
* ]
* },
* "apiVersion": "v1alpha1",
* "kind": "MenuItem",
* "metadata": {
* "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
* "version": 12,
* "creationTimestamp": "2022-08-05T04:19:37.252228Z"
* }
* },
* {
* "spec": {
* "displayName": "Halo",
* "href": "https://ryanc.cc/categories/halo",
* "children": []
* },
* "apiVersion": "v1alpha1",
* "kind": "MenuItem",
* "metadata": {
* "name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
* "version": 4,
* "creationTimestamp": "2022-07-28T06:50:32.777556Z"
* }
* },
* {
* "spec": {
* "displayName": "Java",
* "href": "https://ryanc.cc/categories/java",
* "children": []
* },
* "apiVersion": "v1alpha1",
* "kind": "MenuItem",
* "metadata": {
* "name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
* "version": 1,
* "creationTimestamp": "2022-08-05T04:22:03.377364Z"
* }
* }
* ]
* ```
*
* will be transformed to:
*
* ```json
* [
* {
* "spec": {
* "displayName": "文章分类",
* "href": "https://ryanc.cc/categories",
* "children": [
* {
* "spec": {
* "displayName": "Halo",
* "href": "https://ryanc.cc/categories/halo",
* "children": []
* },
* "apiVersion": "v1alpha1",
* "kind": "MenuItem",
* "metadata": {
* "name": "caeef383-3828-4039-9114-6f9ad3b4a37e",
* "version": 4,
* "creationTimestamp": "2022-07-28T06:50:32.777556Z"
* }
* },
* {
* "spec": {
* "displayName": "Java",
* "href": "https://ryanc.cc/categories/java",
* "children": []
* },
* "apiVersion": "v1alpha1",
* "kind": "MenuItem",
* "metadata": {
* "name": "ded1943d-9fdb-4563-83ee-2f04364872e0",
* "version": 1,
* "creationTimestamp": "2022-08-05T04:22:03.377364Z"
* }
* }
* ]
* },
* "apiVersion": "v1alpha1",
* "kind": "MenuItem",
* "metadata": {
* "name": "40e4ba86-5c7e-43c2-b4c0-cee376d26564",
* "version": 12,
* "creationTimestamp": "2022-08-05T04:19:37.252228Z"
* }
* }
* ]
* ```
*
* @param menuItems
*/
export function buildMenuItemsTree(menuItems: MenuItem[]): MenuTreeItem[] {
const menuItemsToUpdate = cloneDeep(menuItems);
const menuItemsMap = {};
const parentMap = {};
menuItemsToUpdate.forEach((menuItem) => {
menuItemsMap[menuItem.metadata.name] = menuItem;
// @ts-ignore
menuItem.spec.children.forEach((child) => {
parentMap[child] = menuItem.metadata.name;
});
// @ts-ignore
menuItem.spec.children = [];
});
menuItemsToUpdate.forEach((menuItem) => {
const parentName = parentMap[menuItem.metadata.name];
if (parentName && menuItemsMap[parentName]) {
menuItemsMap[parentName].spec.children.push(menuItem);
}
});
const menuTreeItems = menuItemsToUpdate.filter(
(node) => parentMap[node.metadata.name] === undefined
);
return sortMenuItemsTree(menuTreeItems);
}
/**
* Sort a menu tree by priority.
*
* @param menuTreeItems
*/
export function sortMenuItemsTree(
menuTreeItems: MenuTreeItem[] | MenuItem[]
): MenuTreeItem[] {
return menuTreeItems
.sort((a, b) => {
if (a.spec.priority < b.spec.priority) {
return -1;
}
if (a.spec.priority > b.spec.priority) {
return 1;
}
return 0;
})
.map((menuTreeItem) => {
if (menuTreeItem.spec.children.length) {
return {
...menuTreeItem,
spec: {
...menuTreeItem.spec,
children: sortMenuItemsTree(menuTreeItem.spec.children),
},
};
}
return menuTreeItem;
});
}
/**
* Reset the menu tree item's priority.
*
* @param menuItems
*/
export function resetMenuItemsTreePriority(
menuItems: MenuTreeItem[]
): MenuTreeItem[] {
for (let i = 0; i < menuItems.length; i++) {
menuItems[i].spec.priority = i;
if (menuItems[i].spec.children) {
resetMenuItemsTreePriority(menuItems[i].spec.children);
}
}
return menuItems;
}
/**
* Convert a menu tree items into a flat array of menu.
*
* @param menuTreeItems
*/
export function convertTreeToMenuItems(menuTreeItems: MenuTreeItem[]) {
const menuItems: MenuItem[] = [];
const menuItemsMap = new Map<string, MenuItem>();
const convertMenuItem = (node: MenuTreeItem | undefined) => {
if (!node) {
return;
}
const children = node.spec.children || [];
menuItemsMap.set(node.metadata.name, {
...node,
spec: {
...node.spec,
// @ts-ignore
children: children.map((child) => child.metadata.name),
},
});
children.forEach((child) => {
convertMenuItem(child);
});
};
menuTreeItems.forEach((node) => {
convertMenuItem(node);
});
menuItemsMap.forEach((node) => {
menuItems.push(node);
});
return menuItems;
}
export function getChildrenNames(menuTreeItem: MenuTreeItem): string[] {
const childrenNames: string[] = [];
function getChildrenNamesRecursive(menuTreeItem: MenuTreeItem) {
if (menuTreeItem.spec.children) {
menuTreeItem.spec.children.forEach((child) => {
childrenNames.push(child.metadata.name);
getChildrenNamesRecursive(child);
});
}
}
getChildrenNamesRecursive(menuTreeItem);
return childrenNames;
}
/**
* Convert {@link MenuTreeItem} to {@link MenuItem} with flat children name array.
*
* @param menuTreeItem
*/
export function convertMenuTreeItemToMenuItem(
menuTreeItem: MenuTreeItem
): MenuItem {
const childNames = menuTreeItem.spec.children.map(
(child) => child.metadata.name
);
return {
...menuTreeItem,
spec: {
...menuTreeItem.spec,
children: new Set(childNames),
},
};
}