转转统一权限系统的设计与实现(前端实现篇)

转转权限系统之前端实现

一、权限前端SDK设计

本次新版设计,Ehr系统会向权限系统同步用户数据,不用再提供用户注册能力。在保证对外接口不变的情况下简化sdk逻辑,对外提供用户信息和用户权限数据。

目前sdk提供以下接口,利用logingetUserPermssion获取用户信息和权限数据,并保存在全局变量中,并提供一个特殊接口routerFilter可利用实现对菜单树状数据进行权限过滤。

二、权限接入方式

接入方式可以有两种方式,对于普通React项目可以使用普通接入,对于Umi项目则可以更加简洁。

普通React项目接入

请务必在挂载入口 前同步调用,保证数据返回后才渲染

import { commonLogin } from '@zz-common/zz-permission'
(async() => {
  const { userInfo = {}, permissionInfo = {} } = await commonLogin({ appCode: '' });
	const { resources } = permissionInfo
  
  render()
})()
复制代码

获取到权限resources后,就可以对菜单进行权限过滤。

为了方便使用,sdk内部提供了默认的处理方式

import { routerFilter } from '@zz-common/zz-permission'
const router = []
const newRouter = routerFilter(router)
复制代码
Umi项目接入

首先需要安装Umi插件

npm install @umijs/plugin-access -D
复制代码

安装完成后,需要修改相应代码

在src/app.ts文件接入权限sdk

// app.ts
import { commonLogin } from '@zz-common/zz-permission'

export async function getInitialState() {
  // permissionInfo-权限信息,userInfo-当前登录用户信息

  const { permissionInfo, userInfo } = await commonLogin({ appCode: '' })
  const { resources = [] } = permissionInfo

  return {
    userInfo,
    permissionList: resources
  }
}
复制代码

调用权限sdk获取到当前登录用户信息和权限数据,放在initialState中。

创建src/access.ts文件

// 权限定义文件
// https://umijs.org/zh-CN/plugins/plugin-access
import routes from '../config/routes'

function findRouteAccessList() {
  const permissions = []
  const stack = [...routes]
  while (stack.length > 0) {
    const route = stack.pop()
    if (route.access) {
      permissions.push(route.access)
    }
    if (route.routes) {
      stack.push(...route.routes)
    }
  }
  return permissions
}

export default (initialState = {}) => {
  const { permissionList = [] } = initialState
  const accessList = permissionList.reduce((access, item) => {
    access[item.code] = true
    return access
  }, {})
  const routePermissions = findRouteAccessList()
  routePermissions.forEach((key) => {
    if (!(key in accessList)) {
      accessList[key] = false
    }
  })
  return accessList
}
复制代码

在这里需要注意下,对于路由配置的access对应的权限编码,umi插件认为只有显示的设置false才认为是没有权限。

现在我们已经完成权限sdk和umi的结合,告知umi项目权限配置。但是如果想更便利的快速生成导航菜单,则需要搭配@umijs/plugin-layout插件

npm install @umijs/plugin-layout -D
复制代码

这样我们在路由配置文件里增加access字段配置

// config/route.ts
export const routes =  [
  {
    path: '/pageA',
    component: 'PageA',
    access: 'canReadPageA', // 对应的权限编码
  }
]
复制代码

权限插件会将用户在这里配置的 access 字符串与当前用户所有权限做匹配,如果找到相同的项,并当该权限的值为 false,则当用户访问该路由时,默认展示 403 页面。

除了菜单受权限控制,当然还有按钮级别的权限控制,我们可以创建一个公共组件CheckAuth来实现对按钮控制

// src/components/CheckAuth
import React from 'react'
import { useAccess, Access } from 'umi'

const CheckAuth = ({ children, permissionCode }) => {
  const access = useAccess()

  const accessible = access[permissionCode]

  return <Access accessible={accessible}>{children}</Access>
}

export default CheckAuth
复制代码

使用CheckAuth组件对按钮进行包裹来进行渲染,当前用户没有权限就不显示按钮

import React from 'react'
import { Button } from 'antd'
import CheckAuth from '@/components/CheckAuth'

const AuthButton = () => (
  <CheckAuth permissionCode="test">
    <Button type="primary">测试权限控制</Button>
  </CheckAuth>
)
复制代码

三、动态权限菜单实现接入

Umi项目系统左侧菜单默认是根据本地路由生成,如果想通过接口来完全生成菜单、注册路由,则可以使用运行时动态路由。

我们在权限系统配置菜单相关权限,通过接口请求到树状结构权限数据,这里就会有个问题,如何使远程数据和本地的路由配置关联,如何重组Umi路由对象。

首先,我们创建几个辅助函数

// dynamicRoute.ts
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { Route } from '@ant-design/pro-layout/lib/typings'
import type { MenuDataItem } from '@ant-design/pro-layout'
import NotFound from '../../pages/404'

function flatRoutesByName(routes: Route[]) {
  const resultRoutes = routes.reduce((obj, item) => {
    if (item.path) {
      obj[item.path] = item
    }
    return obj
  }, {})
  return resultRoutes
}

/**
 * 渲染路由组件
 *
 * @param {*} route 远程路由对象
 * @param {*} flatRoutes 本地拍平路由集合
 * @param {*} hasChildren 是否有子路由
 */
function renderComponent(route: Route, flatRoutes: Route, hasChildren: boolean) {
  let component

  // 当前路由没有子路由
  if (!hasChildren) {
    const { path } = route
    if (path && flatRoutes[path]) {
      component = flatRoutes[path].component
    }
  }

  return component
}

