若依管理系統前端實踐

longxiaoming 發表於 2023-04-08
前端

若依管理系統是一套基於若依框架開發的後臺管理系統,它是一個前後端分離的專案,前端使用vue, Element, 後端使用Spring Boot & Security。這篇隨筆中將記錄一下自己在使用過程中前端使用上的一些收穫和問題。

目錄

1. 路由控制

1.1 簡述

首先是路由控制。若依管理系統前端路由控制的核心在src/permission.js檔案中。其主要邏輯在router.beforeEach中,如下面的流程圖所示:
若依管理系統前端實踐

其核心程式碼如下:

  router.beforeEach(async(to, from, next) => {
    NProgress.start() // 開啟進度條

    document.title = getPageTitle(to.meta.title) // 設定頁面標題

    const hasToken = getToken()

    // 判斷是否有token,如果有則獲取角色與許可權
    if (hasToken) {
      if (to.path === '/login') {
        // 如果已經有token,且訪問的是登入頁面,則跳轉到首頁
        next({ path: '/' })
        NProgress.done()
      } else {
        // 從store中獲取角色與許可權
        const hasRoles = store.getters.roles && store.getters.roles.length > 0
        if (hasRoles) {
          next()
        } else {
          try {
            // 獲取使用者資訊
            const { roles } = await store.dispatch('user/getInfo')

            // 生成路由
            const accessRoutes = await store.dispatch('permission/generateRoutesByRequiredList', roles)

            router.addRoutes(accessRoutes)

            next({ ...to, replace: true })
          } catch (error) {
            // 遇到錯誤則直接重置token,跳轉到登入頁面
            await store.dispatch('user/resetToken')
            Message.error(error || 'Has Error')
            next(`/login?redirect=${to.path}`)
            NProgress.done()
          }
        }
      }
    } else {
      // 沒有token則直接跳轉到登入頁面
      if (whiteList.indexOf(to.path) !== -1) {
        // 無需登入的頁面則寫進whiteList中,可以直接訪問
        next()
      } else {
        next(`/login?redirect=${to.path}`)
        NProgress.done()
      }
    }
  })
  router.afterEach(() => {
    NProgress.done() // 結束進度條
    /* 由https://github.com/PanJiaChen/vue-element-admin/pull/2939可知
    * afterEach並不總是會被呼叫,例如在首頁手動跳轉到登入頁面,上面程式碼在登入的情況下會跳轉到首頁,則afterEach不會被呼叫
    * 所以即使在這裡寫了NProgress.done(),在某些情況下也要在beforeEach中單獨手動呼叫NProgress.done()
    */
  })

在這其中使用了NProgress來顯示頁面載入進度條,NProgress是一個輕量級的進度條外掛,只需要在路由跳轉前呼叫NProgress.start(),在路由跳轉後呼叫NProgress.done()即可。

1.2 token的檢驗

在每次路由跳轉前先判斷了是否有token,如果有token則獲取使用者角色與許可權並進一步生成路由,如果沒有token則跳轉到登入頁面。這裡token的檢驗進入到src/utils/auth.js檔案中檢視:

import Cookies from 'js-cookie'
const TokenKey = 'Token'
export function getToken() {
  return Cookies.get(TokenKey)
}
export function setToken(token) {
  return Cookies.set(TokenKey, token)
}
export function removeToken() {
  return Cookies.remove(TokenKey)
}

可以看到,token的儲存、獲取與刪除是使用了js-cookie這個外掛,這個外掛可以方便地操作cookie。在這裡,token的儲存是使用了cookie,也可以使用localStorage或者sessionStorage。

1.3 獲取角色許可權

判斷是否有角色許可權的語句為const hasRoles = store.getters.roles && store.getters.roles.length ,這裡的store.getters.roles的實際具體位置在src/store/modules/user.js檔案中,而獲取角色的store.dispatch('user/getInfo')也同樣是在src/store/modules/user.js檔案中,這是用了vuex的模組化管理,將不同的模組分別放在不同的檔案中,這樣可以使得程式碼更加清晰,方便管理。在src/store/modules/user.js檔案中,可以看到如下程式碼。
首先是state,用於儲存使用者資訊:

