Vue3.5 企业级管理系统实战(十九):菜单管理

Vue3.5 企业级管理系统实战(十九):菜单管理

篇幅原因,本节先探讨菜单管理页面增删改查相关功能,角色菜单,菜单权限,动态菜单等内容放在后面。

1 菜单 api

在 src/api/menu.ts 中添加菜单 api,代码如下:

//src/api/menu.ts
import service from "./config/request";
import type { ApiResponse } from "./type";

export interface MenuData {
  id: number;
  title: string;
  path: string;
  icon: string;
  name: string;
  sort_id: number;
  parent_id: number;
}

//获取全部菜单
export const getAllMenus = (): Promise<ApiResponse<MenuData[]>> => {
  return service.get("/a***ess/menu");
};

//删除指定菜单
export const removeMenuById = (
  id: number
): Promise<ApiResponse<MenuData[]>> => {
  return service.delete("/a***ess/menu/" + id);
};

//新增菜单
export const addMenu = (data: MenuData): Promise<ApiResponse<MenuData>> => {
  return service.post("/a***ess/menu", data);
};

//更新指定菜单
export const updateMenuById = (
  id: number,
  data: Partial<MenuData>
): Promise<ApiResponse<MenuData[]>> => {
  return service.put("/a***ess/menu/" + id, data);
};

//批量更新菜单
export const updateBulkMenu = (
  data: Partial<MenuData>[]
): Promise<ApiResponse> => {
  return service.patch("/a***ess/menu/update", { a***ess: data });
};

2 菜单 Store

在 src/store/menu.ts 中添加菜单相关方法,代码如下:

//src/store/menu.ts
import {
  getAllMenus,
  type MenuData,
  addMenu,
  removeMenuById,
  updateMenuById,
  updateBulkMenu as updateBulkMenuApi
} from "@/api/menu";
import { getRoleA***essByRoles } from "@/api/roleA***ess";
import { generateTree, ITreeItemDataWithMenuData } from "@/utils/generateTree";

/**
 * 树形菜单项数据结构,继承自MenuData并扩展了子菜单属性
 */
export interface ITreeItemData extends MenuData {
  children?: ITreeItemData[];
}

/**
 * 菜单状态管理数据结构
 */
export interface IMenuState {
  menuList: Array<MenuData>; // 原始菜单列表数据
  menuTreeData: ITreeItemData[]; // 树形菜单数据(管理界面使用)
  authMenuList: MenuData[]; // 权限过滤后的菜单列表(侧边栏使用)
  authMenuTreeData: ITreeItemDataWithMenuData[]; // 权限过滤后的树形菜单数据(侧边栏使用)
}

/**
 * 菜单管理状态库
 * 使用Pinia实现的菜单状态管理,包含菜单的增删改查及权限控制
 */
