From f088cec4d5e9473f27dee0db58d3b248c0532a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=98=AD?= <81747598+lan-yonghui@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:51:15 +0800 Subject: [PATCH] feat: The menu supports drag and drop sorting (#10626) --- core/app/dto/setting.go | 6 + core/init/migration/helper/menu.go | 40 ++++++ core/init/migration/migrate.go | 1 + core/init/migration/migrations/init.go | 47 +++++++ .../src/layout/components/Sidebar/index.vue | 102 ++++++++++++-- frontend/src/utils/util.ts | 17 +++ .../views/setting/panel/hidemenu/index.vue | 130 +++++++++++++++--- 7 files changed, 315 insertions(+), 28 deletions(-) diff --git a/core/app/dto/setting.go b/core/app/dto/setting.go index 4f458b96d..76aaab812 100644 --- a/core/app/dto/setting.go +++ b/core/app/dto/setting.go @@ -204,9 +204,15 @@ type ShowMenu struct { IsShow bool `json:"isShow"` Title string `json:"title"` Path string `json:"path,omitempty"` + Sort int `json:"sort"` Children []ShowMenu `json:"children,omitempty"` } +type MenuLabelSort struct { + Label string `json:"label"` + Sort int `json:"sort"` +} + type ApiInterfaceConfig struct { ApiInterfaceStatus string `json:"apiInterfaceStatus"` ApiKey string `json:"apiKey"` diff --git a/core/init/migration/helper/menu.go b/core/init/migration/helper/menu.go index af16f778a..04d41e3a4 100644 --- a/core/init/migration/helper/menu.go +++ b/core/init/migration/helper/menu.go @@ -57,6 +57,46 @@ func LoadMenus() string { return string(menu) } +func MenuSort() []dto.MenuLabelSort { + var MenuLabelsWithSort = []dto.MenuLabelSort{ + {Label: "Home-Menu", Sort: 100}, + {Label: "App-Menu", Sort: 200}, + {Label: "Website-Menu", Sort: 300}, + {Label: "Website", Sort: 100}, + {Label: "SSL", Sort: 200}, + {Label: "PHP", Sort: 300}, + {Label: "AI-Menu", Sort: 400}, + {Label: "OllamaModel", Sort: 100}, + {Label: "MCPServer", Sort: 200}, + {Label: "GPU", Sort: 300}, + {Label: "Database-Menu", Sort: 500}, + {Label: "Container-Menu", Sort: 600}, + {Label: "System-Menu", Sort: 700}, + {Label: "File", Sort: 100}, + {Label: "Monitorx", Sort: 200}, + {Label: "FirewallPort", Sort: 300}, + {Label: "Process", Sort: 400}, + {Label: "SSH", Sort: 500}, + {Label: "Disk", Sort: 600}, + {Label: "Terminal-Menu", Sort: 800}, + {Label: "Cronjob-Menu", Sort: 900}, + {Label: "Toolbox-Menu", Sort: 1000}, + {Label: "Xpack-Menu", Sort: 1100}, + {Label: "XApp", Sort: 100}, + {Label: "Dashboard", Sort: 200}, + {Label: "Node", Sort: 300}, + {Label: "Upage", Sort: 400}, + {Label: "MonitorDashboard", Sort: 500}, + {Label: "Tamper", Sort: 600}, + {Label: "Cluster", Sort: 700}, + {Label: "FileExange", Sort: 800}, + {Label: "XSetting", Sort: 900}, + {Label: "Log-Menu", Sort: 1200}, + {Label: "Setting-Menu", Sort: 1300}, + } + return MenuLabelsWithSort +} + func AddMenu(newMenu dto.ShowMenu, parentMenuID string, tx *gorm.DB) error { var menuJSON string if err := tx.Model(&model.Setting{}).Where("key = ?", "HideMenu").Pluck("value", &menuJSON).Error; err != nil { diff --git a/core/init/migration/migrate.go b/core/init/migration/migrate.go index 7224a778a..cc21b2916 100644 --- a/core/init/migration/migrate.go +++ b/core/init/migration/migrate.go @@ -25,6 +25,7 @@ func Init() { migrations.AddSimpleNodeGroup, migrations.AddUpgradeBackupCopies, migrations.AddScriptSync, + migrations.UpdateXpackHideMenuSort, }) if err := m.Migrate(); err != nil { global.LOG.Error(err) diff --git a/core/init/migration/migrations/init.go b/core/init/migration/migrations/init.go index 60cf269eb..5117a07f6 100644 --- a/core/init/migration/migrations/init.go +++ b/core/init/migration/migrations/init.go @@ -555,3 +555,50 @@ var AddScriptSync = &gormigrate.Migration{ return nil }, } + +var UpdateXpackHideMenuSort = &gormigrate.Migration{ + ID: "20251009-update-xpack-hide-menu-sort", + Migrate: func(tx *gorm.DB) error { + var menuJSON string + if err := tx.Model(&model.Setting{}).Where("key = ?", "HideMenu").Pluck("value", &menuJSON).Error; err != nil { + return err + } + + var menus []dto.ShowMenu + if err := json.Unmarshal([]byte(menuJSON), &menus); err != nil { + return tx.Model(&model.Setting{}). + Where("key = ?", "HideMenu"). + Update("value", helper.LoadMenus()).Error + } + + labelSortMap := make(map[string]int) + for _, item := range helper.MenuSort() { + labelSortMap[item.Label] = item.Sort + } + + for i := range menus { + if sortVal, exists := labelSortMap[menus[i].Label]; exists { + menus[i].Sort = sortVal + } else if menus[i].Sort <= 0 { + menus[i].Sort = (i + 1) * 100 + } + + for j := range menus[i].Children { + if childSort, exists := labelSortMap[menus[i].Children[j].Label]; exists { + menus[i].Children[j].Sort = childSort + } else if menus[i].Children[j].Sort <= 0 { + menus[i].Children[j].Sort = menus[i].Sort + (j+1)*10 + } + } + } + + updatedJSON, err := json.Marshal(menus) + if err != nil { + return tx.Model(&model.Setting{}). + Where("key = ?", "HideMenu"). + Update("value", helper.LoadMenus()).Error + } + + return tx.Model(&model.Setting{}).Where("key = ?", "HideMenu").Update("value", string(updatedJSON)).Error + }, +} diff --git a/frontend/src/layout/components/Sidebar/index.vue b/frontend/src/layout/components/Sidebar/index.vue index f7178c0be..bb7cea311 100644 --- a/frontend/src/layout/components/Sidebar/index.vue +++ b/frontend/src/layout/components/Sidebar/index.vue @@ -39,6 +39,7 @@ import { GlobalStore, MenuStore } from '@/store'; import { isString } from '@vueuse/core'; import { getSettingInfo } from '@/api/modules/setting'; import PrimaryMenu from '@/assets/images/menu-bg.svg?component'; +import { sortMenu } from '@/utils/util'; const route = useRoute(); const menuStore = MenuStore(); @@ -89,10 +90,16 @@ const openTask = () => { const search = async () => { const res = await getSettingInfo(); version.value = res.data.systemVersion; - const menuItem = JSON.parse(res.data.hideMenu); + let hideMenu = JSON.parse(res.data.hideMenu); + sortMenu(hideMenu); const showMap = new Map(); - getCheckedLabels(menuItem, showMap); + getCheckedLabels(hideMenu, showMap); + const rootMap = new Map(); + hideMenu.forEach((m, index) => { + rootMap.set(m.label, index); + }); let rstMenuList: RouteRecordRaw[] = []; + let resMenuList: RouteRecordRaw[] = []; for (const menu of menuStore.menuList) { let menuItem = JSON.parse(JSON.stringify(menu)); if (!showMap[menuItem.name]) { @@ -100,24 +107,95 @@ const search = async () => { } else if (menuItem.name === 'Xpack-Menu') { menuItem.meta.hideInSidebar = false; } - let itemChildren = []; - for (const item of menuItem.children) { - if (item.name === 'XAlertDashboard' && globalStore.isIntl) { - continue; - } - if (showMap[item.name]) { - itemChildren.push(item); - } - } + const childMenu = hideMenu.find((item) => item.label == menu.name); + const childMap = buildIndexMap(childMenu?.children || []); + const itemChildren = + (menuItem.children ?? []) + .filter( + (item) => + item.name && + showMap[item.name as string] && + !(item.name === 'XAlertDashboard' && globalStore.isIntl), + ) + .sort(sortByMap(childMap)) || []; + if (itemChildren.length === 1) { menuItem.meta.title = itemChildren[0].meta.title; } menuItem.children = itemChildren; rstMenuList.push(menuItem); } - menuStore.menuList = rstMenuList; + rstMenuList.sort((a, b) => { + const labelA = a.name; + const labelB = b.name; + const indexA = rootMap.get(labelA) ?? Infinity; + const indexB = rootMap.get(labelB) ?? Infinity; + return indexA - indexB; + }); + resMenuList = adjustAndCleanMenu(hideMenu, rstMenuList); + menuStore.menuList = resMenuList; }; +function buildIndexMap(list: any[]): Map { + const map = new Map(); + list.forEach((m, i) => map.set(m.label, i)); + return map; +} + +function sortByMap(map: Map) { + return (a: { name: string }, b: { name: string }) => { + const indexA = map.get(a.name) ?? Infinity; + const indexB = map.get(b.name) ?? Infinity; + return indexA - indexB; + }; +} + +function adjustAndCleanMenu(menuItem, list) { + const cleanList = JSON.parse(JSON.stringify(list)); + const orderMap = new Map(); + menuItem.forEach((item, index) => { + orderMap.set(item.label, index); + }); + const newRootList = []; + const itemMap = new Map(); + cleanList.forEach((parent) => { + if (!parent.children) { + parent.children = []; + } + itemMap.set(parent.name, parent); + parent.children.forEach((child) => { + itemMap.set(child.name, child); + }); + }); + + menuItem.forEach((refItem) => { + const refName = refItem.label; + if (orderMap.has(refName)) { + const targetItem = itemMap.get(refName); + + if (targetItem) { + newRootList.push(targetItem); + for (const parent of cleanList) { + if (parent.children && parent.children.length > 0) { + const originalLength = parent.children.length; + parent.children = parent.children.filter((child) => child.name !== refName); + if (parent.children.length < originalLength) { + break; + } + } + } + } + } + }); + newRootList.sort((a, b) => { + const indexA = orderMap.get(a.name) ?? Infinity; + const indexB = orderMap.get(b.name) ?? Infinity; + return indexA - indexB; + }); + + return newRootList; +} + onMounted(() => { menuStore.setMenuList(menuList); search(); diff --git a/frontend/src/utils/util.ts b/frontend/src/utils/util.ts index 852ed66fc..ab97687b2 100644 --- a/frontend/src/utils/util.ts +++ b/frontend/src/utils/util.ts @@ -868,3 +868,20 @@ type ConvertType = (typeof convertTypes)[number]; export function isConvertible(extension: string, mimeType: string): boolean { return convertTypes.includes(getFileType(extension) as ConvertType) && /^(image|audio|video)\//.test(mimeType); } + +function compareById(a, b) { + return a.sort - b.sort; +} + +export function sortMenu(arr) { + if (!arr || arr.length === 0) { + return; + } + arr.forEach((item) => { + if (item.children && Array.isArray(item.children)) { + item.children.sort(compareById); + } + }); + + arr.sort(compareById); +} diff --git a/frontend/src/views/setting/panel/hidemenu/index.vue b/frontend/src/views/setting/panel/hidemenu/index.vue index 2b259ea1b..0b814c1d5 100644 --- a/frontend/src/views/setting/panel/hidemenu/index.vue +++ b/frontend/src/views/setting/panel/hidemenu/index.vue @@ -1,19 +1,34 @@