likes
comments
collection
share

零后端配置动态菜单功能如何实现(附源码)

作者站长头像
站长
· 阅读数 16

零后端配置动态菜单功能如何实现(附源码)

去年下半年的某一天,突发奇想想做一个开源的项目,因为以前工作中使用过ruoyi,觉得功能和设计还是挺好的,而且也是完全开源的,能够节约很多重复权限和菜单等等功能的开发,但是ruoyi暂时还是只有vue版本的没有react的;故而想用react18koa2来写一个,正好也借此机会来提升我的react技术能力和学习后端方面的知识,在其中也遇到了很多的坑,在遇到问题时的痛苦和解决问题后的喜悦中反复横跳~

我的开源后台项目地址 (希望大家能够给up点个star支持下,谢谢啦~)

一、后端部分

1-1、数据库表字段的设计

在一个功能中,最先思考的和最重要的往往都是数据库表,ruoyi中将多级菜单和菜单按钮权限集成到了一张表中,菜单的层级关系由parent_idmenu_id来进行判断,动态渲染则有pathcomponentmenu_type等等的字段来判断,perms则是权限功能的关键,所以第一步我们需要先将菜单表在数据库中创建;

零后端配置动态菜单功能如何实现(附源码)

1-2、创建查询routers菜单api接口

const router = new Router({ prefix: '/system' })
// 查询routers菜单
router.get('/menu/getRouters', conversionMid, getRouterMid, IndexCon())
  1. conversionMid 中间件是负责从数据库中获取菜单数据并且对数据进行下划线转驼峰;
  2. getRouterMid 中间件是负责将数据库的菜单数据转换为前端需要的树状路由数据;
  3. IndexCon 是统一方回的返回前端中间件;

✨ 下划线转驼峰功能可以看我之前的文章: 如何优雅的封装转换前后端接口数据格式工具函数(下划线<=>大写)

1-3、返回给前端树状路由数据的处理

主要就是写一个递归,用parentId来确认子父级关系,然后就是一些比如菜单状态、是否缓存等的布尔值转换;

// 生成前端menu路由
export const getRouterMid = async (ctx: Context, next: () => Promise<void>) => {
  try {
    const menus = ctx.state.menus
    const routers = [] as RouteType[]
    menus.sort((a: { orderNum: number }, b: { orderNum: number }) => a.orderNum - b.orderNum)

    menus.forEach((menu: menusType) => {
      if (menu.parentId === 0) {
        const route = {} as RouteType
        Object.assign(route, {
          name: menu.path,
          path: '/' + menu.path,
          alwaysShow: menu.menuType === 'M' ? true : false,
          element: menu.component,
          hidden: menu.visible === '0' ? false : true,
          children: [],
          query: menu.query,
          meta: {
            title: menu.menuName,
            link: menu.isFrame ? null : menu.path,
            noCache: menu.isCache ? false : true,
            icon: menu.icon
          }
        })
        createRoute(route, menu.menuId)

        if (route.children && route.children.length < 1) {
          delete route.children
        }
        routers.push(route)
      }
    })

    function createRoute(route: RouteType, parentId: number) {
      menus.forEach((menu: menusType) => {
        if (menu.parentId === parentId) {
          const routeChild = {
            name: menu.path,
            path: menu.path,
            query: menu.query,
            alwaysShow: menu.menuType === 'M' ? true : false,
            element: menu.component,
            hidden: menu.visible === '0' ? false : true,
            children: [],
            meta: {
              title: menu.menuName,
              link: menu.isFrame ? null : menu.path,
              noCache: menu.isCache ? false : true,
              icon: menu.icon
            }
          }
          route.children.push(routeChild)

          createRoute(routeChild, menu.menuId)
        }
      })

      if (route.children.length < 1) {
        delete route.children
      }
    }

    ctx.state.formatData = routers
    // 按照路由格式存储一级菜单
  } catch (error) {
    console.error('前端路由创建失败', error)
    return ctx.app.emit('error', getRoutersErr, ctx)
  }
  await next()
}

1-4、最终效果

零后端配置动态菜单功能如何实现(附源码)

二、前端部分

2-1、菜单管理模块

菜单管理模块就是前端开发者在前端页面自行添加新的菜单及权限,本质就是往后端数据库内添加菜单数据,然后改变返回给前端的路由数据,最终前端拿着路由数据进行路由渲染;

主页面

零后端配置动态菜单功能如何实现(附源码)

新增弹窗

零后端配置动态菜单功能如何实现(附源码)

菜单图标

零后端配置动态菜单功能如何实现(附源码)

其中菜单图标的渲染我这里把代码贴一下:

  1. 从文件中引入所有的svg图表文件:
文件地址:src/assets/icons/index.ts

const requireAll = (requireContext: __WebpackModuleApi.RequireContext) =>
  requireContext.keys().map(requireContext)
const req = require.context('./svg', false, /\.svg$/)
requireAll(req)

const re = /\.\/(.*)\.svg/

const requireIconsName = (requireContext: __WebpackModuleApi.RequireContext) =>
  requireContext.keys()

