feat: The menu supports drag and drop sorting (#10626)

This commit is contained in:
2025-10-14 16:51:15 +08:00
committed by GitHub
parent dde40064d2
commit f088cec4d5
7 changed files with 315 additions and 28 deletions
+6
View File
@@ -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"`
+40
View File
@@ -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 {
+1
View File
@@ -25,6 +25,7 @@ func Init() {
migrations.AddSimpleNodeGroup,
migrations.AddUpgradeBackupCopies,
migrations.AddScriptSync,
migrations.UpdateXpackHideMenuSort,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)
+47
View File
@@ -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
},
}
@@ -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<string, number> {
const map = new Map<string, number>();
list.forEach((m, i) => map.set(m.label, i));
return map;
}
function sortByMap(map: Map<string, number>) {
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();
+17
View File
@@ -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);
}
@@ -1,19 +1,34 @@
<template>
<DrawerPro v-model="drawerVisible" :header="$t('setting.menuSetting')" @close="handleClose" size="small">
<DrawerPro v-model="drawerVisible" :header="$t('setting.menuSetting')" @close="handleClose" size="normal">
<el-alert :closable="false" :title="$t('setting.menuSettingHelper')" type="warning" />
<ComplexTable :heightDiff="1" :data="treeData.hideMenu" :show-header="false" row-key="id">
<el-table-column prop="title" :label="$t('setting.menu')">
<template #default="{ row }">
{{ i18n.global.t(row.title) }}
</template>
</el-table-column>
<el-table-column prop="isShow" :label="$t('setting.ifShow')">
<template #default="{ row }">
<el-switch v-if="!row.disabled" v-model="row.isShow" @change="onChangeShow(row)" />
<span v-else>-</span>
</template>
</el-table-column>
</ComplexTable>
<el-tree
:data="treeData.hideMenu"
:allow-drag="allowDrag"
:allow-drop="allowDrop"
draggable
node-key="id"
class="mt-3"
:icon="ArrowRight"
@node-drop="handleDrop"
>
<template #default="{ node, data }">
<div class="grid grid-cols-4 gap-4 items-center w-full py-2 group">
<span class="col-span-2" :style="{ paddingLeft: `${(node.level - 1) * 16}px` }">
{{ i18n.global.t(data.title) }}
</span>
<span class="flex justify-center w-[60px]">
<el-switch v-if="!data.disabled" v-model="data.isShow" @change="onChangeShow(data)" />
<span v-else>-</span>
</span>
<span
class="text-right hidden cursor-move"
:class="data.label == 'Home-Menu' || data.children?.length > 0 ? '' : 'group-hover:block'"
>
</span>
</div>
</template>
</el-tree>
<template #footer>
<span class="dialog-footer">
<el-button @click="drawerVisible = false">{{ $t('commons.button.cancel') }}</el-button>
@@ -27,11 +42,13 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { AllowDropType, ElMessageBox, RenderContentContext } from 'element-plus';
import i18n from '@/lang';
import { updateMenu } from '@/api/modules/setting';
import { MsgSuccess } from '@/utils/message';
import { GlobalStore } from '@/store';
import { ArrowRight } from '@element-plus/icons-vue';
import { sortMenu } from '@/utils/util';
const globalStore = GlobalStore();
const drawerVisible = ref();
@@ -39,13 +56,84 @@ const loading = ref();
interface DialogProps {
hideMenu: string;
}
const acceptParams = (params: DialogProps): void => {
drawerVisible.value = true;
treeData.hideMenu = JSON.parse(params.hideMenu) || [];
let hideMenu = JSON.parse(params.hideMenu);
sortMenu(hideMenu);
treeData.hideMenu = hideMenu;
if (globalStore.isIntl) {
treeData.hideMenu = removeXAlertDashboard(treeData.hideMenu);
}
};
type Node = RenderContentContext['node'];
const allowDrag = (draggingNode: Node) => {
return !draggingNode.data.label.includes('Home-Menu') || draggingNode.level < 3;
};
const allowDrop = (draggingNode: Node, dropNode: Node, type: AllowDropType) => {
if (dropNode.data.label === 'Home-Menu') {
return type !== 'prev' && type !== 'inner';
}
const draggingHasChildren = draggingNode.childNodes?.length > 0;
const isDraggingTooDeep = draggingNode.level > 2;
const isDraggingFirstLevel = draggingNode.level === 1;
const isDropFirstLevel = dropNode.level === 1;
const isDropSecondLevel = dropNode.level === 2;
const dropHasChildren = dropNode.childNodes?.length > 0;
if (draggingNode.parent && draggingNode.parent.childNodes.length === 1) {
return false;
}
if (isDropFirstLevel && dropHasChildren) {
return type === 'inner' || type === 'prev' || type === 'next';
}
if (isDraggingFirstLevel && draggingNode.childNodes?.length === 0 && dropNode.level !== 2) {
return type === 'prev' || type === 'next';
}
if (isDraggingFirstLevel && draggingNode.childNodes?.length > 0 && dropNode.level !== 2) {
return type === 'inner' || type === 'prev' || type === 'next';
}
if (draggingHasChildren || isDraggingTooDeep) {
return false;
}
if (isDropSecondLevel && draggingNode.level === 2) {
return type === 'prev' || type === 'next';
}
return false;
};
const handleDrop = (draggingNode: Node, dropNode: Node) => {
const siblingNodes = dropNode.level == 2 ? dropNode.parent.parent.data : dropNode.parent.data;
siblingNodes.forEach((node, index) => {
node.sort = (index + 1) * 100;
});
const updateChildSort = (nodes) => {
nodes.forEach((node, index) => {
node.sort = (index + 1) * 100;
if (node.children && node.children.length) {
updateChildSort(node.children);
}
});
};
if (siblingNodes.length) {
siblingNodes.forEach((node) => {
if (node.children && node.children.length) {
updateChildSort(node.children);
}
});
}
};
const treeData = reactive({
hideMenu: [],
@@ -118,3 +206,13 @@ defineExpose({
acceptParams,
});
</script>
<style scoped lang="scss">
:deep(.el-tree) {
--el-tree-node-content-height: 26px;
font-size: 16px;
}
:deep(.el-tree-node__content) {
padding: 8px 8px !important;
border-bottom: var(--panel-border);
}
</style>