const state = {
  token: getToken(),
  name: '',
  avatar: '',
  introduction: '',
  roles: []
}

然後是mutations,用於修改state中的資料,使用時需要使用store.commit('SET_ROLES', roles)的形式:

const mutations = {
  SET_TOKEN: (state, token) => {
    state.token = token
  },
  SET_INTRODUCTION: (state, introduction) => {
    state.introduction = introduction
  },
  SET_NAME: (state, name) => {
    state.name = name
  },
  SET_AVATAR: (state, avatar) => {
    state.avatar = avatar
  },
  SET_ROLES: (state, roles) => {
    state.roles = roles
  }
}

最後是actions,用於非同步修改state中的資料,使用時需要使用store.dispatch('user/getInfo')的形式,這裡主要列一下獲取使用者資訊的程式碼:

const actions = {
  // get user info
  getInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
    //   getInfo(state.token).then(response => {
      const data = {
        roles: ['admin'],
        introduction: 'I am a super administrator',
        avatar: 'xxx',
        name: 'Super Admin' }

      if (!data) {
        reject('Verification failed, please Login again.')
      }

      const { avatar, roles, introduction } = data

      // roles must be a non-empty array
      if (!roles || roles.length <= 0) {
        reject('getInfo: roles must be a non-null array!')
      }
      commit('SET_AVATAR', avatar)
      commit('SET_ROLES', roles)
      commit('SET_INTRODUCTION', introduction)
      resolve(data)
    }).catch(error => {
      // eslint-disable-next-line no-undef
      // reject(error)
      console.log(error)
    })
    // })
  },

}

上述程式碼中的roles是寫死的['admin'],實際使用時需要根據後端返回的資料進行修改。

1.4 生成路由

在獲取角色許可權後,需要根據角色許可權生成路由。生成路由的程式碼在src/store/modules/permission.js中:

const actions = {
  generateRoutes({ commit }, roles) {
    return new Promise(resolve => {
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

呼叫了filterAsyncRoutes函式進行從路由列表中根據角色許可權篩選出符合條件的路由,然後將篩選出的路由新增到路由中,最後返回篩選出的路由。

function filterAsyncRoutes(routes, roles) {
  const res = []
  routes.forEach(route => {
    const tmp = { ...route }
    if (hasRole(roles, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
      res.push(tmp)
    }
  })
  return res
}

判斷路由是否符合條件的函式為hasRole,主要透過路由檔案中的meta中的roles來判斷:

function hasRole(roles, route) {
  if (route.meta && route.meta.roles) {
    return roles.some(role => route.meta.roles.includes(role))
  } else {
    return true
  }
}

需要讓hasRole正確執行則需要在router/index.js中為每個路由設定好對應的角色許可權:

{
  path: '/permission',
  component: Layout,
  redirect: '/permission/page',
  alwaysShow: true, // will always show the root menu
  name: 'Permission',
  meta: {
    title: 'Permission',
    icon: 'lock',
    roles: ['admin', 'editor'] // 可以在根導航中設定角色
  },
  children: [
    {
      path: 'page',
      component: () => import('@/views/permission/page'),
      name: 'PagePermission',
      meta: {
        title: 'Page Permission',
        roles: ['admin'] // 或者在子導航中設定角色
      }
    },
    {
      path: 'directive',
      component: () => import('@/views/permission/directive'),
      name: 'DirectivePermission',
      meta: {
        title: 'Directive Permission'
        // 如果不設定角色,則表示:此頁不需要許可權
      }
    },
    {
      path: 'role',
      component: () => import('@/views/permission/role'),
      name: 'RolePermission',
      meta: {
        title: 'Role Permission',
        roles: ['admin']
      }
    }
  ]
}

在router/index.js中有兩組路由,一組是constantRoutes,一組是asyncRoutesconstantRoutes是不需要許可權的路由,asyncRoutes是需要許可權的路由。後續生成路由時則是在asyncRoutes中進行篩選的。

2. 管理系統的幾個元件

這裡主要說一下左側選單欄和頂部TagsView。

2.1 生成選單

在生成路由後,需要根據路由生成選單。選單的生成是在src/layout/components/Sidebar/index.vue中進行的,程式碼如下:

<el-menu
  :default-active="activeMenu"
  :collapse="isCollapse"
  :background-color="variables.menuBg"
  :text-color="variables.menuText"
  :unique-opened="false"
  :active-text-color="variables.menuActiveText"
  :collapse-transition="false"
  mode="vertical"
>
  <sidebar-item v-for="route in permission_routes" :key="route.path" :item="route" :base-path="route.path" />
</el-menu>

這裡的permission_routes是用了vuexmapGetters來獲取的,程式碼如下:

import { mapGetters } from 'vuex'
export default {
  computed: {
    ...mapGetters(['permission_routes'])
  }
}

mapGetters的作用是將store中的getters對映到元件的computed中,這樣就可以在元件中直接使用this.permission_routes來獲取store中的permission_routes

2.2 TagsView

TagsView是用來顯示當前開啟的頁面的,可以快取之前開啟過的頁面,效果如下如所示:
若依管理系統前端實踐

訪問過以及快取的路由儲存在store/modules/tagsView.js中:

const state = {
  visitedViews: [],
  cachedViews: []
}

上述程式碼中的visitedViews用來儲存訪問過的路由,cachedViews用來儲存快取的路由。如果在路由的meta中設定了noCachetrue,則不會快取該路由。
tagsView.js中的其他程式碼主要用於新增、刪除、清空路由等操作,如addViewdelViewdelOthersViews等,這裡不再贅述。
TagsView對應的元件在src/layout/components/TagsView/index.vue中,程式碼如下:

<div id="tags-view-container" class="tags-view-container">
<scroll-pane ref="scrollPane" class="tags-view-wrapper"@scroll="handleScroll">
  <router-link
    v-for="tag in visitedViews"
    ref="tag"
    :key="tag.path"
    :class="isActive(tag)?'active':''"
    :to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
    tag="span"
    class="tags-view-item"
    @click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
    @contextmenu.prevent.native="openMenu(tag,$event)"
  >
    {{ tag.title }}
    <span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
  </router-link>
</scroll-pane>
<ul v-show="visible" :style="{left:left+'px',top:top+'px'}"class="contextmenu">
  <li @click="refreshSelectedTag(selectedTag)">Refresh</li>
  <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">Close</li>
  <li @click="closeOthersTags">Close Others</li>
  <li @click="closeAllTags(selectedTag)">Close All</li>
</ul>
</div>

獲取visitedViews是在computed中獲取的:

  computed: {
    visitedViews() {
      return this.$store.state.tagsView.visitedViews
    },
    routes() {
      return this.$store.state.permission.routes
    }
  }

3. 實際實踐中自己做出的一些修改

自己在實踐時的程式碼放在了github上:
https://github.com/lxmghct/UserRolePermission

3.1 路由控制

可以看到若依管理的前端是透過角色來控制路由生成的,而在我實踐的專案中,將許可權分為了三級:模組許可權、頁面許可權、操作許可權,路由則是由頁面許可權直接控制的。所以需要進行一些修改。在實際使用中嘗試了以下兩種修改方式,

3.1.1 由後端儲存路由並分配許可權

我嘗試的第一種是直接將對應頁面的路由儲存在資料庫的許可權相應的欄位中,當使用者登入時,後端將使用者所能訪問的路由全部返回給前端,前端據此生成對應的路由。在router/index.js中則無需設定路由對應的角色或許可權。
在store/modules/user.js中,使用者登入時將後端返回的路由component存進localStorage或者sessionStorage中,這一步是必要的,否則在重新整理頁面時,路由會丟失,導致重新跳轉到登入頁面。

const actions = {
  // user login
  login({ commit }, userInfo) {
    const params = new URLSearchParams()
    params.append('userName', userInfo.username)
    params.append('password', md5(userInfo.password))
    return new Promise((resolve, reject) => {
      login(params).then(response => {
        commit('SET_TOKEN', 'admin-token')
        // 獲取後端返回的路由
        // 將路由的每一級都儲存在一個set中,用於生成路由
        const componentSet = new Set()
        if (response.data.component && response.data.component.length > 0) {
          response.data.component.forEach(item => {
            const temp = item.replace(/^\//, '').replace(/\/$/, '').replace(/^system\//, '')
            let index = temp.indexOf('/')
            while (index > 0) {
              componentSet.add(temp.substring(0, index))
              index = temp.indexOf('/', index + 1)
            }
            componentSet.add(temp)
          })
        }
        componentSet.add('')
        localStorage.setItem('component', Array.from(componentSet))
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
}

然後再getInfo中,拿到component儲存在state中,並作為返回值返回給src/permissio.js對應的語句,並傳遞給生成路由的函式。這裡我另外定義了一個generateRoutesByRequiredList,程式碼如下:

const actions = {
  generateRoutesByRequiredList({ commit }, routeList) {
    return new Promise(resolve => {
      const accessedRoutes = filterAsyncRoutesInRequiredList(asyncRoutes, routeList, '')
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

function filterAsyncRoutesInRequiredList(routes, routeList, base) {
  const res = []
  // 過濾掉不在requiredList中的路由
  routes.forEach(route => {
    const tmp = { ...route }
    // 這裡對path格式統一處理去掉開頭和結尾的'/',便於判斷
    let path = tmp.path.replace(/^\//, '').replace(/\/$/, '')
    path = base === '' ? path : base + '/' + path
    path = path.replace(/^\//, '').replace(/^\//, '')
    if (routeList.includes(path)) {
      if (tmp.children) {
        // 考慮到子路由的情況,遞迴呼叫
        tmp.children = filterAsyncRoutesInRequiredList(tmp.children, routeList, base + '/' + path)
      }
      res.push(tmp)
    }
  })

  return res
}

這種策略在專案結構極為簡單的情況下勉強可以使用。在一般情況下實際使用中有很多弊端。

  • 首先是前端的路由名稱與後端資料庫要同步,這對新增路由和修改路由名稱都會帶來很大的麻煩。
  • 其次是由於每個頁面都單獨對應了一個路由,在管理頁面許可權的時候,就不得不給每一個頁面都加上一個許可權,會使許可權控制出現大量不必要的重複功能的許可權。雖然可以考慮在資料庫儲存時每個許可權可以儲存多個路由,但實際上已經將問題複雜化了。

3.1.2 直接將路由中的角色替換為許可權

直接將若依管理系統前端路由部分涉及角色的地方替換為許可權,或者另外增加一個變數去儲存許可權。這種方法顯然實現起來更容易且更可靠。一開始我嘗試採用3.1.1中的方法確實有些多此一舉了。

src/store/modules/user.js中在登入時將後端返回的permission儲存在sessionStorage或localStorage中,這一步是必要的,否則在重新整理頁面時,路由會丟失,導致重新跳轉到登入頁面。這裡permission中儲存的是當前使用者所擁有的的所有許可權程式碼的列表。

const actions = {
  // user login
  login({ commit }, userInfo) {
    const params = new URLSearchParams()
    params.append('username', userInfo.username)
    params.append('password', userInfo.password)
    return new Promise((resolve, reject) => {
      login(params).then(response => {
        commit('SET_TOKEN', 'admin-token')
        localStorage.setItem('userId', response.data.user.id)
        sessionStorage.setItem('permission', response.data.user.permissions || [])
        sessionStorage.setItem('loginInformation', JSON.stringify(response.data))
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },

  // get user info
  getInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
      const permissions = sessionStorage.getItem('permission')
      const data = {
        roles: permissions ? permissions.split(',') : [], // 這裡為圖方便就直接將許可權賦值給角色了,省的多建一個變數
        introduction: 'I am a super administrator',
        avatar: 'xxxx',
        name: 'Super Admin' }

      if (!data) {
        reject('Verification failed, please Login again.')
      }

      const { avatar, roles, introduction } = data

      // roles must be a non-empty array
      if (!roles || roles.length <= 0) {
        reject('getInfo: roles must be a non-null array!')
      }
      commit('SET_AVATAR', avatar)
      commit('SET_ROLES', roles)
      console.log(state.roles)
      commit('SET_INTRODUCTION', introduction)
      resolve(data)
    }).catch(error => {
      // eslint-disable-next-line no-undef
      // reject(error)
      console.log(error)
    })
    // })
  }
}

在生成路由的時候,將路由篩選的條件替換為permission,在store/modules/permission.js中修改如下:

function hasPermission(permissions, route) {
  if (route.meta && route.meta.permissions) {
    return permissions.some(permission => route.meta.permissions.includes(permission))
  } else {
    return true
  }
}

function filterAsyncRoutes(routes, permissions) {
  const res = []

  routes.forEach(route => {
    const tmp = { ...route }
    if (hasPermission(permissions, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, permissions)
      }
      res.push(tmp)
    }
  })

  return res
}

const actions = {
  generateRoutes({ commit }, permissions) {
    return new Promise(resolve => {
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, permissions)
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

相應的在src/router/index.js中將meta中的roles換為permissions,這裡就不多贅述。

3.2 許可權管理

由於在實際專案中將許可權分成了模組許可權、頁面許可權、操作許可權三級,所以許可權管理的時候就把所有許可權用樹形結構展示出來,這樣就可以很方便的管理許可權了。這裡就不多贅述了。

4. 若依管理路由控制的其他應用

從這次若依管理系統的路由控制的實現過程中,我也學到了這樣一種動態生成路由的方式。事實上這種方式也可以應用於除了管理系統以外的一般的前端專案,只需將選單等其他部分忽略,將路由控制的那部分提取出來即可。
這部分程式碼也放在了github上,
https://github.com/lxmghct/UserRolePermission
這裡我沒有去使用vuexjs-cookie

4.1 router

首先在router/index.js中,仍然保留constantRoutesasyncRoutes兩個變數,同時也保留createRouterresetRouter兩個函式。由於不需要生成選單,所以meta這一屬性也可以去掉了,許可權的判斷直接在每個路由加上permissions屬性即可。

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export const constantRoutes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login')
  }
]

export const asyncRoutes = [
  {
    path: '/home',
    name: 'Home',
    component: () => import('@/views/Home'),
    permissions: ['TEST_MAIN']
  },
  {
    path: '/page1',
    name: 'Page1',
    component: () => import('@/views/Page1'),
    permissions: ['TEST_PAGE1']
  },
  {
    path: '/page2',
    name: 'Page2',
    component: () => import('@/views/Page2'),
    permissions: ['TEST_MAIN']
  }
]

const createRouter = () => new Router({
  // mode: 'history', // require service support
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRoutes
})

const router = createRouter()

export function resetRouter () {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher // reset router
}

export default router

4.2 store

在store中只要用到user.js,所以只保留這部分在store/user.js中。

import { resetRouter } from '@/router'

export const userStore = {
  roles: [],
  permissions: []
}

export const UserUtils = {
  resetUserStore () {
    userStore.roles = []
    userStore.permissions = []
    resetRouter()
  }
}

由於沒使用vuex,所以也無需建立getters.js。

4.3 permission.js

我把路由守衛以及路由生成的程式碼都統一放在了src/permission.js中。沒使用vuex,不過直接import對應的變數也可以獲取到需要的值。沒使用js-cookie,所以直接使用sessionStorage來儲存使用者資訊。之所以要另外再弄個user.js將sessionStorage的操作封裝起來,我覺得可能是有兩個原因,一個是用於判斷使用者是否已經生成過了路由,還有一個作用就是將使用者的許可權資訊存在記憶體中,即使使用者修改了sessionStorage中的值,也不會影響到使用者的許可權資訊。當然如果想要修改也是能做到的,因為user.js中的內容會在重新整理後消失,所以修改sessionStorage中的值後,重新整理頁面就會重新生成路由了。

import router, { asyncRoutes } from './router'
import { userStore, UserUtils } from './store/user'

function hasPermission (permissions, route) {
  if (route.permissions) {
    return permissions.some(permission => route.permissions.includes(permission))
  } else {
    return true
  }
}

function filterAsyncRoutes (routes, permissions) {
  const res = []

  routes.forEach(route => {
    const tmp = { ...route }
    if (hasPermission(permissions, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, permissions)
      }
      res.push(tmp)
    }
  })

  return res
}

const whiteList = ['/login'] // no redirect whitelist

router.beforeEach(async (to, from, next) => {
  const loginInfo = sessionStorage.getItem('loginInformation')
  if (loginInfo) {
    if (to.path === '/login') {
      // if is logged in, redirect to the home page
      next({ path: '/home' })
    } else {
      const hasPermission = userStore.permissions && userStore.permissions.length > 0
      if (hasPermission) {
        if (router.getRoutes().map(item => item.path).includes(to.path)) {
          next()
        } else {
          next({ path: '/login' })
        }
      } else {
        try {
          userStore.permissions = JSON.parse(loginInfo).user.permissions
          // generate accessible routes map
          const accessRoutes = filterAsyncRoutes(asyncRoutes, userStore.permissions)
          if (accessRoutes.length === 0 || userStore.permissions.length === 0) {
            throw new Error('No permission')
          }
          // dynamically add accessible routes
          accessRoutes.forEach(item => { router.addRoute(item) })

          // set the replace: true, so the navigation will not leave a history record
          next({ ...to, replace: true })
        } catch (error) {
          sessionStorage.removeItem('loginInformation')
          UserUtils.resetUserStore()
          next(`/login?redirect=${to.path}`)
        }
      }
    }
  } else {
    /* not logged in */
    if (whiteList.indexOf(to.path) !== -1) {
      // in the free login whitelist, go directly
      next()
    } else {
      // other pages that do not have permission to access are redirected to the login page.
      next(`/login?redirect=${to.path}`)
    }
  }
})

4.4 登入登出

登入時將使用者資訊儲存在sessionStorage中, 登出時將sessionStorage中的使用者資訊清除。

sessionStorage.setItem('loginInformation', JSON.stringify(res.data));
// sessionStorage.removeItem('loginInformation');

4.5 操作許可權的控制

操作許可權最主要還是要透過後端去控制,不過前端也可以根據許可權去選擇隱藏或禁用一些不允許使用的按鈕,透過v-if:disabled之類的方式來控制。
可以在main.js中進行相應的配置使許可權寫起來更方便一些。
首先是在4.2中的user.js中新增一個hasPermission方法,用於判斷使用者是否有許可權。

export const hasPermission = (permissions) => {
  // 為了寫起來方便,這裡同時支援傳入陣列和字串
  if (Array.isArray(permissions)) { 
    return permissions.some(permission => userStore.permissions.includes(permission))
  } else {
    return userStore.permissions.includes(permissions)
  }
}

然後在main.js中全域性註冊判斷許可權的方法。

import { hasPermission } from '@/store/user'
Vue.prototype.$permission = { has: hasPermission }

這樣在元件中就可以透過this.$permission.has('TEST_MAIN')this.$permission.has(['TEST_MAIN', 'TEST_SUB'])來判斷使用者是否有許可權了。

<el-button v-if="$permission.has('TEST_MAIN')" type="primary" @click="handleAdd">新增</el-button>