const icons = requireIconsName(req).map((i: any) => {
  return i.match(re)[1]
})

export default icons
  1. 封装IconSelect组件:
文件地址:src/components/IconSelect/index.tsx

import React, { useEffect, useState } from 'react'
import icons from '@/assets/icons'
import { Col, Input, Row } from 'antd'
import SvgIcon from '../SvgIcon'
import { SearchOutlined } from '@ant-design/icons'

export type IconsSelectProps = {
  onSubmit: (icon: string, open: boolean) => void
}

const IconSelect: React.FC<IconsSelectProps> = (props) => {
  const { onSubmit } = props
  const [newIcons, setNewIcons] = useState<string[]>([])

  useEffect(() => {
    initIcons()
  }, [])

  const initIcons = () => {
    const iconList = icons.filter((icon) => icon !== 'login_bg')
    setNewIcons(iconList)
  }

  const filterIcons = (name?: string) => {
    const iconList = icons.filter((icon) => icon.indexOf(name) != -1)
    setNewIcons(iconList)
  }

  return (
    <Row style={{ width: 500, height: 160, overflow: 'auto' }}>
      <Input
        onChange={(e) => {
          e.target.value ? filterIcons(e.target.value) : initIcons()
        }}
        allowClear
        prefix={<SearchOutlined />}
        placeholder="请输入图表名称"
        style={{ margin: '0 10px 10px 0', height: 32 }}
      />
      {newIcons.map((icon) => (
        <Col
          span={8}
          key={icon}
          onClick={() => {
            onSubmit(icon, false)
          }}
        >
          <Row align="middle" style={{ cursor: 'pointer' }}>
            <SvgIcon iconClass={icon}></SvgIcon>
            <span style={{ marginLeft: 5 }}>{icon}</span>
          </Row>
        </Col>
      ))}
    </Row>
  )
}

export default IconSelect

2-2、动态路由渲染

侧边栏菜单基础是使用了antdesignMneu组件,createMenuFn该递归函数则是对路由数据里面的功能进行实现,比如菜单的显示状态,菜单是否为外链,菜单是否缓存(该功能还未做)等等都在该函数中进行实现;

文件地址:src/Layout/Menu/index.tsx

  // 生成  menu
  function createMenuFn(dynamicRouters: RouteType[], isAlwaysShow?: boolean, onPath: string = '') {
    const newItems: ItemType[] = []

    dynamicRouters.forEach((route: RouteType) => {
      if (route.alwaysShow || isAlwaysShow) {
        if (!route.hidden) {
          // 多级目录
          let frontPath: string = ''
          if (!onPath) {
            frontPath = route.path as string
          } else {
            frontPath = onPath + '/' + route.path
          }

          newItems.push(
            getItem(
              route.meta?.link ? (
                <a href={route.meta?.link} target="_blank" rel="noopener noreferrer">
                  {route.meta?.title}
                </a>
              ) : (
                route.meta?.title
              ),
              frontPath,
              <SvgIcon iconClass={route.meta?.icon as string} style={{ marginRight: 5 }} />,
              route.children && createMenuFn(route.children, true, frontPath),
              route.query,
            ),
          )
        }
      } else {
        // 单级目录
        if (!route.hidden) {
          newItems.push(
            getItem(
              route.meta?.link ? (
                <a href={route.meta?.link} target="_blank" rel="noopener noreferrer">
                  {route.meta?.title}
                </a>
              ) : (
                route.meta?.title
              ),
              route.path as string,
              <SvgIcon iconClass={route.meta?.icon as string} style={{ marginRight: 5 }} />,
              [],
              route.query,
            ),
          )
        }
      }
    })
    return newItems
  }

  const items: MenuItem[] = createMenuFn(toJS(dynamicRouters))

2-3、路由守卫中获取路由数据

文件地址:src/routes/utils/routers.tsx

/**
 * @description 递归查询对应的路由
 * @param {String} path 当前访问地址
 * @param {Array} routes 路由列表
 * @returns array
 */
// 设置白名单
const whitePaths = ['/login', '/404', '/500']
// 路由守卫配置函数
export const AuthRouter: any = (props: { children: RouteType }) => {
  const { pathname } = useLocation()
  const {
    useUserStore: { setUserInfo, userInfo },
    useRoutersStore: { setRouters },
  } = useStore()

  // 第一步 判断有无 token
  if (getToken()) {
    // 第二步 判断是否前往login页面,等于跳转 '/', 不等于则继续判断
    if (pathname === '/login') {
      return <Navigate to="/" replace />
    } else {
      // 第三步 判断是否拿到用户个人信息、路由、权限,没拿到则进行axios请求数据,进行信息存储及权限路由渲染,否则直接放行
      if (Object.keys(userInfo).length < 1 || !userInfo) {
        // 获取用户个人信息
        async function getMes() {
          try {
            const userInfo = await getUserAPI()
            setUserInfo(userInfo.data.result as IuserInfo)

            const {
              data: { result },
            } = await getRoutersAPI()
            setRouters(result as RouteType[])
          } catch (error) {}
        }
        getMes()
      }

      return props.children
    }
  } else {
    if (whitePaths.includes(pathname)) {
      return props.children
    } else {
      return <Navigate to="/login" replace />
    }
  }
}

