因为之前一直都是做移动端开发的项目,所以忽视了现在主流的PC上的后台管理端,导致前段时间在重新找工作的时候吃了个大亏,不是技术上不会,而且PC端确实做的少,存在一些架构或者解决方案的不了解,这就间接的被误认为技术不咋地或者给人感觉不行的错觉...自认为本人还是比较好学的,平时也会看看新技术或者练习一些好玩的代码尝试,对着代码其实也是有热爱和关注的,但就这次的面试经历来说其实是不友好的,第一个本人不善言辞,知道的东西也不会说的很全,第二就是不会夸大吹牛,当然这肯定不好的,但有句话是这样说的 ”唬得住30k,唬不住3k“ ,这也说明很多东西也需要一些表达成分的,再就是项目涉及上,一直以移动为主的话,PC上的管理项目确实有待欠缺。撑着工作之余还是重新沉下心看了先阶段的明星前端框架 vue-element-admin和一些其他比较主流的前端架构实现,自己也写了一个初级的架构。

开始

不考虑中间的一些业务代码,这个项目只考虑了前端的登录流程和动态路由实现。业务代码谁都可以写,无非就是一些掉接口增删改查数据,再就是加一些动画之类的,下面讲就要说的是业务之外流程,就登陆的流程来说还是有必要拿来说一下的。

我们都知道一个项目的开始的第一步一定是少不了登录的操作的,说难不难,说简单也没那么简单。简单的地方就是输入账号密码点击登录然后再掉一个登录接口就完事。难的地方涉及到以下几个:

  1. 接口的封装
  2. 登录的校验
  3. 携带 token 请求校验和返回数据处理
  4. 保存登录信息到缓存和vuex中
  5. 路由权限控制
  6. 获取用户角色信息和权限
  7. 通过角色信息获取菜单
  8. 过滤接口返回来的菜单
  9. 保存菜单到缓存确保刷新可用
  10. ...

可能写的这么写还不够完全的展示登录的流程,但也说了大部分的一些操作了。可以看下流程图便于理解;

登录流程图

当然其中的一些细节代码不好在展示,但是大致的流程就是这样的简单。下面开始说下具体代码;

路由配置

按照平常的项目文件规划,可以把路由拦截的代码单独拿出来写作一个路由访问的权限处理,使用的时候就直接在 main.js 中引入即可。

新建 permission.jsmain.js 同级目录下。

import router from '@/router'
import { useUserStore } from '@/store/user'
import { useRouterStore } from '@/store/router'

// 设置路由拦截器
router.beforeEach(async (to, from, next) => {
  // 获取用户信息
  const userStore = useUserStore()
  const routerStore = useRouterStore()
  // 判断用户是否登录
  if (userStore.getUserToken) {
    // 如果用户信息不存在,就通过 token 重新获取
    if (!userStore.getLoginStatus) {
      await userStore.getUserInfo()
      const routers = await routerStore.getUserRouters()
      // 动态添加路由
      routers.forEach((item) => {
        router.addRoute(item)
      })

      // 添加完动态路由之后,需要在进行一次主动跳转
      return next({ ...to, replace: true })
    }
    next()
  } else {
    next('/login')
  }
})

根据上图中的流程简单的写一下权限的代码,在路由的钩子函数中 beforeEach 拦截,在方法里面获取到 piniastore 值,然后再根据里面的 getter 获取用户的 token 值和登录状态信息,拿到信息之后按照之前说的直接可以动态的添加用可用的权限菜单了。在这之前我们可以先看一下根据后端约定返回的用户菜单信息接口是什么样的格式。

确保后端返回的正确格式信息给前端,这样在过滤可用的路由菜单时才不会出错导致页面渲染不出来,当然这下面的参数只是基本的内容,可以自行在里面添加需要的字段,比如说 keep-alive 等。

