mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 08:00:53 +00:00
feat: The menu supports drag and drop sorting (#10626)
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ func Init() {
|
||||
migrations.AddSimpleNodeGroup,
|
||||
migrations.AddUpgradeBackupCopies,
|
||||
migrations.AddScriptSync,
|
||||
migrations.UpdateXpackHideMenuSort,
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
global.LOG.Error(err)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user