export const useMenuStore = defineStore("menu", () => {
  // 定义响应式状态
  const state = reactive<IMenuState>({
    menuList: [],
    menuTreeData: [],
    authMenuList: [], // 侧边菜单需要的
    authMenuTreeData: []
  });

  /**
   * 获取全部菜单列表并转换为树形结构
   * 用于管理界面展示完整菜单树
   */
  const getAllMenuList = async () => {
    const res = await getAllMenus();
    if (res.code == 0) {
      const { data } = res;
      state.menuList = data; // 获取的原始菜单列表数据
      state.menuTreeData = generateTree(data); // 将原始数据转换为树形结构
    }
  };

  /**
   * 添加新菜单
   * @param data - 新菜单数据
   * @returns 添加成功返回true,失败返回false
   */
  const appendMenu = async (data: ITreeItemData) => {
    const res = await addMenu(data);
    if (res.code == 0) {
      const node = { ...res.data };
      state.menuList.push(node); // 添加到原始列表
      state.menuTreeData = generateTree(state.menuList); // 重新生成树形结构
      return true;
    }
  };

  /**
   * 删除菜单
   * @param data - 要删除的菜单数据(需包含id)
   * @returns 删除成功返回true,失败返回false
   */
  const removeMenu = async (data: ITreeItemData) => {
    const res = await removeMenuById(data.id);
    if (res.code == 0) {
      const idx = state.menuList.findIndex((menu) => menu.id === data.id);
      state.menuList.splice(idx, 1); // 从原始列表中删除
      state.menuTreeData = generateTree(state.menuList); // 重新生成树形结构
      return true;
    }
  };

  /**
   * 批量更新菜单(主要用于更新排序)
   * 1. 更新顶级菜单的sortId为其在数组中的索引位置
   * 2. 移除菜单对象中的children属性避免干扰后端数据
   * @returns 更新成功返回true,失败返回false
   */
  const updateBulkMenu = async () => {
    // 1.更新sortId
    state.menuTreeData.forEach((menu, idx) => (menu.sort_id = idx));
    // 2.删除子节点(后端存储不需要children字段)
    const menus = state.menuTreeData.map((menu) => {
      const temp = { ...menu };
      delete temp.children;
      return temp;
    });
    // 批量更新
    const res = await updateBulkMenuApi(menus);
    if (res.code == 0) {
      return true;
    }
  };

  /**
   * 更新单个菜单
   * @param data - 要更新的菜单数据(需包含id)
   * @returns 更新成功返回true,失败返回false
   */
  const updateMenu = async (data: Partial<MenuData>) => {
    const res = await updateMenuById(Number(data.id), data);
    if (res.code === 0) {
      await getAllMenuList(); // 更新成功后重新获取完整菜单列表
      return true;
    }
  };

  /**
   * 获取管理员权限的全部菜单
   * 用于管理员用户侧边栏显示
   */
  const getAllMenuListByAdmin = async () => {
    const res = await getAllMenus();
    if (res.code == 0) {
      const { data } = res;
      state.authMenuList = data; // 侧边栏菜单列表
      state.authMenuTreeData = generateTree(data, true); // 生成带权限标识的树形菜单
    }
  };

  /**
   * 根据角色获取对应的菜单权限
   * @param roles - 角色ID数组
   */
  const getMenuListByRoles = async (roles: number[]) => {
    const res = await getRoleA***essByRoles(roles);
    if (res.code == 0) {
      const { data } = res;
      const a***ess = data.a***ess;
      state.authMenuList = a***ess; // 侧边栏菜单列表(权限过滤后)
      state.authMenuTreeData = generateTree(a***ess, true); // 生成带权限标识的树形菜单
    }
  };

  // 导出状态和方法
  return {
    getAllMenuList,
    state,
    appendMenu,
    removeMenu,
    updateBulkMenu,
    updateMenu,
    getAllMenuListByAdmin,
    getMenuListByRoles
  };
});

3 封装生成 Tree 的方法

在 src/utils/generateTree.ts 中封装生成 Tree 的方法,代码如下:

//src/utils/generateTree.ts 
import type { MenuData } from "@/api/menu";
import type { ITreeItemData } from "@/stores/menu";

/**
 * 扩展树形菜单项数据结构,添加可选的meta元数据
 * 用于侧边栏菜单的路由配置和权限控制
 */
export type ITreeItemDataWithMenuData = ITreeItemData & {
  meta?: { icon: string; title: string; [key: string]: string };
};

/**
 * 映射表类型定义,用于快速查找节点
 * 键为菜单ID,值为树形菜单项数据
 */
export type IMap = Record<number, ITreeItemDataWithMenuData>;

/**
 * 将扁平的菜单列表转换为树形结构
 * @param list - 扁平的菜单数据列表
 * @param withMeta - 是否添加meta元数据,默认为false
 * @returns 树形结构的菜单数据
 */