{
    "success": true,
  "code": 0,
  "message": "成功",
  "data": {
    "routers": [
        {
          "name": "System",                    // 组件名字
        "path": "/system",                 // 组件路由路径
        "component": "Layout",        // 组件对应的项目页面信息
        "hidden": false,                    // 是否隐藏该页面
        "meta": {                                     // 需要传入的参数
          "title": "系统管理",     // 页面名称
          "icon": "system"                // 页面的图标
        },
        "children": [                            // 组件下面的子页面
          {
            "name": "User",
            "path": "user",
            "component": "system/user",
            "hidden": false,
            "meta": {
              "title": "用户管理",
              "icon": "user"
            }
          },
          {
            "name": "Role",
            "path": "role",
            "component": "system/role",
            "hidden": false,
            "meta": {
              "title": "角色管理",
              "icon": "role"
            }
          },
          {
            "name": "Menu",
            "path": "menu",
            "component": "system/menu",
            "hidden": false,
            "meta": {
              "title": "菜单管理",
              "icon": "menu"
            }
          }
        ]
      },
      {
        "name": "Monitor",
        "path": "/monitor",
        "component": "Layout",
        "hidden": false,
        "meta": {
          "title": "系统监控",
          "icon": "monitor"
        },
        "children": [
          {
            "name": "Online",
            "path": "online",
            "component": "monitor/online",
            "hidden": false,
            "meta": {
              "title": "在线用户",
              "icon": "online"
            }
          }
        ]
      }
    ]
  }
}

大致上是以这个结构为基础的返回,在拿到路由信息之后需要对路由进行有效的过滤和筛选,其目的有两个。

  1. 将过滤之后的数据添加到路由中,可以保证在输入页面地址可以访问
  2. 将过滤之后的数据保存在缓存中,可以保证刷新的时候可以重新添加并可以访问到

因为在刷新之后,存在 pinia 中的数据是会被销毁的,需要重新获取并插入到 piniastate 中,vuex 也是同理。

Axios 封装

在看登录信息之前先看一下 axios 的请求封装,针对拦截器里面的 header 添加 token 加入去拿接口数据,再加上前端控制的登录超时自动退出,还有相应拦截的错误处理。

配置请求的 token 非常简单,只需要在 axios 中设置请求拦截器,在对应的回调函数中把 token 加入到 header 中,具体操作如下;

import axios from 'axios'
import { useUserStore } from '@/store/user'

const http = axios.create({
  baseURL: process.env.VUE_APP_API_URL,
  timeout: 5000
})

// 设置请求拦截器
http.interceptors.request.use(
  (res) => {
    const userStore = useUserStore()
    if (userStore.token) {
      res.headers['X-Access-Token'] = userStore.token
    }
    return res
  },
  (err) => {
    return Promise.reject(err)
  }
)

获取 userStore 中的 token 值是否存在,并把其放到接口的请求头中。

当然这里面的操作在复杂的项目中远不止这么简单,有很多接口需要前端在请求之前做一些数据的处理,比如说到的安全问题,就可以在这里加上对数据的加密,操作对传进来的数据进行校验防止 xss 攻击等等。就本次示例来说只加上 token 和超时登录的自动退出。

超时自动退出

一般来说用户退出登录有两种操作。

  1. 用户 主动 退出
  2. 用户 被动 退出

解释一下就是,主动退出是指,用户点击退出按钮之后的退出操作;被动退出是指,登录用的 token 过期或者被其他人 ”顶替(登录了相同的账号)“ 时的退出。

这里本次内容只讲 token 过期时的登录,被其他人顶下了的操作展示不管,需要后端配合操作,这里就涉及到单点登录的问题了。主动退出就没什么好说的了,就直接调用退出登录的接口,然后清理缓存和权限再返回到登录页。

这里就讲一下被动退出的解决方案,其实也很简单,就是在登录的时候保存当前的时间戳,通过每次请求接口时,也就是在 Axios 的请求拦截里面判断当前的时间戳和之前登录保存的时间戳的差值是否大于设定的时间,这样的话如果是超过了时间,就直接返回,被动退出账号。

新建一个 auth.js 文件用来专门处理认证时间过期。

import { setStorage, getStorage } from './storage'
import { TIME_STAMP, TOKEN_TIMEOUT_VALUE } from './constant'

// 获取时间戳
export const getTimestamp = () => {
  return getStorage(TIME_STAMP)
}

// 设置当前时间戳
export const setTimestamp = () => {
  setStorage(TIME_STAMP, new Date().getTime())
}

