当前位置: 首页 > news >正文

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("/access/menu");
};//删除指定菜单
export const removeMenuById = (id: number
): Promise<ApiResponse<MenuData[]>> => {return service.delete("/access/menu/" + id);
};//新增菜单
export const addMenu = (data: MenuData): Promise<ApiResponse<MenuData>> => {return service.post("/access/menu", data);
};//更新指定菜单
export const updateMenuById = (id: number,data: Partial<MenuData>
): Promise<ApiResponse<MenuData[]>> => {return service.put("/access/menu/" + id, data);
};//批量更新菜单
export const updateBulkMenu = (data: Partial<MenuData>[]
): Promise<ApiResponse> => {return service.patch("/access/menu/update", { access: 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 { getRoleAccessByRoles } from "@/api/roleAccess";
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.更新sortIdstate.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 getRoleAccessByRoles(roles);if (res.code == 0) {const { data } = res;const access = data.access;state.authMenuList = access; // 侧边栏菜单列表(权限过滤后)state.authMenuTreeData = generateTree(access, 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; // 当前节点的父IDconst 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-currentdraggable: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-menuv-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/components/tree/src/model/node";// 处理菜单更新事件
const handleUpdateEdit = async (data: Partial<MenuData>) => {const r = await store.updateMenu(data);if (r) {proxy?.$message.success("菜单编辑成功");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 = computed(() => store.state.menuTreeData);
// 初始化加载菜单数据
store.getAllMenuList();// 树形组件配置
const defaultProps = {children: "children",label: "title"
};// 菜单类型:0-顶级菜单 1-子菜单
const menuType = ref(0);
// 右侧面板显示控制
const panelVisible = ref(false);// 计算属性:面板标题
const panelTitle = computed(() => {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和排序IDdata.parent_id = 0;data.sort_id = genMenuSortId(menus.value);let res = await store.appendMenu(data);if (res) {proxy?.$message.success("根菜单添加成功了");}
};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.success("子菜单添加成功了");}
};// 提交菜单表单
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.success("菜单删除成功");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/components/addMenu.vue 中编写菜单添加组件,代码如下:

//src/views/system/menu/components/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/components/editorMenu.vue 中编写菜单编辑组件,代码如下:

//src/views/system/menu/components/editorMenu.vue
<template><div class="editor-container"><el-formref="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>

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

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

相关文章:

  • WPF技巧-BindingProxy
  • MySQL故障排查
  • iOS 蓝牙开发中的 BT 与 BLE
  • map与set封装
  • 项目QT+ffmpeg+rtsp(三)——延迟巨低的项目+双屏显示
  • mysql故障排查与环境优化
  • 使用 Whisper 生成视频字幕:从提取音频到批量处理
  • 力扣面试150题--从前序与中序遍历序列构造二叉树
  • 九、异形窗口
  • Flask 与 Django 服务器部署
  • Django 项目中,将所有数据表注册到 Django 后台管理系统
  • C++(24):容器类<list>
  • 学习源码?
  • cmd里可以使用npm,vscode里使用npm 报错
  • OpenCv(7.0)——银行卡号识别
  • 中山大学具身智能体高效探索与精准问答!Beyond the Destination:面向探索感知的具身问答新基准
  • std::ranges::views::stride 和 std::ranges::stride_view
  • 2025年AI与网络安全的终极博弈:冲击、重构与生存法则
  • 【OpenCV基础2】
  • Interrupt 2025 大会回顾:关于LangChain 的 AI Agent会议内容总结
  • 交通运输局男子与两名女子办婚礼?官方通报:未登记结婚,开除该男子
  • 欧阳娜娜等20多名艺人被台当局列入重要查核对象,国台办回应
  • 国家主席习近平任免驻外大使
  • 一女游客在稻城亚丁景区因高反去世,急救两个多小时未能恢复生命体征
  • 小米汽车回应部分SU7前保险杠形变
  • 穆迪下调美国主权信用评级