本文的主要内容是如何使用vue3、elementPlus、Pinia来快速实现一个多角色登录
现在我们有一个后端采用Oauth2授权,返回JWT作为登录令牌,并且令牌里面包含用户的角色信息
我们前端要做的是检验用户是否登录,并且对于不同角色的用户我们要限制其能访问哪些页面、能看到哪些菜单
对于上述需求我们需要解决下面的问题
- 如何设计登录页面,怎么用elementUI的表单组件做简单的校验
- 如何封装Axios接口,怎么用封装好的接口向后端请求数据,怎么对返回内容做数据处理和异常捕获
- 如何存取登录成功后返回的token
- 如何利用vue-router限制不同角色登录后能看到的菜单,能访问的界面
本文将为你解决这些问题提供思路
注:如果你不关心如何创建项目环境建议直接从章节二开始阅读
一、项目创建
本节介绍,使用vite来搭建vue3项目,并使用element-plus进行开发的项目搭建和配置流程
逐步配置
本小节介绍如何一步一步配置开发过程中需要的一些依赖和插件,比如elementUI如何自动按需导入等等
如果你有经验,或者想一步配置好,请跳过此小节
1.基于vite创建项目
pnpm create vue@latest
pnpm i
安装过程中的勾选项
- ts
- pinia
- vue-router
- Prettier
2.配置setup语法糖
pnpm i vite-plugin-vue-setup-extend -D
vite.config.ts
//配置
import { defineConfig } from 'vite'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
export default defineConfig({
plugins: [ VueSetupExtend() ]
})
使用setup
<script setup lang="ts" name="Person">
3.配置element-ui需要的相关依赖
分别是对自动按需导入以及elementUI的图标库进行配置
#-elemntui
#-按需导入依赖的两个插件
pnpm install element-plus
pnpm install -D unplugin-vue-components unplugin-auto-import
#-elemntui图标库
#-相关的按需导入插件
pnpm install @element-plus/icons-vue
pnpm i -D unplugin-icons
vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [
ElementPlusResolver(),
// 自动导入图标组件
IconsResolver()
]
}),
Components({
resolvers: [
ElementPlusResolver(),
// 自动注册图标组件
IconsResolver({
enabledCollections: ['ep']
}) //ep参数是elementPlus集合
]
}),
Icons({
autoInstall: true
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
4.安装sass
pnpm i sass
快速配置
我们搭建目需要常用的依赖:
vite搭建项目时需要选择的配置
- pinia
- vue-router
- eslint
- Prettier
额外需要的插件
- 配置使用setup
- element-plus
- elementUI的图标库
- sass
- axios
使用vite搭建项目时勾选好我们需要的配置可以帮我们省很多事,剩下的配置遵循下面的步骤
1.复制到package.json
{
"name": "vue-learning-project",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"element-plus": "^2.5.3",
"pinia": "^2.1.7",
"sass": "^1.70.0",
"vue": "^3.3.11",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@iconify-json/ep": "^1.1.14",
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node18": "^18.2.2",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^18.19.3",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.0",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"npm-run-all2": "^6.1.1",
"prettier": "^3.0.3",
"typescript": "~5.3.0",
"unplugin-auto-import": "^0.17.5",
"unplugin-icons": "^0.18.3",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.10",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vue-tsc": "^1.8.25"
}
}
保存后一次性安装
pnpm i
2.复制到vite.config.ts
一次性配好常用插件的配置文件
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
VueSetupExtend(),
AutoImport({
resolvers: [ElementPlusResolver(), IconsResolver()]
}),
Components({
resolvers: [
ElementPlusResolver(),
IconsResolver({
enabledCollections: ['ep']
})
]
}),
Icons({
autoInstall: true
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
可能遇到的问题
1使用自动按需导入插件导入使用elementui有一些组件经常会无法正常导入或者丢失样式,解决的办法是需要我们在main.ts手动导入
main.ts
//elemnetui部分组件无法正常按需导入,我们手动导入
//这些组件的使用频率还是很高的,推荐一次配置好
import 'element-plus/theme-chalk/el-loading.css'
import 'element-plus/theme-chalk/el-message.css'
import 'element-plus/theme-chalk/el-notification.css'
import 'element-plus/theme-chalk/el-message-box.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
注意事项
配置完成后如何正确使用elemtui的图标库?
按上面的步骤无论是一步一步自己配置,还是复制本文的模板快速配置,我们都需要注意elemtui图标库的使用方法
以使用House这个图标为例
比如用House图标时要使用IEp前缀也就是IEpHouse
<el-icon><IEpHouse /></el-icon>
二、目录设计
这些目录基本上是vite创建项目的时候自动生成的,主要关注一下api、utils这是我额外创建的,一个用来保存接口函数,一个用来保存一些经常用到的工具函数
src
├─api 存放向后端请求的接口
├─assets 一些静态文件
├─components 组件
├─router 路由
├─stores Pinia
├─utils 工具
└─views 主要视图
三、多角色登录
我们在开始前,先说明一下开发需求
我们要为一个使用Oauth2协议,登录使用JWT作为令牌,并且采用RBAC模型来做权限关联的后端项目来实现一个多角色登录,下面说明一些我们都需要做哪些事情:
- 使用AXIOS做好API接口供登录界面来向后端发请求
- 在localStorage为用户存储登录成功后,后端返回的JWT令牌
- 使用Pinia来配合管理和使用JWT令牌
- 使用AXIOS的拦截器为每个请求添加Authorization请求头内容是JWT令牌
- 使用vue-router的路由守卫来检查用户的登录状态,以及为部分路由添加角色限制
- 自己编写条件判断来控制不同角色能看到的菜单
Pinia存取Token
先不着急思考如何向后端发请求获取token,我们先聊聊,登录成功后帮用户拿到了token我们怎么存怎么取
我们通过一个变量,一个方法,两个计算属性来实现对token灵活方便合理的存取
-
token变量
拿到token先在Pinia的token变量里存一份
-
saveToken方法
定义一个方法将token存在localStorage里面
-
getToken计算属性
我们真正用token的时候不是通过token变量而是通过计算属性,先取pinia的token变量中取找,再到localStorage里找
-
getUserObj计算属性
这个方法用于对 JWT token进行解码,拿出里面的用户信息,方便我们后续做权限校验使用,这里的token来自getToken,可以理解为一种依赖关系
stores/token.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { tokenPayloadDecoding, type userData } from '@/utils/jwt'
export const useTokenStore = defineStore('mytoken', () => {
const token = ref('')
const getToken = computed(() => {
return token.value || window.localStorage.getItem('TokenInfo') || ''
})
const getUserObj = computed((): userData | null => {
const token = getToken.value
if (token == '') {
return null
} else {
try {
const UserJson = tokenPayloadDecoding(token)
return UserJson
} catch (error) {
return null
}
}
})
function saveToken(data: string) {
window.localStorage.setItem('TokenInfo', data)
}
return { token, getToken, getUserObj, saveToken }
})
补充tokenPayloadDecoding是我再/utils/jwt里面定义的一个数据处理工具,来取到token里的用户数据
export interface userData {
user_name: string
id: number
user_status: number
super_admin: boolean
exp: number
role: Array<string>
}
export function tokenPayloadDecoding(Token: string): userData {
try {
const tokenList = Token.split('.')
const data = tokenList[1]
const decodedString = atob(data)
const jsondata = JSON.parse(decodedString)
return jsondata
} catch (error) {
throw new Error('Token parsing Error')
}
}
后端接口分析
在创建Axios接口之前,我们先来看看Ouath2的后端登录接口有哪些要求
这个后端要求我们必须传输的字段是username、password
同时要注意,它要的数据可不是JSON哦
举个例子
username=mario&password=123456
封装Axios接口
首先创建一个.env文件,保存我们的后端地址
VITE_API_URL=http://127.0.0.1:8000
接着我们在utils.requests文件下创建axios的实例
并且我们用到了拦截器,为我们的每一个请求添加Authorization请求头,内容是JWT令牌,这是本文后端校验的要求也是Oauth2的要求,实际开发还是要看自己的后端具体怎么要求
src/utils/requests.ts
import axios, { type AxiosRequestHeaders } from 'axios'
import { useTokenStore } from '@/stores/token'
const axio = axios.create({
baseURL: import.meta.env.VITE_API_URL
})
//请求拦截器来添加token
axio.interceptors.request.use((config) => {
if (config.headers) {
config.headers = {} as AxiosRequestHeaders
}
const store = useTokenStore()
config.headers.Authorization = `Bearer ${store.getToken}`
//拦截器修改完config还要返回
return config
})
export default axio
然后我们创建在api.user文件下封装我们的登录接口
我的习惯是再嵌套一层async function,在哪里调用再在哪里通过.then和.catch做数据处理和错误捕获
import axio from '@/utils/request'
interface loginInfo {
username: string
password: string
scope?: string
}
interface LoginResult {
access_token: string
token_type: string
}
export async function login(loginInfo: loginInfo): Promise<LoginResult> {
const response = await axio.post(
'/login/token',
`username=${loginInfo.username}&password=${loginInfo.password}`
)
return response.data
}
ElementPlus表单
有了请求接口,有了存储位置和工具,我们就要开始着手做登录表单
element-ui的表单时可以绑定一些简单的校验规则的,也是非常的方便,使用方法我下面的代码里有具体的注释
还有一些要注意的点
- 要注意时如何使用封装好的接口来发送请求,怎么接收数据保存token,怎么捕获异常弹出错误
- 表单的button按钮我们使用了loading来为button按钮添加状态,当点击button后让其处于loading状态,等成功收到请求结果或者请求失败之后,再解除loading状态,防止用户重复发送请求
views/login/login.vue
<script setup lang="ts">
//表单响应式数据
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { reactive, ref } from 'vue'
import { login } from '@/api/user'
import { useTokenStore } from '@/stores/token'
import { useRouter, useRoute } from 'vue-router'
const tokenStore = useTokenStore()
const isLoading = ref(false)
const router = useRouter()
const route = useRoute()
const form = reactive({
username: 'user',
password: 'maluyao123'
})
//定义表单校验规则,传入的泛型是Elementui已经定义好的类型
//参数解释:trigger:"blur"表示在失去光标的时候触发校验
//可以定义多个校验{pattern:正则,message:"不符合规则",trigger:"blur"}
const rules = reactive<FormRules>({
username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
password: [
{ required: true, message: '密码不能为空', trigger: 'blur' },
{ min: 6, message: '密码最小6位', trigger: 'blur' }
]
})
//获取表单元素,定义的泛型是ELementUI为我们封装好的表单实例类型
const formRef = ref<FormInstance>()
//首先对表单的内容做二次校验,校验失败要做错误捕获,捕获完毕仍要thorw抛出错误停止代码运行
//登录成功后跳转到 / 页面,或者触发路由守卫前的页面
async function onSubmit() {
await formRef.value?.validate().catch((err) => {
ElMessage.error('表单校验失败')
throw err
})
login(form)
//请求成功使用pinia对token进行存储
.then((res) => {
const userToken: string = res.access_token
tokenStore.token = userToken
tokenStore.saveToken(userToken)
isLoading.value = false
router.push((route.query.redirect as string) || '/')
})
.catch((error) => {
if (error.response.status === 401) ElMessage.error('登录失败账号或密码错误')
else ElMessage.error('出错了,登录失败,请稍后再试')
isLoading.value = false
})
}
</script>
<template>
<div class="login">
<el-form
:model="form"
:rules="rules"
ref="formRef"
label-width="120px"
label-position="top"
size="large"
>
<h2>登录</h2>
<!-- prop参数用于分配表单规则 -->
<el-form-item label="账号" prop="username">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit" :loading="isLoading">登录</el-button>
</el-form-item>
</el-form>
</div>
</template>
<style scoped lang="scss">
.login {
background-color: #ccc;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.el-form {
width: 300px;
background-color: #fff;
padding: 30px;
border-radius: 10px;
.el-form-item {
margin-top: 20px;
}
.el-button {
width: 100%;
margin-top: 30px;
}
}
</style>
路由守卫
我们的路径中可以设定meta,meta要是一个对象
你可以理解为路径添加meta是在为路径添加要求,meta是个对象,它的元素是一个个键值对,键值对的值可以是一个Boolean也可以是个Array,因此meta中的元素,可能是代表我要为这个路径添加一个要求或者是一组要求
import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import { useTokenStore } from '@/stores/token'
import { tokenVertify } from '@/utils/jwt'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/login/LoginView.vue')
},
{
path: '/',
name: 'home',
//我们要求requiersAuth: true,要求用户必须登录,这里帮一次就行了,这同样会约束它的children路由
meta: { requiersAuth: true },
component: AppLayout,
children: [
{
path: 'permission-test',
name: 'permissionTest',
component: () => import('@/views/PermissionTestVue.vue'),
//在meta里绑定这个路径需要的角色列表
//用户要访问这个路径,在token中用户必须有几个角色的信息才行
meta: { role: ['test_role1','test_role2'] }
},
{
path: '/:xxx(.*)*',
name: 'errorPage',
component: () => import('@/views/error/ErrorPage.vue')
}
]
},
{
path: '/error',
name: 'error',
component: () => import('@/views/error/ErrorPage.vue')
}
]
})
router.beforeEach((to, from, next) => {
//to.meta能获取将要前往的路由的meta数据,我们来判断其需不需要requiersAuth
//如果有requiersAuth就要去看用户是否有token看用户是否是登录状态
if (to.meta.requiersAuth) {
//获取store的时候不能过早,就应该再这里
const store = useTokenStore()
const tokenVT: boolean = tokenVertify(store.getToken)
if (!tokenVT) {
next({ name: 'login', query: { redirect: to.fullPath } })
}
//然后看meta里面是否有role,有role就说明路径需要指定的角色来访问
else if (to.meta.role) {
if (!store.getUserObj) {
next({ name: 'error', query: { redirect: to.fullPath } })
} else {
const superAdmin = store.getUserObj.super_admin
const routerRole = to.meta.role as string[]
const roleVertify: boolean = routerRole.every((role) =>
store.getUserObj?.role.includes(role)
)
//校验不通过的条件是,既不是超级管理员,有没有全部的role权限
if (!superAdmin && !roleVertify) {
next({ name: 'error', query: { redirect: to.fullPath } })
}
}
}
}
next()
})
export default router
根据角色控制菜单的显示
这是主页界面的一个侧边栏,我习惯放在components文件夹下,我们现在想要实现根据用户token中不同的role来控制,用户能看到哪些菜单,不能看到哪些菜单
实现方式非常简单,从pinia拿用户的role,然后为菜单添加不同的v-if做判断就行
<script setup lang="ts">
import { useTokenStore } from '@/stores/token'
import { ref } from 'vue'
const store = useTokenStore()
const allMenusControl = ref(false)
if (store.getUserObj !== null) {
allMenusControl.value = true
}
</script>
<template>
<el-menu class="layoutMenu" router>
<el-menu-item index="1">
<el-icon><IEpClock /></el-icon>
<span>主要业务</span>
</el-menu-item>
<el-menu-item index="/user-manage">
<el-icon><IEpUser /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/role-manage">
<el-icon><IEpLocation /></el-icon>
<span>角色管理</span>
</el-menu-item>
<el-menu-item index="/permission-manage">
<el-icon><IEpLock /></el-icon>
<span>权限管理</span>
</el-menu-item>
<el-menu-item
index="/permission-test"
v-if="
store.getUserObj?.super_admin ||
(allMenusControl && store.getUserObj?.role.includes('test_role'))
"
>
<el-icon><IEpdocument /></el-icon>
<span>权限测试</span>
</el-menu-item>
</el-menu>
</template>
<style scoped lang="scss">
.layoutMenu {
height: calc(100vh - 60px);
}
.el-menu-item {
font-size: 17px;
}
</style>
退出登录状态
这里只讲实现退出登录的事件函数怎么写,至于你把触发退出登录事件的按钮放在呢由你自己决定
思路就是ElMessageBox弹出对话框,当用户确定退出后再退出,退出登录使用我们pinia里面存的saveToken方法同时给localstorage里面和pinia里面的token全赋值成空字符串,最后给用户定向到登录界面就行
import { useTokenStore } from '@/stores/token'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
const router = useRouter()
const toeknStore = useTokenStore()
async function handLogout() {
ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
// 执行退出操作清空token
ElMessage({
type: 'success',
message: '已退出登录'
})
useTokenStore().saveToken('')
router.push('/login')
})
.catch(() => {
ElMessage({
type: 'info',
message: '取消退出'
})
return new Promise(() => {})
})
}
退出登录使用我们pinia里面存的saveToken方法同时给localstorage里面和pinia里面的token全赋值成空字符串,最后给用户定向到登录界面就行
import { useTokenStore } from '@/stores/token'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
const router = useRouter()
const toeknStore = useTokenStore()
async function handLogout() {
ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
// 执行退出操作清空token
ElMessage({
type: 'success',
message: '已退出登录'
})
useTokenStore().saveToken('')
router.push('/login')
})
.catch(() => {
ElMessage({
type: 'info',
message: '取消退出'
})
return new Promise(() => {})
})
}