export const generateTree = (list: MenuData[], withMeta: boolean = false) => {
  // 第一步:构建映射表,快速查找节点
  const map = list.reduce((memo, current) => {
    // 复制当前节点数据
    const temp = { ...current };

    // 如果需要元数据,添加meta字段
    if (withMeta) {
      (temp as ITreeItemDataWithMenuData).meta = {
        title: current.title, // 菜单标题
        icon: current.icon // 菜单图标
      };
    }

    // 将节点添加到映射表中
    memo[current.id] = temp;
    return memo;
  }, {} as IMap);

  // 第二步:构建树形结构
  const tree: ITreeItemDataWithMenuData[] = [];

  list.forEach((item) => {
    const pid = item.parent_id; // 当前节点的父ID
    const cur = map[item.id]; // 从映射表中获取当前节点

    // 如果存在父节点,则将当前节点添加到父节点的children中
    if (pid !== 0 || pid != null) {
      const parent = map[pid];

      if (parent) {
        const children = parent?.children || [];
        children.push(cur);
        parent.children = children;
        return;
      }
    }

    // 如果没有父节点或父节点不存在,则作为根节点
    tree.push(cur);
  });

  return tree;
};

4 菜单管理界面

4.1 刷新页面 hook 函数

在 src/hooks/useReloadPage.ts 中,封装刷新页面的钩子函数,代码如下:

//src/hooks/useReloadPage.ts
export const useReloadPage = () => {
  const { proxy } = getCurrentInstance()!;
  const reloadPage = async ({
    title = "是否刷新",
    message = "你确定"
  } = {}) => {
    try {
      await proxy!.$confirm(title, message);
      window.location.reload();
    } catch {
      proxy?.$message.warning("已经取消了刷新");
    }
  };
  return {
    reloadPage
  };
};

4.2 菜单管理页面

在 src/views/system/menu/index.vue 中添加菜单管理页面,代码如下:

//src/views/system/menu/index.vue
<template>
  <div class="menu-container">
    <!-- 菜单树展示区域 -->
    <el-card>
      <template #header>
        <el-button @click="handleCreateRootMenu">新增顶级菜单</el-button>
      </template>
      <div class="menu-tree">
        <!-- 可拖拽的树形菜单组件 -->
        <el-tree
          :data="menus"
          :props="defaultProps"
          @node-click="handleNodeClick"
          :expand-on-click-node="false"
          highlight-current
          draggable
          :allow-drop="allowDrop"
          :allow-drag="allowDrag"
          @node-drop="handleNodeDrop"
        >
          <!-- 自定义树节点内容,包含操作按钮 -->
          <template #default="{ node, data }">
            <p class="custom-item">
              <span>{{ data.title }} </span>
              <span>
                <el-button link @click="handleCreateChildMenu(data)"
                  >添加</el-button
                >
                <el-button link @click="handleRemoveMenu(data)">删除</el-button>
              </span>
            </p>
          </template>
        </el-tree>
      </div>
    </el-card>

    <!-- 菜单编辑区域 -->
    <el-card class="edit-card">
      <template #header> 编辑菜单 </template>
      <!-- 菜单编辑组件,选中节点后显示 -->
      <editor-menu
        v-show="editData && editData.id"
        :data="editData!"
        @updateEdit="handleUpdateEdit"
      />
      <span v-if="editData == null">从菜单列表选择一项后,进行编辑</span>
    </el-card>

    <!-- 右侧添加菜单面板 -->
    <right-panel v-model="panelVisible" :title="panelTitle" :size="330">
      <add-menu @submit="submitMenuForm"></add-menu>
    </right-panel>
  </div>
</template>

<script lang="ts" setup>
import type { MenuData } from "@/api/menu";
import { useReloadPage } from "@/hooks/useReloadPage";
import { type ITreeItemData, useMenuStore } from "@/stores/menu";
import type Node from "element-plus/es/***ponents/tree/src/model/node";

// 处理菜单更新事件
const handleUpdateEdit = async (data: Partial<MenuData>) => {
  const r = await store.updateMenu(data);
  if (r) {
    proxy?.$message.su***ess("菜单编辑成功");
    reloadPage(); // 刷新页面数据
  }
};

// 控制节点是否可拖拽
const allowDrag = (draggingNode: Node) => {
  // 根节点不可拖拽
  return (
    draggingNode.data.parent_id !== 0 || draggingNode.data.parent_id != null
  );
};

type DropType = "prev" | "inner" | "next";