// 判断是否超时
export const isCheckTimeout = () => {
  const timestamp = getTimestamp()
  if (!timestamp) {
    return true
  }
  const now = new Date().getTime()
  return now - timestamp > TOKEN_TIMEOUT_VALUE
}

引入 storage.js 缓存的控制,这个文件就不单独写出来,就是简单的 window.localStorage 的封装,包括新增数据,删除数据,获取数据,清空数据。另外两个常量字段 TIME_STAMPTOKEN_TIMEOUT_VALUE 则是时间戳的键值和设置所需过期的时间段。针对时间戳的获取来判断是否是超时登录,导出一个 isCheckTimeout 的函数判断。下面就可直接在 Axios 的请求拦截里面加上此判断,保证用户超时的被动退出。

import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { isCheckTimeout } from '@/utils/auth'

// 设置请求拦截器
http.interceptors.request.use(
  (res) => {
    const userStore = useUserStore()
    if (userStore.token) {
      if (isCheckTimeout()) {
        userStore.userLogout()
        ElMessage.error('登录超时,请重新登录')
        return Promise.reject(new Error('登录超时,请重新登录'))
      }
      res.headers['X-Access-Token'] = userStore.token
    }
    return res
  },
  (err) => {
    return Promise.reject(err)
  }
)

说完请求拦截里面的被动退出,再看一下相应拦截的处理。顾名思义,相应接口的返回数据之前做的一个拦截,目的也是为了做接口返回的数据格式化,比如返回加密文件可以在这里做解密操作,可以通过返回数据的类型给出相应的处理,服务错误的时候可以做一个消息提示优化用户体验。

// 设置相应拦截器
http.interceptors.response.use(
  (res) => {
    const { success, message, data } = res.data
    // 可以用 success 或者 code 来判断用户是否是失败的状态,根据项目来
    if (success) {
      return data
    } else {
      ElMessage.error(message)
      return Promise.reject(message)
    }
  },
  (err) => {
    switch (err.response && err.response.status) {
      case 401:
        ElMessage.error('登录超时,请重新登录')
        break
      case 403:
        ElMessage.error('没有权限')
        break
      case 404:
        ElMessage.error('请求资源不存在')
        break
      case 500:
        ElMessage.error('服务器错误')
        break
      default:
        ElMessage.error('未知错误')
    }

    return Promise.reject(err)
  }
)

获取路由配置信息

下面就要开始做动态路由的获取了,在前面的路由拦截中有一段请求路由的信息配置的接口 routerStore.getUserRouters() ,通过这个 actions 函数把关于路由方面的操作都写到新的 route.js 文件中。

store 文件下新建一个 route.js ,用来存放路由的操作处理。

import { defineStore } from 'pinia'
import { getRouters } from '@/api/router'
import { baseRouter, errorRouter } from '@/router'
import { formateAsyncRouters, formateRouters } from '@/utils/router'

export const useRouterStore = defineStore({
  id: 'router',
  state() {
    return {
      sidebarRouters: []
    }
  },
  actions: {
    async getUserRouters() {
      const { routers } = await getRouters()
      // 合并默认路由和用户路由
      const allRouter = baseRouter.concat(routers)
      // 把错误页面添加到路由中
      allRouter.push(errorRouter)
      // 格式化路由
      const asyncRouters = formateAsyncRouters(allRouter)
      console.log(asyncRouters)
      // 删选可用侧边栏路由
      const sidebarRouters = formateRouters(asyncRouters)
      console.log(sidebarRouters)
      // 保存路由到 store 中
      this.sidebarRouters = sidebarRouters

      return asyncRouters
    }
  },
  getters: {
    getSidebarRouters: (state) => state.sidebarRouters
  }
})

其中 getRouters() 是访问后端接口的请求,返回来的数据格式就上文所说,然后拿到路由信息之后把基础路由 baseRouter 合并进去,再把错误页面的路由也添加进数据中,最后格式化路由处理,相当于返回来的 component 字段中的值要对应前端页面的路径,对格式化的路由后面再做筛选侧边栏,保证侧边栏可以点击的时候访问到页面,顺便根据里面的字段 titleicon 过滤一下可显示的菜单展示。具体可看代码;

