Browse Source

feat: 菜单管理支持拖拽排序

后端新增 PUT /menu/sort 批量排序接口,前端使用 SortableJS
实现表格行拖拽,同级菜单间自由调整顺序,拖放后自动保存。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 1 tuần trước cách đây
mục cha
commit
d2a868796f

+ 5 - 0
haha-admin-web/src/api/system.ts

@@ -59,6 +59,11 @@ export const deleteMenuApi = (id: number) => {
   return http.request<Result>("delete", `/menu/${id}`);
 };
 
+/** 批量更新菜单排序 */
+export const updateMenuSort = (data: { id: number; rank: number }[]) => {
+  return http.request<Result>("put", "/menu/sort", { data });
+};
+
 /** 获取系统管理-部门管理列表 */
 export const getDeptList = (data?: object) => {
   return http.request<Result>("post", "/dept", { data });

+ 32 - 2
haha-admin-web/src/views/system/menu/index.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref, watch, nextTick, onBeforeUnmount } from "vue";
 import { useMenu } from "./utils/hook";
 import { transformI18n } from "@/plugins/i18n";
 import { PureTableBar } from "@/components/RePureTableBar";
@@ -25,13 +25,29 @@ const {
   resetForm,
   openDialog,
   handleDelete,
-  handleSelectionChange
+  handleSelectionChange,
+  initDragSort,
+  destroyDragSort
 } = useMenu();
 
 function onFullscreen() {
   // 重置表格高度
   tableRef.value.setAdaptive();
 }
+
+watch(
+  () => dataList.value,
+  () => {
+    nextTick(() => {
+      const el = tableRef.value?.getTableRef()?.$el;
+      if (el) initDragSort(el);
+    });
+  }
+);
+
+onBeforeUnmount(() => {
+  destroyDragSort();
+});
 </script>
 
 <template>
@@ -160,4 +176,18 @@ function onFullscreen() {
     margin-bottom: 12px;
   }
 }
+
+:deep(.sortable-ghost) {
+  opacity: 0.4;
+  background: var(--el-color-primary-light-9);
+}
+
+:deep(.drag-handle) {
+  cursor: grab;
+  user-select: none;
+}
+
+:deep(.drag-handle:active) {
+  cursor: grabbing;
+}
 </style>

+ 129 - 6
haha-admin-web/src/views/system/menu/utils/hook.tsx

@@ -1,13 +1,14 @@
 import editForm from "../form.vue";
 import { handleTree } from "@/utils/tree";
 import { message } from "@/utils/message";
-import { getMenuList, createMenu, updateMenu, deleteMenuApi } from "@/api/system";
+import { getMenuList, createMenu, updateMenu, deleteMenuApi, updateMenuSort } from "@/api/system";
 import { transformI18n } from "@/plugins/i18n";
 import { addDialog } from "@/components/ReDialog";
 import { reactive, ref, onMounted, h } from "vue";
 import type { FormItemProps } from "../utils/types";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 import { cloneDeep, isAllEmpty, deviceDetection } from "@pureadmin/utils";