// 控制节点拖拽放置规则
const allowDrop = (draggingNode: Node, dropNode: Node, type: DropType) => {
  // 根节点只能作为兄弟节点,不能作为子节点
  if (
    draggingNode.data.parent_id === 0 ||
    draggingNode.data.parent_id == null
  ) {
    return type !== "inner";
  }
};

// 处理节点拖拽完成事件
const handleNodeDrop = () => {
  store.updateBulkMenu(); // 更新菜单排序
};

// 页面刷新工具
const { reloadPage } = useReloadPage();
// 获取菜单状态管理
const store = useMenuStore();
// 计算属性:获取树形菜单数据
const menus = ***puted(() => store.state.menuTreeData);
// 初始化加载菜单数据
store.getAllMenuList();

// 树形组件配置
const defaultProps = {
  children: "children",
  label: "title"
};

// 菜单类型:0-顶级菜单 1-子菜单
const menuType = ref(0);
// 右侧面板显示控制
const panelVisible = ref(false);

// 计算属性:面板标题
const panelTitle = ***puted(() => {
  return menuType.value === 0 ? "添加顶级节点" : "添加子节点";
});

// 创建顶级菜单
const handleCreateRootMenu = () => {
  menuType.value = 0;
  panelVisible.value = true;
};

// 父菜单数据
const parentData = ref<ITreeItemData | null>();

// 创建子菜单
const handleCreateChildMenu = (data: ITreeItemData) => {
  menuType.value = 1;
  panelVisible.value = true;
  parentData.value = data;
};

// 重置状态
const resetStatus = () => {
  panelVisible.value = false;
  parentData.value = null;
  reloadPage();
};

// 生成菜单排序ID
const genMenuSortId = (list: ITreeItemData[]) => {
  if (list && list.length) {
    return list[list.length - 1].sort_id + 1;
  }
  return 0;
};

// 添加顶级菜单
const handleAddRootMenu = async (data: MenuData) => {
  // 设置父ID和排序ID
  data.parent_id = 0;
  data.sort_id = genMenuSortId(menus.value);
  let res = await store.appendMenu(data);
  if (res) {
    proxy?.$message.su***ess("根菜单添加成功了");
  }
};

const { proxy } = getCurrentInstance()!;

// 生成子菜单数据
const genChild = (data: MenuData) => {
  const parent = parentData.value!;
  if (!parent.children) {
    parent.children = [];
  }
  data.sort_id = genMenuSortId(parent.children);
  data.parent_id = parent.id;
  return data;
};

// 添加子菜单
const handleChildRootMenu = async (data: MenuData) => {
  const child = genChild(data);
  let res = await store.appendMenu(child);
  if (res) {
    proxy?.$message.su***ess("子菜单添加成功了");
  }
};

// 提交菜单表单
const submitMenuForm = async (data: MenuData) => {
  if (menuType.value === 0) {
    handleAddRootMenu({ ...data });
  } else {
    handleChildRootMenu({ ...data });
  }
  resetStatus();
};

// 删除菜单
const handleRemoveMenu = async (data: MenuData) => {
  try {
    await proxy?.$confirm("你确定删除" + data.title + "菜单吗?", {
      type: "warning"
    });
    await store.removeMenu({
      ...data
    });
    proxy?.$message.su***ess("菜单删除成功");
    reloadPage();
  } catch {
    proxy?.$message.info("取消菜单");
  }
};

// 当前编辑的菜单数据
const editData = ref({} as MenuData);

// 处理节点点击事件
const handleNodeClick = (data: MenuData) => {
  editData.value = { ...data };
};
</script>

<style scoped>
.menu-container {
  @apply flex p-20px; /* 容器布局和内边距 */
}
.menu-tree {
  @apply min-w-500px h-400px overflow-y-scroll; /* 菜单树区域固定高度,溢出滚动 */
}
.custom-item {
  @apply flex items-center justify-between flex-1; /* 自定义菜单项样式,两端对齐 */
}
.edit-card {
  @apply flex-1 ml-15px; /* 编辑区域自适应宽度 */
}
</style>

