篇幅原因,本節先探討菜單管理頁面增刪改查相關功能,角色菜單,菜單權限,動態菜單等內容放在后面。
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>
以上,就是菜單管理的相關內容。
下一篇將繼續探討 角色菜單的實現,敬請期待~?