/**
 * 远程路由和本地路由结合组成新路由
 *
 * @param {*} sourceRoutes 远程接口路由配置
 * @param {*} localRoutes 本地路由
 */
export function renderRoutes(sourceRoutes: Route[] = [], localRoutes: Route[] = []) {
  const flatRoutes = flatRoutesByName(localRoutes)
  sourceRoutes = sourceRoutes
    .filter((item) => item.url)
    .map((route) => ({
      ...route,
      path: route.url.toLowerCase()
    }))
  const hideRoutes = localRoutes.filter((route) => route.hideInMenu || route.showInMenu)
  sourceRoutes.push(...hideRoutes)

  const routes: any[] = []

  sourceRoutes.forEach((route) => {
    const { path, name, children, image, type } = route
    const hasChildren = children && children.length > 0 && children.every((item: Route) => item.url)
    const localRoute = (path && flatRoutes[path]) || {}
    const childLocalRoutes = localRoute.routes

    const routeItem: any = {
      ...localRoute,
      name,
      path,
      icon: image,
      exact: !hasChildren,
      component: renderComponent(route, flatRoutes, !!hasChildren)
    }
    if (hasChildren) {
      routeItem.routes = renderRoutes(children, childLocalRoutes)
      routeItem.routes.push({ component: NotFound })
    }
    routes.push(routeItem)
  })

  return routes
}

// 组建重定向路由
export function getRedirectRoute(routes: Route = [], cataloguePath: string = '/') {
  const getRedirectPath: (childRoutes: any) => void = (childRoutes) => {
    const firstParentRoute = childRoutes[0] || {}

    return firstParentRoute?.routes?.length > 0
      ? getRedirectPath(firstParentRoute.routes)
      : firstParentRoute.path
  }
  const redirectPath = getRedirectPath(routes)

  return { path: cataloguePath, redirect: redirectPath, exact: true }
}
复制代码

然后在app.ts文件修改路由配置

import React from 'react'
import type { RunTimeLayoutConfig } from 'umi'
import type { Route } from '@ant-design/pro-layout/lib/typings'
import { parse } from 'query-string'
import { ProBreadcrumb } from '@ant-design/pro-layout'
import { commonLogin } from '@zz-common/zz-permission'
import NotFound from '@/pages/404'
import UnAccessiblePage from '@/pages/403'
import { renderMenuItem, renderBreadcrumItem, renderBreadcrum } from '@/components/CustomizeLayout'
import { renderRoutes, getRedirectRoute } from '@/components/CustomizeLayout/dynamicRoute'
import GlobalHeader from '@/components/GlobalHeader'
import defaultSettings from '../config/defaultSettings'
import type { ZLayoutSettings } from '../config/defaultSettings'

export const dva = {
  config: {
    onError(e: Error) {
      // e.preventDefault()
      console.error(e.message)
    }
  }
}

let userInfo: any
let permissionList: any[]
let authRoutes: any[]

// 渲染之前获取权限信息
export function render(oldRender: () => void) {
  commonLogin({ appId: defaultSettings.systemId })
    .then(({ permissionInfo, userInfo: user }: any) => {
      const { resources = [], resourcesTree = [] } = permissionInfo

      userInfo = user
      permissionList = resources
      authRoutes = resourcesTree
    })
    .catch(() => {})
    .finally(() => {
      oldRender()
    })
}

// 注册路由
export function patchRoutes({ routes }: any) {
  const localRoutes = routes[0].routes[0].routes
  const mainRoutes = renderRoutes(authRoutes, localRoutes)

  // 重定向路由
  const redirectRoute = getRedirectRoute(mainRoutes)
  if (redirectRoute) {
    mainRoutes.unshift(redirectRoute)
  }

  // 404路由
  const notFoundRoute = { component: NotFound }
  mainRoutes.push(notFoundRoute)
  routes[0].routes[0].routes = mainRoutes
}

// 把用户和权限信息放在initialState里
export async function getInitialState(): Promise<{
  userInfo: any
  permissionList: any[]
  authRoutes: any[]
  settings: ZLayoutSettings
}> {
  return {
    userInfo,
    permissionList,
    authRoutes,
    settings: {
      ...defaultSettings,
      collapsed: localStorage.getItem('ui:collapsed') === 'true'
    }
  }
}

// 运行时布局配置
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
  return {
    ...initialState?.settings,
    contentStyle: { margin: useSidebar ? 12 : 0 },
    unAccessible: <UnAccessiblePage />,
    onCollapse: (collapsed: boolean) => {
      if (initialState) {
        setInitialState({
          ...initialState,
          settings: { ...initialState.settings, collapsed }
        })
      }
      localStorage.setItem('ui:collapsed', collapsed.toString())
    },
    menuItemRender: renderMenuItem,
    breadcrumbRender: renderBreadcrum,
    itemRender: renderBreadcrumItem,
    rightContentRender: () => <GlobalHeader />,
    headerContentRender: () => <ProBreadcrumb />
  }
}
复制代码

从代码中可看到,核心是获取权限树状结构数据后,通过patchRoutes修改路由配置,即可实现运行时动态路由,菜单也会同步生成。

四、总结

本文主要介绍权限Sdk如何在前端结合使用,对于Umi和非Umi项目,需要注意的就是在页面渲染前一定要先获取到当前用户权限信息。对于前端页面来讲,权限就是控制菜单、路由、按钮展示,使用公司内部Umi脚手架模板接入权限展示受控菜单则更加简易,因此也是在公司内推荐同学们升级新的脚手架模板。

转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」,各种干货实践,欢迎交流分享~

猜你喜欢

转载自juejin.im/post/7110096929895219213