import Layout from '@/layout'

// 格式化拼接路由并转换路由格式
export const formateAsyncRouters = (routers) => {
  return routers.map((router) => {
    const currentRouter = {
      ...router,
      component:
        router.component === 'Layout' ? Layout : loadView(router.component)
    }
    // 若遍历的当前路由存在子路由,需要对子路由进行递归遍历
    if (router.children && router.children.length > 0) {
      currentRouter.children = formateAsyncRouters(router.children)
    } else {
      delete currentRouter.children
    }
    return currentRouter
  })
}
// import 加载路由页面
function loadView(view) {
  return () => require.ensure([], (require) => require(`../views/${view}`))
}

使用递归的方式处理含有子元素的 component 路径,如果是 Layout 页面就直接引入,不是的话就做一个懒加载处理。没有子元素的 children 就删除字段,为后续的筛选侧边栏做铺垫。

侧边栏格式化:

import Layout from '@/layout'
import { isNull } from '@/utils/utils'

// 删选可用侧边栏路由
export const formateRouters = (routers, basePath = '') => {
  const result = []
  // 遍历路由表
  routers.forEach((router) => {
    // 过滤属性 hidden 为 true 的路由
    if (router.hidden) return
    // 过滤没有 children 并且没有 meta 的路由项
    if (isNull(router.children) && isNull(router.meta)) return
    // 如果有 children 但是没有 meta
    if (!isNull(router.children) && isNull(router.meta)) {
      // 如果 children 为空,则添加到结果中
      result.push(...formateRouters(router.children))
      return
    }
    // 如果有 meta 但是没有 children
    // 1. 先合并 path
    const routePath = path.resolve(basePath, router.path)
    // 2. 判断是否有同名路径
    let samePath = result.find((item) => item.path === routePath)
    // 3. 没有同名路径就添加到 result 中
    if (!samePath) {
      samePath = {
        ...router,
        path: routePath,
        children: []
      }
      // 如果有 title 的情况下就是 需要显示路由
      if (samePath.meta.icon && samePath.meta.title) {
        result.push(samePath)
      }
    }
    // 如果 children 和 meta 都存在
    if (router.children) {
      samePath.children.push(...formateRouters(router.children, router.path))
    }
  })
  return result
}

展示菜单

拿到对应的侧边栏数据之后就是在菜单中显示了,这里用到的 UI 库是 element-ui ,所以这边用的是 el-menu 用递归的方式把所有的菜单展示出来。

<template>
  <div class="aside">
    <el-menu
      :default-active="isActive"
      :unique-opened="true"
      router
    >
      <layout-aside-item :router="getSidebarRouters"></layout-aside-item>
    </el-menu>
  </div>
</template>

<script setup>
import LayoutAsideItem from './layout-aside-item'
import { computed } from 'vue'
import { useRouterStore } from '@/store/router'
import { useRoute } from 'vue-router'

const routerStore = useRouterStore()
const getSidebarRouters = computed(() => routerStore.getSidebarRouters)

const route = useRoute()
const isActive = computed(() => route.path)
</script>

LayoutAsideItem 组件内容:

<template>
  <template v-for="item in router">
    <el-sub-menu
      v-if="item.children && item.children.length > 0"
      :index="item.path"
      :key="item.path"
    >
      <template #title>
        <svg-icon class="icon" :icon="item.meta.icon"></svg-icon>
        <span>{{ item.meta.title }}</span>
      </template>
      <layout-aside-item :router="item.children"></layout-aside-item>
    </el-sub-menu>
    <el-menu-item v-else :index="item.path" :key="item.path">
      <svg-icon class="icon" :icon="item.meta.icon"></svg-icon>
      <span>{{ item.meta.title }}</span>
    </el-menu-item>
  </template>
</template>

<script setup>
import { defineProps } from 'vue'
defineProps({
  router: {
    type: Object,
    required: true
  }
})
</script>

以上的都是些简答的代码实现,针对业务的不同可以做不同的调整和判断,但是就简单的来说,万变也不离其宗,玩来玩去也就是权限和菜单再加上针对业务上的一些处理,以及安全问题等。好了这次就说到这里,如果感兴趣可以看一下源码