4.3 addMenu 组件

在 src/views/system/menu/***ponents/addMenu.vue 中编写菜单添加组件,代码如下:

//src/views/system/menu/***ponents/addMenu.vue
<template>
  <div class="editor-container" p-20px>
    <el-form ref="editFormRef" :model="editData" label-width="80px">
      <el-form-item label="菜单标题">
        <el-input v-model="editData.title" placeholder="请输入用户名" />
      </el-form-item>
      <el-form-item label="路径">
        <el-input v-model="editData.path" placeholder="请输入邮箱" />
      </el-form-item>

      <el-form-item label="图标">
        <el-input v-model="editData.icon" placeholder="请输入邮箱" />
      </el-form-item>

      <el-form-item label="路由name">
        <el-input v-model="editData.name" placeholder="请输入邮箱" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="submitMenuForm">提交</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script lang="ts" setup>
import type { FormInstance } from "element-plus";

const emit = defineEmits(["submit"]);

const editFormRef = ref<FormInstance | null>(null);
const editData = ref({
  title: "",
  path: "",
  icon: "",
  name: ""
});

// 提交编辑菜单
const submitMenuForm = () => {
  (editFormRef.value as FormInstance).validate((valid) => {
    if (valid) {
      emit("submit", editData.value);
      editData.value = {
        title: "",
        path: "",
        icon: "",
        name: ""
      };
    }
  });
};
</script>

4.4 editorMenu 组件

在 src/views/system/menu/***ponents/editorMenu.vue 中编写菜单编辑组件,代码如下:

//src/views/system/menu/***ponents/editorMenu.vue
<template>
  <div class="editor-container">
    <el-form
      ref="editFormRef"
      :model="editData"
      :rules="menuFormRules"
      label-width="100px"
    >
      <el-form-item label="菜单名称" prop="title">
        <el-input v-model="editData.title" placeholder="请输入菜单名称" />
      </el-form-item>
      <el-form-item label="路径" prop="path">
        <el-input v-model="editData.path" placeholder="请输入路由路径" />
      </el-form-item>
      <el-form-item label="路由Name" prop="name">
        <el-input v-model="editData.name" placeholder="请输入路由名称" />
      </el-form-item>
      <el-form-item label="图标" prop="icon">
        <el-input v-model="editData.icon" placeholder="请输入icon名称" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="submitMenuForm">编辑菜单</el-button>
        <el-button @click="submitReset">重置</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script lang="ts" setup>
import type { MenuData } from "@/api/menu";
import type { FormInstance } from "element-plus";
// 编辑的数据
const editData = ref({
  id: -1,
  title: "",
  name: "",
  path: "",
  icon: ""
});
// 验证规则
const menuFormRules = {
  title: {
    required: true,
    message: "请输入菜单名称",
    trigger: "blur"
  },
  path: {
    required: true,
    message: "请输入路由路径",
    trigger: "blur"
  },
  name: {
    required: true,
    message: "请输入路由名称",
    trigger: "blur"
  }
};
const editFormRef = ref<FormInstance | null>(null);
const loading = ref(false);

const resetFormData = (data: MenuData) => {
  editData.value = { ...editData.value, ...data };
};
const props = defineProps({
  data: {
    type: Object as PropType<MenuData>,
    required: true
  }
});
watch(
  () => props.data,
  (value) => {
    if (value) {
      resetFormData(value);
    }
  }
);
const submitReset = () => resetFormData(props.data);
const emit = defineEmits(["updateEdit"]);
const submitMenuForm = () => {
  (editFormRef.value as FormInstance).validate((valid) => {
    if (valid) {
      loading.value = true;
      emit("updateEdit", { ...editData.value });
    }
  });
};
</script>

以上,就是菜单管理的相关内容。

下一篇将继续探讨 角色菜单的实现,敬请期待~ 

转载请说明出处内容投诉
CSS教程网 » Vue3.5 企业级管理系统实战(十九):菜单管理

发表评论

欢迎 访客 发表评论

一个令你着迷的主题!

查看演示 官网购买