2-4、mobx管理路由数据,并且将路由组件进行懒加载

文件地址:src/store/modules/permission.tsx

  // 存储 routers
  setRouters = (routers: RouteType[]) => {
    this.filterRouters(routers) // 权限过滤,还未做
    this.mapRouter(routers)   // 路由映射
    routers.push({
      path: '*',
      element: <Navigate to="/404" />,
      hidden: true,
    })

    this.dynamicRouters = routers
  }

此处的404页面需要在路由映射完毕,所有路由添加完后,添加到路由表的最后,否则会出现刷新进入404页面的情况;

文件地址:src/store/modules/permission.tsx

  // 路由映射
  mapRouter(routers: RouteType[]) {
    routers.forEach((router) => {
      // 组件替换
      if (!router.element) {
        delete router.element
      } else {
        router.element = this.lazyLoadFn(router.element as string)
      }
      // 如果有children,则递归
      if (router.children) this.mapRouter(router.children)
    })
  }
  
  // 懒加载
  lazyLoadFn(moduleName: string) {
    return lazyLoad(React.lazy(() => import(`@/views${moduleName}`)))
  }

对所有路由的数据进行动态懒加载,使用的是React中原生自带的lazy方法;

2-5、监听路由数据变化,动态渲染侧边栏

import React from 'react'
/* 引入工具函数 */
import '@/assets/style/App.css'
import { AuthRouter } from '@/routes/utils/routers'
/* 全局 Loading */
import Loading from '@/components/Loading'
import { Router } from '@/routes/index'

const App = () => {
  return (
    <div style={{ height: 100 + '%' }}>
      {/* 注册路由 */}
      <AuthRouter>
        <Router /> 
      </AuthRouter>
      {/* Loaing */}
      <Loading />
    </div>
  )
}

export default App

AuthRouter为路由守卫、routerreact-router-domuseRoutes方式生成的路由表

文件地址:src/routes/index.tsx

/**
 * 路由配置项
 *
 * path:'路径'            // 路径,如果不是多级嵌套,可为 ' '
 * hidden:true           // 设置为true时不会出现在侧边栏
 * name:'router-name'     // 设定路由名,此项必填 (也是唯一标志名)
 * element:<login />     // 组件
 * alwaysShow::true       // 设置该属性为true后,侧边栏就会出现多级嵌套,否则不会出现
 * meta:{
 *   title:'title'        // 设置该路由在侧边栏和面包屑的name
 *   link:'http'          // 外链地址
 *   noCache:false       // 是否缓存
 *   icon:'svg-name'      // 设置该路由的图标,对应路径 src/assets/icons/svg
 * }
 */

export const rootRouter = [
  // 所有的动态路由都将渲染到该主菜单上
  {
    element: <Layout />,
    children: [] as RouteType[],
  },
  {
    path: '/login',
    element: <Login />,
    meta: {
      title: '登录页',
    },
  },
  {
    path: '/404',
    element: <Page404 />,
    meta: {
      title: '404页面',
    },
  },
  {
    path: '/500',
    element: <Page500 />,
    meta: {
      title: '500页面',
    },
  },
  {
    path: '/',
    element: <Navigate to={HOME_URL} />,
  },
]

export const Router = observer(() => {
  const [route, setRoute] = useState(rootRouter)
  const {
    useRoutersStore: { dynamicRouters },
  } = useStore()

  useEffect(() => {
    rootRouter[0].children = toJS(dynamicRouters)
    setRoute([...rootRouter])
  }, [dynamicRouters])

  return useRoutes(route)
})

主要思路是监听mobx中保管的路由数据,路由数据发生变化,则就将动态路由数据加载到layout主路由内,然后重新用useRoutes生成新的路由表即可;

2-6、最终效果

零后端配置动态菜单功能如何实现(附源码)

三、遇到的问题

最后useRoutes渲染动态渲染路由表,会导致刚开始几次出现该路由找不到的警告提醒问题,虽然不影响使用,但是很难受;

个人思考导致的原因:因为现在React18 hooks变成为异步渲染,比如刚开始所有的静态页面会渲染一次,然后就会处理些函数,比如axios请求,请求完后处理的数据导致页面的依赖项改变了,然后会触发页面渲染;这样就会导致,比如在菜单管理页面刷新,第一次渲染拿去的是最基本的静态路由表,肯定无法找到,需要等待api请求回来数据,再经过渲染加工后生成新的路由表,菜单管理才找得到;搞了挺久一段时间,但是依然无法解决掉这个错误提醒,希望大家能够看下有什么方法能够解决此问题,非常感谢了~~

问题图片:

零后端配置动态菜单功能如何实现(附源码)

四、结语

我今后会逐步的将自己在这个开源项目中学习到的知识,总结成文章分享给大家,也希望大家能够支持下作者,给作者的开源项目提意见,找bug,非常感谢大家了,让我们一起学习进步吧~🥗