介绍

V3 Admin Vite 是一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术。另外还有:

目录结构

# v3-admin-vite
├─ .husky                # 用户提交代码时格式化代码
├─ .vscode               # 本项目推荐的 vscode 配置和拓展
├─ public
│  ├─ favicon.ico
│  ├─ app-loading.css    # 首屏加载 loading
├─ src
│  ├─ api                # api 接口
│  ├─ assets             # 静态资源
│  ├─ components         # 全局组件
│  ├─ config             # 全局配置
│  ├─ constant           # 常量/枚举
│  ├─ directives         # 全局指令
│  ├─ hooks              # 全局 hook
│  ├─ icons              # svg icon
│  ├─ layout             # 布局
│  ├─ plugins            # 全局插件
│  ├─ router             # 路由
│  ├─ store              # pinia store
│  ├─ styles             # 全局样式
│  ├─ utils              # 全局公共方法
│  └─ views              # 所有页面
│  ├─ App.vue            # 入口页面
│  └─ main.ts            # 入口文件
├─ tests                 # 单元测试
├─ types                 # ts 声明
├─ .env.development      # 开发环境
├─ .env.production       # 正式环境
├─ .env.staging          # 预发布环境
├─ .eslintrc.js          # eslint 配置
├─ .prettier.config.js   # prettier 配置
├─ tsconfig.json         # ts 编译配置
├─ unocss.config.ts      # unocss 配置
└─ vite.config.ts        # vite 配置

建立目录结构

  1. @/src/api 目录下的 login 文件夹(没有的话就需要新建一个)即代表了登录模块。
  2. login 文件夹里面再建立一个 types 文件夹(专门放置和登录模块相关的 TS 类型)和 index.ts

假如一个模块叫系统管理 system,里面有两个子模块,分别叫用户管理 user、角色管理 role,那么我们建立的目录大致就应该长这个样子:

编写 TS 类型

编写接口的 TS 类型,需要根据后端同事提供的接口文档,拿到接口的请求参数和响应数据的格式。

  1. 请求数据类型 ILoginRequestData
export interface ILoginRequestData {
  /** admin 或 editor */
  username: "admin" | "editor"
  /** 密码 */
  password: string
  /** 验证码 */
  code: string
}
  1. 响应数据类型 LoginResponseData
export type LoginResponseData = IApiResponseData<{ token: string }>
  1. IApiResponseData 这个类型作为一个全局类型,被定义在 @/types/api.d.ts 文件里:
/** 所有 api 接口的响应数据都应该准守该格式 */
interface IApiResponseData<T> {
  code: number
  data: T
  message: string
}
  1. 所以最终响应数据类型 LoginResponseData 就相当于:
{
  code: number
  data: { token: string }
  message: string
}
  1. 最终效果如下:

编写接口

  1. 发送请求是通过封装好的 Axios,所以第一步就是导入相关的方法:
import { request } from "@/utils/service"
  1. 将上文写好的登录接口的类型导入进来:
import type * as Login from "./types/login"
  1. 登录接口的函数名为 loginApi,它接受一个参数 data,类型为 ILoginRequestData
/** 登录并返回 Token */
export function loginApi(data: Login.ILoginRequestData) {
  return request<Login.LoginResponseData>({
    url: "users/login",
    method: "post",
    data
  })
}
  • request<Login.LoginResponseData> 则表示的是待会接口响应成功的 data 数据类型为 LoginResponseData
  • url 代表接口地址,method 代表接口方法(get/post/put/delete),data 表示请求体数据(如果是 get 请求,则要换成 params)。
  1. 接口写好后如下图:

调用登录接口

首先点击登录按钮将调用的函数是 handleLogin

const handleLogin = () => {
  loginFormRef.value?.validate((valid: boolean) => {
    if (valid) {
      loading.value = true
      useUserStore()
        .login({
          username: loginForm.username,
          password: loginForm.password,
          code: loginForm.code
        })
        .then(() => {
          router.push({ path: "/" })
        })
        .catch(() => {
          createCode()
          loginForm.password = ""
        })
        .finally(() => {
          loading.value = false
        })
    } else {
      return false
    }
  })
}
  • loginFormRef.value?.validate 是校验登录表单。
  • useUserStore() 是状态管理器 Pinia 的 Store,调用该 Store 的 login action,并传入用户名、密码、验证码三个参数即可。
  • login action 返回值是一个 Promise,所以后面链式跟一个 .then.catch.finally,接口调用成功则会执行 .then (跳转到首页),如果途中发生错误,则会执行 .catch,而无论什么情况都会执行 .finally

