
顶级菜单支持拖拽排序,侧边栏菜单会按照顶级菜单拖放顺序生成
点击左侧节点 编辑
添加节点
路由name目前没用到 之前想通过路由name筛选路由
3-1 菜单管理页面

src/views/system/menu/index.vue
<template><div class="menu-container"><!-- 菜单树 --><el-card class="tree-card"><template #header><el-button @click="handleCreateRootMenu">新增顶级菜单</el-button></template><div class="block"><div class="menu-tree"><el-treeref="menuTreeRef":data="menus"highlight-currentnode-key="id":expand-on-click-node="false":check-strictly="true"@node-click="handleNodeClick":props="defaultProps"draggable:allow-drop="allowDrop":allow-drag="allowDrag"@node-drop="handleNodeDrop"><template #default="{ node, data }"><span class="custom-tree-node"><span>{{ node.label }}</span><span><el-button type="text" @click.stop="handleCreateChildMenu(data)">添加</el-button><el-button type="text" @click.stop="handleRemoveMenu(node, data)">删除</el-button></span></span></template></el-tree></div></div></el-card><el-card class="edit-card"><template #header>编辑菜单</template><editor-menu v-show="editData && editData.id" :data="editData" /><span v-if="editData == null">从菜单列表选择一项后,进行编辑</span></el-card><!-- 添加菜单 --><right-panel v-model="dialogVisible" :title="panelTitle"><div class="menu-form"><el-formref="menuFormRef":model="menuFormData":rules="menuFormRules"label-width="100px"><el-form-item label="菜单名称" prop="title"><el-inputv-model="menuFormData.title"placeholder="请输入菜单名称"/></el-form-item><el-form-item label="路径" prop="path"><el-inputv-model="menuFormData.path"placeholder="请输入路由路径"/></el-form-item><el-form-item label="路由Name" prop="name"><el-inputv-model="menuFormData.name"placeholder="请输入路由名称"/></el-form-item><el-form-item label="图标" prop="icon"><el-inputv-model="menuFormData.icon"placeholder="请输入icon名称"/></el-form-item><el-form-item><el-button type="primary" @click="submitMenuForm">创建菜单</el-button></el-form-item></el-form></div></right-panel></div></template><script lang="ts">import { defineComponent, ref, reactive, computed, onMounted, watch, getCurrentInstance } from 'vue'import { ElTree, ElForm } from 'element-plus'import RightPanel from '@/components/RightPanel/index.vue'import { addNewMenu, removeMenuByID, updateBulkMenu } from '@/api/menu'import { ITreeItemData, MenuData } from '@/store/modules/menu'import { useStore } from '@/store'import EditorMenu from './components/editorMenu.vue'import { useReloadPage } from '@/hooks/useReload'interface ITreeNode {id: numbertitle: stringchildren: ITreeNode[]parentId?: numbersortId: numberparent: {data: ITreeNode},data: ITreeItemData}type IMenuTree = InstanceType<typeof ElTree>type IMenuForm = InstanceType<typeof ElForm>type IMenuItemNotID = Omit<ITreeItemData, 'id'>export default defineComponent({name: 'Menu',components: {RightPanel,EditorMenu},setup() {const store = useStore()const { proxy } = getCurrentInstance()!const menuTreeRef = ref<IMenuTree | null>(null)const treeData = computed(() => store.getters.menusTree)const menus = ref<ITreeItemData[]>([])const editData = ref<MenuData|null>()watch(treeData, (value: ITreeItemData[]) => {menus.value = JSON.parse(JSON.stringify(value))editData.value = null})onMounted(() => { // 获取全部菜单store.dispatch('menu/getAllMenuList')})// tree propsconst defaultProps = ref({children: 'children',label: 'title'})// 重新刷新整个系统const { reloadPage } = useReloadPage()// 添加菜单panelconst dialogVisible = ref(false)watch(dialogVisible, value => {if (!value) {(menuFormRef.value as IMenuForm).resetFields()}})// 分配sortId 根据最后一个数据sortId+1const getMenuNodeSortID = (list: ITreeItemData[]) => {if (list && list.length > 0) {return list[list.length - 1].sort_id + 1}return 0}// 移除节点const removeNode = (node: ITreeNode, childId: number) => {const parent = node.parentconst children = parent.data.children || parent.dataconst index = children.findIndex(d => d.id === childId)children.splice(index, 1)menus.value = [...menus.value]}/*** node: 当前node对象* menuData: 当前节点数据*/const handleRemoveMenu = (node: ITreeNode, menuData: ITreeItemData) => {proxy?.$confirm(`您确认要删除菜单${menuData.title}吗?`, '删除确认', {type: 'warning'}).then(() => {// 根据id删除菜单removeMenuByID(menuData.id).then(res => {if (res.code === 0) {proxy?.$message.success('删除成功')removeNode(node, menuData.id)// 如果删除的是当前编辑的菜单 就重置编辑表单if (editData.value && menuData.id === editData.value.id) {editData.value = null}// 是否重新刷新整个系统reloadPage()}})}).catch(() => {proxy?.$message({type: 'info',message: '已取消删除'})})}// 新增顶级菜单// 添加菜单表单const menuFormRef = ref<IMenuForm | null>(null)// 菜单表单数据const menuFormData = reactive<IMenuItemNotID>({title: '',path: '',name: '',icon: '',parent_id: '',sort_id: 0})const menuType = ref(0) // 添加菜单类型 0顶级 1子级// 面板titleconst panelTitle = computed(() =>menuType.value === 0 ? '添加顶级菜单' : '添加子菜单')// 重置添加菜单状态const resetStatus = () => {dialogVisible.value = falsemenuFormRef.value?.resetFields()parentData.value = null}// ············· 添加顶级菜单 ······················// 点击添加顶级菜单const handleCreateRootMenu = () => {menuType.value = 0dialogVisible.value = true}// 顶级菜单分配partentId和sortIdconst allocRootMenuId = (data: IMenuItemNotID) => {const sortId = getMenuNodeSortID(menus.value)data.sort_id = sortIddata.parent_id = '0'}// 顶级菜单 添加到 tree组件中const appendRootMenu = (id: number, data: IMenuItemNotID) => {const node = { id, ...data, children: [] }menus.value.push(node)menus.value = [...menus.value]}// 添加顶级菜单const handleAddRootMenu = async (data: IMenuItemNotID) => {allocRootMenuId(data)await addNewMenu(data).then(res => {if (res.code === 0) {const { id } = res.dataappendRootMenu(id, data)proxy?.$message.success('菜单创建成功')// 是否重新刷新整个系统reloadPage()}})}// ············· 添加子菜单 ······················// 子菜单分配sortid 和 parentIdconst allocChildMenuId = (data: IMenuItemNotID, parentData: ITreeItemData): IMenuItemNotID => {const pid = parentData.id as numberlet sortId = 0if (!parentData.children) {parentData.children = []}if (parentData.children.length > 0) {sortId = getMenuNodeSortID(parentData.children)}data.sort_id = sortIddata.parent_id = pidreturn data}// 添加子菜单到tree组件中const appendChildMenu = (child: ITreeItemData, parentData: ITreeItemData) => {(parentData.children!).push(child)menus.value = [...menus.value]}// 添加子菜单const parentData = ref<ITreeItemData | null>(null) // 缓存父菜单dataconst handleAddChildMenu = async (data: IMenuItemNotID) => {const child = allocChildMenuId(data, parentData.value!)await addNewMenu(data).then(res => {if (res.code === 0) {const { id } = res.data;(child as ITreeItemData).id = idappendChildMenu(child as ITreeItemData, parentData.value!)proxy?.$message.success('菜单创建成功')// 是否重新刷新整个系统reloadPage()}})}// 新增子菜单const handleCreateChildMenu = (data: ITreeItemData) => {menuType.value = 1dialogVisible.value = trueparentData.value = data}// 菜单编辑const handleNodeClick = (data: MenuData) => {editData.value = { ...data }}// 提交menuFormconst submitMenuForm = () => {(menuFormRef.value as IMenuForm).validate(async valid => {if (valid) {if (menuType.value === 0) {// 添加根菜单await handleAddRootMenu({ ...menuFormData })} else if (menuType.value === 1) {// 添加子菜单await handleAddChildMenu({ ...menuFormData })}// 重置相关状态resetStatus()}})}// 实现顶级菜单 拖拽排序// 拖拽一级节点const allowDrag = (draggingNode: ITreeNode) => {const data = draggingNode.datareturn data.parent_id === 0 || data.parent_id == null}// 拖放一级节点type DropType = 'before' | 'after' | 'inner'const allowDrop = (draggingNode: ITreeNode, dropNode: ITreeNode, type: DropType) => {if (dropNode.data.parent_id === 0 || dropNode.data.parent_id == null) {return type !== 'inner'}}// 拖放完成事件const handleNodeDrop = () => {menus.value.forEach((menu, index) => {menu.sort_id = index})// 批量更新菜单状态 这里是为了更新sort_idconst menuList = menus.value.map(menu => {const temp = { ...menu }delete menu.childrenreturn temp})// 批量更新updateBulkMenu(menuList).then(res => {if (res.code === 0) {// 重新生成菜单 1 代表是菜单排序更新store.dispatch('permission/generateRoutes', 1)}})}// 验证规则const menuFormRules = reactive({title: {required: true,message: '请输入菜单名称',trigger: 'blur'},path: {required: true,message: '请输入路由路径',trigger: 'blur'},name: {required: true,message: '请输入路由名称',trigger: 'blur'}})return {menus,handleCreateRootMenu,handleCreateChildMenu,handleRemoveMenu,menuTreeRef,handleNodeClick,dialogVisible,menuFormData,menuFormRules,menuFormRef,submitMenuForm,defaultProps,panelTitle,editData,allowDrag,allowDrop,handleNodeDrop}}})</script><style lang="scss">.menu-container {display: flex;padding: 20px;justify-content: space-around;.menu-tree {height: 400px;overflow-y: scroll;}.tree-card {min-width: 500px;padding-bottom: 30px;}.edit-card {flex: 1;margin-left: 15px;}.el-form-item__content {min-width: 220px;}.custom-tree-node {flex: 1;display: flex;align-items: center;justify-content: space-between;font-size: 14px;padding-right: 8px;}.menu-form {padding: 20px 10px 20px 0;box-sizing: border-box;}}</style>
创建编辑菜单组件

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-inputv-model="editData.title"placeholder="请输入菜单名称"/></el-form-item><el-form-item label="路径" prop="path"><el-inputv-model="editData.path"placeholder="请输入路由路径"/></el-form-item><el-form-item label="路由Name" prop="name"><el-inputv-model="editData.name"placeholder="请输入路由名称"/></el-form-item><el-form-item label="图标" prop="icon"><el-inputv-model="editData.icon"placeholder="请输入icon名称"/></el-form-item><el-form-item><el-buttontype="primary"@click="submitMenuForm":loading="loading">编辑菜单</el-button><el-button @click="submitReset">重置</el-button></el-form-item></el-form></div></template><script lang="ts">import { defineComponent, PropType, ref, watch, getCurrentInstance } from 'vue'import { MenuData } from '@/store/modules/menu'import { ElForm } from 'element-plus'import { updateMenuByID } from '@/api/menu'import { useStore } from '@/store'import { useReloadPage } from '@/hooks/useReload'type FormInstance = InstanceType<typeof ElForm>export default defineComponent({name: 'EditorMenu',props: {data: {type: Object as PropType<MenuData>}},emits: ['updateEdit'],setup(props) {const store = useStore()const { proxy } = getCurrentInstance()!const loading = ref(false)const editFormRef = ref<FormInstance|null>(null)const editData = ref({id: '',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 resetFormData = (data: MenuData) => {if (data) {const { id, title, name, path, icon } = dataeditData.value = { id: String(id), title, name, path, icon }}}watch(() => props.data, (value) => {if (value) {resetFormData(value)}})// 刷新系统const { reloadPage } = useReloadPage()// 提交编辑菜单const submitMenuForm = () => {(editFormRef.value as FormInstance).validate(valid => {if (valid) {loading.value = trueupdateMenuByID(Number(editData.value.id), editData.value).then(res => {if (res.code === 0) {proxy?.$message.success('菜单编辑成功')// 重新获取菜单store.dispatch('menu/getAllMenuList')reloadPage()}}).finally(() => {loading.value = false})}})}// 重置编辑菜单const submitReset = () => {resetFormData(props.data as MenuData)}return {editData,submitMenuForm,submitReset,editFormRef,menuFormRules,loading}}})</script>
3-2 菜单store
src/store/modules/menu.ts
import { Module, MutationTree, ActionTree } from 'vuex'import { IRootState } from '@/store'import { getAllMenus } from '@/api/menu'import generateTree from '@/utils/generateTree'import generateMenuTree from '@/utils/generateMenuTree'import { getAccessByRoles } from '@/api/roleAccess'/* eslint-disable camelcase */export interface MenuData {id: number;title: string;path: string;name: string;icon: string;parent_id: string | number;sort_id: number;}export interface ITreeItemData extends MenuData {children?: ITreeItemData[]}// state类型export interface IMenusState {menuTreeData: Array<ITreeItemData>; // 树形菜单数据menuList: Array<MenuData>; // 原始菜单列表数据authMenuTreeData: Array<ITreeItemData>; // 树形菜单数据authMenuList: Array<MenuData>; // 原始菜单列表数据}// mutations类型type IMutations = MutationTree<IMenusState>// actions类型type IActions = ActionTree<IMenusState, IRootState>// 定义stateconst state: IMenusState = {menuTreeData: [],menuList: [],authMenuTreeData: [],authMenuList: []}// 定义mutationsconst mutations: IMutations = {SET_MENU_LIST(state, data: IMenusState['menuList']) {state.menuList = data},SET_MENU_TREE_DATA(state, data: IMenusState['menuTreeData']) {state.menuTreeData = data},SET_AUTH_MENU_LIST(state, data: IMenusState['menuList']) {state.authMenuList = data},SET_AUTH_MENU_TREE_DATA(state, data: IMenusState['menuTreeData']) {state.authMenuTreeData = data}}// 定义actionsconst actions: IActions = {getAllMenuList({ dispatch, commit }) {return new Promise<MenuData[]>((resolve, reject) => {getAllMenus().then(response => {const { data } = responsedispatch('generateTreeData', [...data])commit('SET_MENU_LIST', data)resolve([...data])}).catch(reject)})},generateTreeData({ commit }, data: IMenusState['menuList']) {const treeData = generateTree(data)commit('SET_MENU_TREE_DATA', treeData)},generateAuthTreeData({ commit }, data: IMenusState['menuList']) {const treeData = generateMenuTree(data)commit('SET_AUTH_MENU_TREE_DATA', treeData)},getAllMenuListByAdmin({ dispatch, commit }) {return new Promise<MenuData[]>((resolve, reject) => {getAllMenus().then(response => {const { data } = responsedispatch('generateAuthTreeData', [...data])commit('SET_AUTH_MENU_LIST', data)resolve([...data])}).catch(reject)})},getAccessByRoles({ dispatch, commit }, roles: number[]) {return new Promise<MenuData[]>((resolve, reject) => {getAccessByRoles(roles).then(response => {const { access } = response.datadispatch('generateAuthTreeData', [...access])commit('SET_AUTH_MENU_LIST', access)resolve([...access])}).catch(reject)})}}// 定义menu moduleconst menu: Module<IMenusState, IRootState> = {namespaced: true,state,mutations,actions}export default menu
store.ts
src/store/index.ts
import { InjectionKey } from 'vue'import { createStore, Store, useStore as baseUseStore } from 'vuex'import createPersistedState from 'vuex-persistedstate'import app, { IAppState } from '@/store/modules/app'import tagsView, { ITagsViewState } from '@/store/modules/tagsView'import settings, { ISettingsState } from '@/store/modules/settings'import user, { IUserState } from '@/store/modules/user'import getters from './getters'import menu, { IMenusState } from './modules/menu'import role, { IRoleState } from './modules/role'import permission, { IPermissionState } from './modules/permission'// 模块声明在根状态下export interface IRootState {app: IAppState;user: IUserState;menu: IMenusState;role: IRoleState;tagsView: ITagsViewState;settings: ISettingsState;permission: IPermissionState;}// 通过下面方式使用 TypeScript 定义 store 能正确地为 store 提供类型声明。// https://next.vuex.vuejs.org/guide/typescript-support.html#simplifying-usestore-usage// eslint-disable-next-line symbol-descriptionexport const key: InjectionKey<Store<IRootState>> = Symbol()// 对于getters在组件使用时没有类型提示// 有人提交了pr #1896 为getters创建泛型 应该还未发布// https://github.com/vuejs/vuex/pull/1896// 代码pr内容详情// https://github.com/vuejs/vuex/pull/1896/files#diff-093ad82a25aee498b11febf1cdcb6546e4d223ffcb49ed69cc275ac27ce0ccce// vuex store持久化 默认使用localstorage持久化const persisteAppState = createPersistedState({storage: window.sessionStorage, // 指定storage 也可自定义key: 'vuex_app', // 存储名 默认都是vuex 多个模块需要指定 否则会覆盖// paths: ['app'] // 针对app这个模块持久化// 只针对app模块下sidebar.opened状态持久化paths: ['app.sidebar.opened', 'app.size'] // 通过点连接符指定state路径})const persisteSettingsState = createPersistedState({storage: window.sessionStorage, // 指定storage 也可自定义key: 'vuex_setting', // 存储名 默认都是vuex 多个模块需要指定 否则会覆盖// paths: ['app'] // 针对app这个模块持久化// 只针对app模块下sidebar.opened状态持久化paths: ['settings.theme', 'settings.originalStyle', 'settings.tagsView', 'settings.sidebarLogo'] // 通过点连接符指定state路径})export default createStore<IRootState>({plugins: [persisteAppState,persisteSettingsState],getters,modules: {app,user,tagsView,settings,menu,role,permission}})// 定义自己的 `useStore` 组合式函数// https://next.vuex.vuejs.org/zh/guide/typescript-support.html#%E7%AE%80%E5%8C%96-usestore-%E7%94%A8%E6%B3%95// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-typesexport function useStore () {return baseUseStore(key)}// vuex持久化 vuex-persistedstate文档说明// https://www.npmjs.com/package/vuex-persistedstate
3-3 菜单api
src/api/menu.ts
import request from '@/api/config/request'import { MenuData } from '@/store/modules/menu'import { ApiResponse } from './type'// 添加新菜单export const addNewMenu = (data: Omit<MenuData, 'id'>): Promise<ApiResponse> => {return request.post('/access/menu',data)}// 获取全部菜单export const getAllMenus = (): Promise<ApiResponse<MenuData[]>> => {return request.get('/access/menus')}// 删除指定菜单export const removeMenuByID = (id: number): Promise<ApiResponse<null>> => {return request.delete(`/access/menu/${id}`)}// 更新指定菜单type UpdateMenuData = Omit<MenuData, 'id'|'parent_id'|'sort_id'>export const updateMenuByID = (id: number, data: UpdateMenuData): Promise<ApiResponse<null>> => {return request.put(`/access/menu/${id}`, data)}// 批量更新菜单export const updateBulkMenu = (data: MenuData[]): Promise<ApiResponse<null>> => {return request.patch('/access/menu/update', {access: data})}import request from '@/api/config/request'import { MenuData } from '@/store/modules/menu'import { ApiResponse } from './type'// 添加新菜单export const addNewMenu = (data: Omit<MenuData, 'id'>): Promise<ApiResponse> => {return request.post('/access/menu',data)}// 获取全部菜单export const getAllMenus = (): Promise<ApiResponse<MenuData[]>> => {return request.get('/access/menus')}// 删除指定菜单export const removeMenuByID = (id: number): Promise<ApiResponse<null>> => {return request.delete(`/access/menu/${id}`)}// 更新指定菜单type UpdateMenuData = Omit<MenuData, 'id'|'parent_id'|'sort_id'>export const updateMenuByID = (id: number, data: UpdateMenuData): Promise<ApiResponse<null>> => {return request.put(`/access/menu/${id}`, data)}
3-4 角色和菜单
api
src/api/roleAccess.ts
import request from '@/api/config/request'import { MenuData } from '@/store/modules/menu'import { IRole, IRoleAccessList } from '@/store/modules/role'import { ApiResponse } from './type'/*** 根据角色分配权限* @param id 角色id* @param data 权限id列表*/export const allocRoleAccess = (id: number, data: number[]): Promise<ApiResponse> => {return request.post(`/role_access/${id}`, {access: data})}/*** 根据角色获取权限* @param id 角色id* @param data 权限id列表*/export const getRoleAccess = (id: number): Promise<ApiResponse<IRoleAccessList>> => {return request.get(`/role_access/${id}`)}// 根据用户角色获取用户菜单type RolesAccess = MenuData & {roles: IRole[]}interface ApiRolesAccess {access: RolesAccess[]}export const getAccessByRoles = (roles: number[]): Promise<ApiResponse<ApiRolesAccess>> => {return request.post('/role_access/role/access', {roles})}
3-5 权限store
src/store/modules/permission.ts
import { Module, MutationTree, ActionTree } from 'vuex'import { RouteRecordRaw } from 'vue-router'import store, { IRootState } from '../index'import { asyncRoutes } from '../../router/index'import { MenuData } from './menu'import path from 'path'// 生成路由路径数组const generateRoutePaths = (menus: Array<MenuData>): string[] => {return menus.map(menu => menu.path)}// 白名单const whiteList = ['/:pathMatch(.*)*']// 生成可访问路由表const generateRoutes = (routes: Array<RouteRecordRaw>, routePaths: string[], basePath = '/') => {const routerData: Array<RouteRecordRaw> = []routes.forEach(route => {const routePath = path.resolve(basePath, route.path)if (route.children) { // 先看子路由 是否有匹配上的路由route.children = generateRoutes(route.children, routePaths, routePath)}// 如果当前路由子路由 数量大于0有匹配上 或 paths中包含当面路由path 就需要把当前父路由添加上if (routePaths.includes(routePath) || (route.children && route.children.length >= 1) || whiteList.includes(routePath)) {routerData.push(route)}})return routerData}const filterAsyncRoutes = (menus: Array<MenuData>, routes: Array<RouteRecordRaw>) => {// 生成要匹配的路由path数组const routePaths = generateRoutePaths(menus)// 生成匹配path的路由表const routerList = generateRoutes(routes, routePaths)return routerList}// 定义state类型export interface IPermissionState {routes: Array<RouteRecordRaw>;accessRoutes: Array<RouteRecordRaw>;}// mutations类型type IMutations = MutationTree<IPermissionState>// actions类型type IActions = ActionTree<IPermissionState, IRootState>// 定义stateconst state: IPermissionState = {routes: [],accessRoutes: []}// 定义mutationconst mutations: IMutations = {SET_ROUTES(state, data: Array<RouteRecordRaw>) {state.routes = data},SET_ACCESS_ROUTES(state, data: Array<RouteRecordRaw>) {state.accessRoutes = data}}// 定义actionsconst actions: IActions = {generateRoutes({ dispatch }, type?: number) { // 1 针对菜单排序更新return new Promise((resolve, reject) => {let accessedRoutes: Array<RouteRecordRaw> = []if (store.getters.roleNames.includes('super_admin')) { // 超级管理员角色accessedRoutes = asyncRoutesdispatch('menu/getAllMenuListByAdmin', null, { root: true })resolve(accessedRoutes)} else { // 根据角色过滤菜单const roles = store.getters.roleIdsdispatch('menu/getAccessByRoles', roles, { root: true }).then(menus => {if (type !== 1) { // 菜单重新排序 不需要再过次滤路由accessedRoutes = filterAsyncRoutes(menus, asyncRoutes)}resolve(accessedRoutes)}).catch(reject)}})}}// 定义user moduleconst permission: Module<IPermissionState, IRootState> = {namespaced: true,state,mutations,actions}export default permission