+import Sortable from "sortablejs";
 
 export function useMenu() {
   const form = reactive({
@@ -17,6 +18,36 @@ export function useMenu() {
   const formRef = ref();
   const dataList = ref([]);
   const loading = ref(true);
+  const flatDataMap = ref(new Map<string | number, any>());
+  const flatOrderedList = ref<any[]>([]);
+  let sortableInstance: Sortable | null = null;
+  let draggedDataCache: any = null;
+
+  /** 深度优先遍历树,返回与 DOM 行顺序一致的扁平列表 */
+  function buildFlatOrdered(tree: any[]): any[] {
+    const result: any[] = [];
+    function walk(nodes: any[]) {
+      for (const node of nodes) {
+        result.push(node);
+        if (node.children?.length > 0) {
+          walk(node.children);
+        }
+      }
+    }
+    walk(tree);
+    return result;
+  }
+
+  /** 给表格每行打上 data-id 属性,供拖拽时识别菜单 */
+  function tagRowIds(tableEl: HTMLElement) {
+    const rows = tableEl.querySelectorAll(".el-table__body-wrapper .el-table__row");
+    const list = flatOrderedList.value;
+    for (let i = 0; i < rows.length; i++) {
+      if (i < list.length) {
+        rows[i].setAttribute("data-id", String(list[i].id));
+      }
+    }
+  }
 
   const getMenuType = (type, text = false) => {
     switch (type) {
@@ -38,6 +69,16 @@ export function useMenu() {
       align: "left",
       cellRenderer: ({ row }) => (
         <>
+          <span class="drag-handle inline-flex align-middle mr-1" style="cursor: grab; color: #909399; user-select: none; vertical-align: middle;">
+            <svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
+              <circle cx="4" cy="3" r="1.2"/>
+              <circle cx="8" cy="3" r="1.2"/>
+              <circle cx="4" cy="8" r="1.2"/>
+              <circle cx="8" cy="8" r="1.2"/>
+              <circle cx="4" cy="13" r="1.2"/>
+              <circle cx="8" cy="13" r="1.2"/>
+            </svg>
+          </span>
           <span class="inline-block mr-1">
             {h(useRenderIcon(row.icon), {
               style: { paddingTop: "1px" }
@@ -107,14 +148,24 @@ export function useMenu() {
   async function onSearch() {
     loading.value = true;
     const { data } = await getMenuList(); // 这里是返回一维数组结构,前端自行处理成树结构,返回格式要求:唯一id加父节点parentId,parentId取父节点id
-    let newData = data;
+    // 构建 id -> 菜单项 的扁平映射表,供拖拽排序时查询
+    const map = new Map<string | number, any>();
+    for (const item of data) {
+      map.set(item.id, item);
+    }
+    flatDataMap.value = map;
+    // 构建树并缓存深度优先扁平列表(与 DOM 行顺序一致)
+    const fullTree = handleTree(data);
+    flatOrderedList.value = buildFlatOrdered(fullTree);
     if (!isAllEmpty(form.title)) {
-      // 前端搜索菜单名称
-      newData = newData.filter(item =>
+      // 前端搜索菜单名称:过滤扁平数据后重新构建树
+      const filtered = data.filter(item =>
         transformI18n(item.title).includes(form.title)
       );
+      dataList.value = handleTree(filtered);
+    } else {
+      dataList.value = fullTree;
     }
-    dataList.value = handleTree(newData); // 处理成树结构
     setTimeout(() => {
       loading.value = false;
     }, 500);
@@ -200,6 +251,74 @@ export function useMenu() {
     }
   }
 
+  function initDragSort(tableEl: HTMLElement) {
+    destroyDragSort();
+    const tbody = tableEl.querySelector(".el-table__body-wrapper tbody") as HTMLElement;
+    if (!tbody) return;
+
+    // 给行打上 data-id 属性
+    tagRowIds(tableEl);
+
+    sortableInstance = Sortable.create(tbody, {
+      handle: ".drag-handle",
+      draggable: ".el-table__row",
+      animation: 200,
+      ghostClass: "sortable-ghost",
+      onStart() {
+        draggedDataCache = null;
+      },
+      onMove(evt) {
+        const draggedId = evt.dragged.getAttribute("data-id");
+        const relatedId = evt.related.getAttribute("data-id");
+        const draggedData = flatDataMap.value.get(draggedId);
+        const relatedData = flatDataMap.value.get(relatedId);
+        if (!draggedData || !relatedData) return false;
+        if (!draggedDataCache) draggedDataCache = draggedData;
+        // 只允许同级拖拽
+        return draggedData.parentId === relatedData.parentId;
+      },
+      async onEnd() {
+        const draggedData = draggedDataCache;
+        if (!draggedData) return;
+
+        const parentId = draggedData.parentId;
+        // 从 DOM 新顺序中收集同级兄弟的 id(data-id 在 DOM 移动后跟着走了)
+        const allRows = tbody.querySelectorAll(".el-table__row");
+        const siblingIds: any[] = [];
+        for (const row of allRows) {
+          const id = row.getAttribute("data-id");
+          const data = flatDataMap.value.get(id);
+          if (data && data.parentId === parentId && !siblingIds.includes(id)) {
+            siblingIds.push(id);
+          }
+        }
+
+        if (siblingIds.length <= 1) return;
+
+        const sortData = siblingIds.map((id, index) => ({
+          id,
+          rank: index + 1
+        }));
+
+        try {
+          await updateMenuSort(sortData);
+          message("排序更新成功", { type: "success" });
+        } catch {
+          message("排序更新失败", { type: "error" });
+        }
+        draggedDataCache = null;
+        onSearch();
+      }
+    });
+  }
+
+  function destroyDragSort() {
+    if (sortableInstance) {
+      sortableInstance.destroy();
+      sortableInstance = null;
+    }
+  }
+
   onMounted(() => {
     onSearch();
   });
@@ -217,6 +336,10 @@ export function useMenu() {
     openDialog,
     /** 删除菜单 */
     handleDelete,
-    handleSelectionChange
+    handleSelectionChange,
+    /** 初始化拖拽排序 */
+    initDragSort,
+    /** 销毁拖拽排序 */
+    destroyDragSort
   };
 }

+ 23 - 0
haha-admin/src/main/java/com/haha/admin/controller/MenuController.java

@@ -116,6 +116,23 @@ public class MenuController {
         return Result.success(menuIds);
     }
 
+    /**
+     * 批量更新菜单排序
+     */
+    @RequirePermission("menu:update")
+    @Log(module = "菜单管理", operation = OperationType.UPDATE, summary = "批量更新菜单排序")
+    @PutMapping("/sort")
+    public Result<Void> updateSort(@RequestBody List<MenuSortDTO> sortList) {
+        List<Menu> menus = sortList.stream().map(dto -> {
+            Menu menu = new Menu();
+            menu.setId(dto.getId());
+            menu.setRank(dto.getRank());
+            return menu;
+        }).collect(Collectors.toList());
+        menuService.updateSort(menus);
+        return Result.success("排序更新成功");
+    }
+
     /**
      * 保存角色菜单关联
      */
@@ -161,6 +178,12 @@ public class MenuController {
         private Long id;
     }
 
+    @lombok.Data
+    public static class MenuSortDTO {
+        private Long id;
+        private Integer rank;
+    }
+
     @lombok.Data
     public static class SaveRoleMenuDTO {
         private Long roleId;

+ 7 - 0
haha-service/src/main/java/com/haha/service/MenuService.java

@@ -65,6 +65,13 @@ public interface MenuService extends IService<Menu> {
      */
     List<Long> getMenuIdsByRoleId(Long roleId);
 
+    /**
+     * 批量更新菜单排序
+     *
+     * @param menus 菜单列表(仅需 id 和 rank 字段)
+     */
+    void updateSort(List<Menu> menus);
+
     /**
      * 保存角色的菜单关联
      *

+ 18 - 0
haha-service/src/main/java/com/haha/service/impl/MenuServiceImpl.java

@@ -195,6 +195,24 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
         ).stream().map(RoleMenu::getMenuId).collect(Collectors.toList());
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateSort(List<Menu> menus) {
+        if (menus == null || menus.isEmpty()) {
+            return;
+        }
+        for (Menu menu : menus) {
+            if (menu.getId() != null && menu.getRank() != null) {
+                Menu update = new Menu();
+                update.setId(menu.getId());
+                update.setRank(menu.getRank());
+                update.setUpdateTime(LocalDateTime.now());
+                updateById(update);
+            }
+        }
+        log.info("批量更新菜单排序成功: {}条", menus.size());
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void saveRoleMenus(Long roleId, List<Long> menuIds) {