状态管理

  1. 由于点击登录按钮触发了 useUserStorelogin action,然后在 login action 中调用这个 loginApi 并传入对应参数(如果这里参数传递错误,那么 TS 就会报错提醒我们,因为我们在上文中定义接口的时候已经约束了类型)。
  1. 调用登录接口成功时,我们将接口返回的响应数据 res 中的 token 分别保存到 cookie(对应语句 setToken(res.data.token))和 当前 Store(对应语句 token.value = res.data.token) 中,如果接口失败,则直接 reject
  2. 如果这里执行了 .then 那么登录页面也将执行 .then,也就会开始跳转路由到首页,那么就会触发路由守卫。

路由守卫

  1. @/src/router/permission.ts 包含了路由守卫全部的代码:
import router from "@/router"
import { useUserStoreHook } from "@/store/modules/user"
import { usePermissionStoreHook } from "@/store/modules/permission"
import { ElMessage } from "element-plus"
import { whiteList } from "@/config/white-list"
import { getToken } from "@/utils/cache/cookies"
import asyncRouteSettings from "@/config/async-route"
import NProgress from "nprogress"
import "nprogress/nprogress.css"

NProgress.configure({ showSpinner: false })

router.beforeEach(async (to, _from, next) => {
  NProgress.start()
  const userStore = useUserStoreHook()
  const permissionStore = usePermissionStoreHook()
  // 判断该用户是否登录
  if (getToken()) {
    if (to.path === "/login") {
      // 如果已经登录,并准备进入 Login 页面,则重定向到主页
      next({ path: "/" })
      NProgress.done()
    } else {
      // 检查用户是否已获得其权限角色
      if (userStore.roles.length === 0) {
        try {
          if (asyncRouteSettings.open) {
            // 注意:角色必须是一个数组! 例如: ['admin'] 或 ['developer', 'editor']
            await userStore.getInfo()
            const roles = userStore.roles
            // 根据角色生成可访问的 Routes(可访问路由 = 常驻路由 + 有访问权限的动态路由)
            permissionStore.setRoutes(roles)
          } else {
            // 没有开启动态路由功能,则启用默认角色
            userStore.setRoles(asyncRouteSettings.defaultRoles)
            permissionStore.setRoutes(asyncRouteSettings.defaultRoles)
          }
          // 将'有访问权限的动态路由' 添加到 Router 中
          permissionStore.dynamicRoutes.forEach((route) => {
            router.addRoute(route)
          })
          // 确保添加路由已完成
          // 设置 replace: true, 因此导航将不会留下历史记录
          next({ ...to, replace: true })
        } catch (err: any) {
          // 过程中发生任何错误,都直接重置 Token,并重定向到登录页面
          userStore.resetToken()
          ElMessage.error(err.message || "路由守卫过程发生错误")
          next("/login")
          NProgress.done()
        }
      } else {
        next()
      }
    }
  } else {
    // 如果没有 Token
    if (whiteList.indexOf(to.path) !== -1) {
      // 如果在免登录的白名单中,则直接进入
      next()
    } else {
      // 其他没有访问权限的页面将被重定向到登录页面
      next("/login")
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})
  • 判断用户是否登录,没登录则只能进入白名单页面,比如登录页。
  • 如果已经登录,将不允许进入登录页。
  • 如果已经登录,还要检查是否拿到用户角色,如果没有,并且开启了动态路由功能,则要调用用户详情接口。
  • 如果没有开启动态路由功能,则启用默认角色。
  • 一旦发生错误,就重置 Token,并重定向到登录页。
  • 如果通过路由守卫的检查后,就能正常跳转到首页了。

鉴权

  1. 后续所有的操作,都将携带保存在前端的 token 去调用接口,token 将是后端服务判断当前请求合不合法的依据,项目本身已经写在 Axios 的封装里面了:
  1. 假如 token 已经过期后,理论上接口会抛出一个 http code 401 的错误,我们只需要在响应拦截器里重定向到登录页即可:

参考资料

  1. 【V3 Admin Vite】教程三:掌握登录模块(涉及 API、Axios、Pinia、路由守卫、鉴权)