本项目使用springboot+vue3+typescript的技术栈开发。
一、创建项目的准备
在某个工程文件夹下创建项目
npm init vue@latest
各种工具选择都选“是”,本项目使用pinia、typescript的技术选型。安装环境node_modules之后,显示如图:
在开始项目之前,请确保掌握typescript、pinia、vue3的基本语法:
typescript入门
vue3学习
因为typescript可以对类型进行检验,便于查错,实际编码时当成js来写代码也可以,所以选择用typescript。
(一) 引入UI框架ant-design-vue
在vue2的时候常用的PC端的ui框架是element-ui,在vue3时本应该使用它的继承者element-plus。
但由于element-plus的UI框架不够完善,组件功能不够丰富,所以我建议还是用ant-design-vue的UI框架。
官方参考资料:https://3x.antdv.com/docs/vue/introduce-cn
1.引入UI框架
接下来下载插件。
npm install ant-design-vue --save
在main.ts中加入如下配置,进行全局完整注册。
import App from './App.vue'
import Antd from 'ant-design-vue';
const app = createApp(App)
app.use(Antd);
同时,不要忘记在main.ts
文件中引入样式。如果不引入,你将无法正确地展示 ant-design-vue 的样式。
2.引入样式
注意:官方文档给的是如下引入路径,可能会引入失败。
import 'ant-design-vue/dist/antd.css';
实际需要看UI框架的版本,在node_modules中查看样式文件中的真实路径,可能是如下路径:
import 'ant-design-vue/dist/reset.css';
3.设置语言为中文
引入样式之后,组件生效的语言默认为英文。这时需要在App.vue中全局设置组件为中文。
<template>
<a-locale-provider :locale="zhCN">
<RouterView />
</a-locale-provider>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import zhCN from 'ant-design-vue/lib/locale/zh_CN';
</script>
(二)引入图标库
1.引入自带图标库
ant-design有很多自带的图标组件,需要如下命令安装图标组件包:
npm install --save @ant-design/icons-vue
基本使用方法:通过 @ant-design/icons-vue 引用 Icon 组件,不同主题的 Icon 组件名为图标名加主题做为后缀,也可以通过设置 spin 属性来实现动画旋转效果。
<template>
<a-space>
<home-outlined />
<setting-filled />
<smile-outlined />
<sync-outlined spin />
<smile-outlined :rotate="180" />
<loading-outlined />
</a-space>
</template>
<script lang="ts" setup>
import {
HomeOutlined,
SettingFilled,
SmileOutlined,
SyncOutlined,
LoadingOutlined,
} from '@ant-design/icons-vue';
</script>
注意到ant-design-vue的图标每次使用时还需要显式引入,可以进行全局注册,在main.ts中进行如下配置
//导入组件库
import * as antIcon from '@ant-design/icons-vue'
let antIcons: any = antIcon;
// 注册组件
Object.keys(antIcons).forEach(key => {
app.component(key, antIcons[key])
})
// 添加到全局
app.config.globalProperties.$antIcons = antIcons
2.引入font-awesome图标库
注意到ant-design-vue自带的图标库可能不够丰富,可以考虑引入font-awesome的图标库。
官方网站:https://fontawesome.com.cn/
引入依赖
# vue3版本
npm i --save @fortawesome/vue-fontawesome@latest-3
# 使用npm安装核心包,它包含了让图标工作的所有实用工具
npm i --save @fortawesome/fontawesome-svg-core
#fontawesome的图标有免费版和专业版,本文主要使用的是free版本,一般free版本的图标足够用了
#free图标又划分为三个图标库,主要有实心图标(solid)、常规图标(regular)以及品牌图标(brand),根据需求去下载对应的图标库,无须全部下载
npm i --save @fortawesome/free-solid-svg-icons
npm i --save @fortawesome/free-regular-svg-icons
npm i --save @fortawesome/free-brands-svg-icons
在main.ts中加入以下配置,全局注册图标库。
//引入 fortawesome 图标库
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { library } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons';
library.add(fas);
app.component('font-awesome-icon', FontAwesomeIcon);
基本使用方法如下:
<template>
<font-awesome-icon :icon="['fas', 'house']" />
<font-awesome-icon icon="fa-solid fa-house" />
</template>
(三) 安装CSS插件
现在用SASS比较多,所以选择安装sass依赖
npm install -D sass
在package.json的文件,如图所示:
(四) 安装开发者工具vue-tools
下载地址:https://gitcode.net/mirrors/vuejs/devtools?utm_source=csdn_github_accelerator
下载vue-tools解压之后,执行以下命令
# 如果没有安装yarn的话
# 不知道自己是否安装 可以通过 yarn -v 查看一下
# 安装Vue-Devtools的依赖需要用到yarn,而不是npm,所以首先我们要安装yarn。命令行进入到解压后的Vue-Devtools目录。
npm install -g yarn
yarn install
yarn run build:watch
yarn run dev:chrome
看到本界面,说明安装成功了。之后Ctrl+C结束命令框。
之后,在google浏览器开启开发者模式,加载devtools-6.5.0\packages
下的shell-chrome
文件夹即可。
二、添加页面路由
(一)客户端路由
客户端路由的作用是在单页应用 (SPA) 中将浏览器的 URL 和用户看到的内容绑定起来。当用户在应用中浏览不同页面时,URL 会随之更新,但页面不需要从服务器重新加载。
为进行页面的路由控制,需要使用vue-router插件来完成,此前创建项目时已经引入了vue-router的插件,在此不再详述。
官方文档: https://router.vuejs.org/zh
在views目录下,可以新建Login.vue、404.vue、 Home.vue页面等3个简单页面用于测试
<template>
<div class='page'>
<h2>登录页</h2>
</div>
</template>
在router/index.ts中注册以下路由
import Login from '@/views/Login.vue'
import Home from '@/views/Home.vue'
import NotFound from '@/views/404.vue'
const router = createRouter({
//用于指定路由的历史模式
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/404',
name: 'NotFound',
component: NotFound
},
]
})
运行后,页面测试如图所示:
(二) 改进页面路由
1.提取路由参数
可以将路由配置从createRouter函数中提取出来作为一个数组常量。
将router/index.ts文件修改成以下代码:
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/404',
name: 'NotFound',
component: NotFound
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router
三、构建静态页面布局
(一) 修改App.vue
创建项目后,默认的App.vue有一系列自带的div布局,需要全部删掉,改成以下页面代码:
<template>
<a-locale-provider :locale="zhCN">
<router-view />
</a-locale-provider>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import zhCN from 'ant-design-vue/lib/locale/zh_CN';
</script>
(二) 创建页面布局
1.总体布局
在src/views/layout下创建index.vue文件,用于存放总体布局。代码如下:
<template>
<div class="app-wrapper">
<page-header class="app-header"></page-header>
<div class="app-container">
<page-sidebar class="sidebar-container"></page-sidebar>
<div class="main-wrapper">
<main-content></main-content>
</div>
</div>
</div>
</template>
<script>
import PageHeader from '@/views/layout/components/PageHeader.vue'
import PageSidebar from '@/views/layout/components/PageSidebar.vue'
import MainContent from '@/views/layout/components/MainContent.vue'
export default {
name: "layout",
components: {
PageHeader,
PageSidebar,
MainContent,
},
}
</script>
Wrapper(整体布局):包裹整个页面或页面的一部分,通常用于设置背景、边框等样式。
Container(容器):包裹页面的主要内容,通常用于设置宽度、居中等样式。
Content(内容):包裹页面的具体内容,通常用于设置字体、颜色等样式。
Main(主要内容):包裹页面的主要内容,通常用于设置页面的主要样式。
2.各分支布局
总体布局分为顶部的头栏,左边的侧边栏和右边的页面主体内容。
在src/views/layout/compnents下创建各布局组件,分别为PageHeader.vue,PageSidebar.vue,MainContent.vue组件。
顶部头栏分为左侧的网站LOGO,中间的全局导航和右侧的用户个性化设置。
//PageHeader.vue
<template>
<div>
顶部头栏
</div>
</template>
侧边栏用于放置局部菜单导航,控制右侧主体内容的显示。
//PageSidebar.vue
<template>
<div>
侧边栏
</div>
</template>
主体内容栏是整个网站的主体,占据网页主要部分。
//MainContent.vue
<template>
<div>
<router-view></router-view>
</div>
</template>
3.页面路由与布局
可以看到App.vue和MainContent.vue都包含<router-view>
的组件,它们是嵌套关系,可以构成子路由的嵌套。
在routes的路由对象数组中,第一个component属性代表的组件匹配到App.vue的<router-view>
的组件中,若该对象包含children属性,则在children的对象数组中component的组件匹配到MainContent.vue都包含<router-view>
的组件中。
例如,我们可以在views/personal下创建个人中心页面文件index.vue,将其设置为Layout的子路由。
import Layout from '@/views/layout/index.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Layout,
children: [
{
path: 'personal',
name: 'personal',
component: () => import('@/views/personal/index.vue')
},
]
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/404',
name: 'NotFound',
component: NotFound
},
]
因此,Login登录页和404错误页都挂在到App.vue的router-view标签中,不会应用Layout布局。而personal个人中心页面因为是子路由,在children属性的数组中,所以挂载到MainContent.vue的router-view标签中,应用了Layout的布局。
值得注意的是,不管菜单有多少层级的嵌套,若想应用Layout的布局,都应该在children的数组中作为平级。
可以略微调整CSS样式,访问http://localhost:5173/personal 的链接,最终设计的布局如图:
四、工具模块封装
(一)封装axios
例如,登录需要将用户信息提交到后台进行校验处理,因此各种API访问操作要使用常用工具axios发送请求,为便于重用代码,axios需要重新封装。
1. 安装axios
先安装axios插件
npm install axios
在package.json的文件,如图所示:
2. 设置配置文件
首先,后台的返回规则有一个通用的规则,前端的请求也有统一的规则,所以可以考虑设置一个通用的配置文件。
在src/http下创建一个config.js文件,将Axios通用配置写入文件中。
export default {
method: 'get',
// 基础url前缀
baseUrl: 'http://localhost:8001',
// 请求头信息
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
// 参数
data: {},
// 设置超时时间
timeout: 10000,
// 携带凭证
withCredentials: true,
// 返回数据类型
responseType: 'json'
}
3. 设置拦截器
现在 统一API请求,并设置拦截器。
在src/http下创建一个request.js文件,引入Axios,再引入上一步创建的config.js文件,然后创建一个request方法,返回Promise,并导出这个方法,以便在其他文件中使用。在这个方法中通过axios.create创建一个Axios实例,代码如下:
import axios from 'axios'
import config from './config'
import { ElMessage, ElMessageBox } from 'element-plus'
export default function request(options) {
return new Promise((resolve, reject) => {
//创建axios实例
const instance = axios.create({
baseURL: config.baseUrl,
headers: config.headers,
timeout: config.timeout,
withCredentials: config.withCredentials
})
const user = useUserStore()
// request 请求拦截器
instance.interceptors.request.use(
config => {
//请求必须携带身份信息token
if (user.token != '') {
config.headers['Authorization'] = getToken() //从cookie中获取token的值,
}
return config
},
error => {
console.log(error) // 请求发生错误时,通过控制台查看报错的逻辑
return Promise.reject(error) // 在调用的那边可以拿到(catch)你想返回的错误信息
}
)
// response 响应拦截器
instance.interceptors.response.use(
response => {
//返回响应的逻辑,若返回了后台的错误码不是正确响应,则前端提示消息,并拦截下一步操作
const res = response.data
if (res.errcode !== "00000") {
ElMessage({
message: res.errmsg,
duration: 3 * 1000,
type: 'error',
})
console.log(res)
return Promise.reject('error')
}
return response.data
},
err => {
//错误返回响应的逻辑,例如后台出现未知报错,则前端提示消息,并拦截下一步操作
console.error(err)
ElMessage({
message: err.errmsg,
type: 'error',
duration: 3 * 1000
})
return Promise.reject(err) // 返回接口返回的错误信息
}
)
// 请求处理
instance(options).then(res => {
resolve(res)
return false
}).catch(error => {
reject(error)
})
})
}
分析:
(1)请求方式正确时,应该校验访问者的token信息,token信息校验正确则进入访问页面,若校验错误则重定向到登录页面。
(2)请求方式错误时,应该提示报错信息。
(3)返回正确的响应时,不做处理
(4)返回错误的响应时,应该提示报错信息。
携带的token信息跟实现机制有关,可能由cookie,localStorage,vuex等实现。
提示的报错信息跟使用的ui框架有关,应该使用ui框架提供的消息提示工具,同时在console也要提示报错。
正确返回结果时,统一返回格式为JSON,包含3个属性:errcode、errmsg和data。code表示成功标识,为00000时表示成功,成功时通常会带回数据data,如果不是00000,则为失败,需要读取提示信息。
{
errcode: "00000",
errmsg: "success",
data: {
//....
}
}
4. 统一管理请求
为了统一管理请求,在src下创建api文件夹,用于存放各模块的远程请求方法。
例如,在api文件夹中创建login.js文件,放入以下请求。
import request from '@/http/request'
export function login(username, password) {
return request({
url: '/api/ums/admin/login',
method: 'post',
data: {
username,
password
}
})
}
该方法调用axios封装方法request来发送请求,当请求返回登录成功时,正常情况下会带回一个登录标识token,在处理请求返回时,将这个token存到本地缓存localStorage中,然后跳转到系统首页。
(二) 封装Mock.js
为了能看到登录效果,要么依赖后台功能正常返回,要么考虑前端模拟后端返回。
先安装 mockjs插件。
npm install -D mockjs
1. 编写模拟数据
和Axios请求模拟一样,数据的模拟也区分不同模块,因此继续在src/mock下创建一个modules文件夹,用于存放不同模块的模拟函数。并在modules文件夹下创建login.js文件。
export function login(){
return {
url : "login",
type: "post",
data: {
errcode : "00000",
errmsg : "success",
data : {
token : "abc123456789",
username : "admin"
}
}
}
}
2. 封装所有模块
在src目录下新建一个mock目录,创建index.js。在index.js文件中引入各模块文件
import Mock from 'mockjs'
import * as login from './modules/login.js'
// 开启/关闭模块的拦截
const openMock = true
const baseUrl = "http://localhost:8001"
//模拟所有模块
mockAll([login],openMock)
function mockAll(modules,isOpen = true){
for (const k in modules){
mock(modules[k],isOpen)
}
}
function mock(mod,isOpen = true){
if(isOpen){
for(var key in mod){
((res)=>{
let url = baseUrl
if(!url.endsWith("/")){
url = url +"/"
}
url = url + res.url
Mock.mock(new RegExp(url),res.type,(opts) => {
opts['data'] = opts.body ? JSON.parse(opts.body) :null
delete opts.body
console.log('\n')
console.log('%cmock拦截,请求:','color:blue',opts)
console.log('%cmock拦截,请求:','color:blue',res.data)
return res.data
})
})(mod[key]()|| {})
}
}
}
该代码统一管理所有模块的模拟数据。首先,通过openMock统一管理所有模块模拟数据的开启/关闭。
然后url必须拼接baseUrl且以“/”结尾,各模块的url不能以“/”开头。
至此,Mock.js封装完成。只需要在入口文件src/main.js中引入上面的入口文件即可,即修改main.js如下(注意加粗部分):
import './assets/main.css'
import './mock'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.mount('#app')
3. 使用mockjs
通过axios访问对应的api,得到如下结果
axios.get('http://localhost:8001/api/ums/admin/info',{
params :{
pageNum:1,
pageSize:20
}
}).then(res => { // url即在mock.js中定义的
console.log(res) // 打印一下响应数据
})
(三)安装js-cookie
在上面的axios.js中,会用到Cookie获取token,所以需要把相关依赖安装一下。执行以下命令,安装依赖包,如图22-3所示。
npm install js-cookie
在http/auth.js文件中编写代码
import Cookies from 'js-cookie'
const TokenKey = 'loginToken'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
五、管理应用状态
(一)引入状态管理库Pinia及管理登录状态
1.注册pinia
若未安装pinia,则先引入
npm install pinia
确保在main.ts文件中应用了pinia
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
2. 设置用户登录状态
在src/stores目录下新建user.ts文件,在管理用户登录状态时,需要管理用户的token、用户名、权限等。同时,实现用户的登录,注销、获取用户信息的功能
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: getToken(),//token
username:'', //用户名
avatar: '', // 用户头像
roles: [] //权限
}),
getters: {
getToken: (state) => state.token && true,
},
actions: {
login(username: string, password: string) {
},
logout() { },
getInfo(){},
},
})
在上述代码中,token是存在cookie中的,作为长期存储,若手动清除cookie,则会清除登录状态。其他信息存放在store的内存中,每次刷新浏览器,都需要重新获取权限。
3. 关于状态的持久化存储
这里存在一个细节,即何时去请求菜单数据。如果直接在登录成功之后请求菜单资源,那么在请求到JSON数据之后,将其保存在store中,以便下一次使用。
但是这样会有一个问题:假如用户登录成功之后,可以点击渲染好的菜单导航通过router
进入某一个子页面中,但是按一下F5键进行刷新,这个时候就会出现空白页面,因为按F5键刷新之后store中的数据就没了,而我们又只在登录成功的时候请求了一次菜单资源。
要解决这个问题,有两种方案:方案一,不要将菜单资源保存到store中,而是保存到localStorage中,这样即使按F5键刷新之后数据还在。
由于pinia和vuex一样将数据保存在内存中,当刷新浏览器时,state里的数据就会丢失,意味着登录状态不能被保存,这是不可接受的!
若是每次都需要记住登录状态,比如token的值,必须对需要保存的信息,进行持久化。在浏览器中,localStorage或sessionStorage、Cookie中都可以持久的保存信息。
当然,pinia自身也有持久化工具——pinia-plugin-persist
插件。它可以自动将state里的值缓存起来,存在localStorage或sessionStorage、Cookie中,这可以自行配置。
关于pinia-plugin-persist插件用法如下:
npm install pinia-plugin-persist
修改配置文件main.js如下
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persist'
import App from './App.vue'
const pinia = createPinia()
pinia.use(piniaPersist)
const app = createApp(App)
app.use(pinia)
app.mount('#app')
然后,在store的需要的模块文件中开启持久化配置
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
username:'', //用户名
roles: [] //用户角色
}),
getters: {
//...
},
persist: {
enabled: true, // true 表示开启持久化保存
strategies: [ //可自行配置存储方案
{ storage: sessionStorage, paths: ['username'] },
{ storage: localStorage, paths: ['roles'] },
],
},
actions: {
//...
},
})
但是!!!!!这其实多此一举!
我们只用直接把token信息存在cookie或localStorage中作为长期存储就够了,token作为判断用户登录状态使用,所以并不需要这个多余的插件的存在。
而其他信息比如用户名,菜单资源等等,在刷新页面后丢失信息!这正是我们想要的!!!!
因为在后台为用户更新用户信息和所属权限时,我们需要这种更改立马生效,用户应该尽早去后台取得新的身份信息,而不是等待登录状态超时失效之后重新登录才生效。
方案二,直接在每一个页面的mounted方法中都加载一次菜单资源。由于菜单资源是非常敏感的,因此不建议将其保存到本地,故舍弃方案一,但是方案二的工作量有点大,而且也不易维护,这里可以使用路由中的导航守卫来简化方案二的工作量。下面介绍具体实现步骤。
这里有一点巧妙的地方,就是首次访问目标页面时,经过导航守卫处理时,cookie中有token的值,意味着用户处于登录状态,但是store里没有值,需要去后台获取身份信息存在store里,然后再经过导航守卫重定向到目标页面。
六、完善登录流程
现在我们对用户完整的登录流程进行完善。
首先,对用户使用系统的行为方式进行思考分析,并对要使用的一些工具进行总结:
(1)用户想要使用网站的功能,必须有两个操作:打开页面和发送请求。
(2)打开页面的操作,可以通过导航守卫拦截,开发人员可以在此封装行为逻辑,这里由vue-router
处理页面路由和权限。
(3)发送请求和返回响应也可以封装行为逻辑。第一步的登录需要将用户信息提交到后台进行校验处理,因此各种API访问操作要使用常用工具axios
发送请求,为便于重用代码,axios需要重新封装。
(4)在前后端分离项目中,为追求效率,可使用mockjs
进行模拟数据的测试。
(5)当请求发送成功时,需要保留登录状态,登录状态将在整个项目中使用,用户的菜单等权限资源需要全局共用,这里使用pinia
工具进行处理。
(6)任何操作都需要在已登录状态下完成,这样才能保证用户数据或网站数据的安全。如果登录状态失效,则需要更新状态并退出系统路由到登录页面,用户需要重新登录,才可以重新执行相应操作。
(一)设计登录页面
这里登录页面中需要发送一个包含账号密码的表单给服务器进行验证,该登录函数委托给状态管理进行处理。因为状态管理需要记录用户状态。
//login.vue
<template>
<!--省略html部分代码-->
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { reactive, ref } from 'vue'
const loading = ref(false)
const loginForm = reactive({
username: '',
password: ''
})
//登录的异步操作,若登录成功则跳转首页,若登录失败则报错
const handleLogin = async () => {
loading.value = true
const user = useUserStore() //获取pinia管理的用户状态
try {
await user.login(loginForm.username, loginForm.password) //委托给状态管理库处理登录
router.push({ path: '/' })
} catch (error) {
console.error('登录失败:', error)
}
loading.value = false
}
</script>
(二)用户登录状态
在状态管理的stores目录中新建user.ts文件,由该文件管理用户的登录状态,代码如下:
//stores/user.ts
import { defineStore } from 'pinia'
import { login, logout, getInfo } from '@/api/ums/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
export const useUserStore = defineStore('user', {
// 定义状态:一个函数,返回一个对象
state: () => ({
token: getToken(),//token
username: '', //用户名
avatar: '', // 用户头像
roles: [] //角色权限
}),
// 定义 getters,等同于组件的计算属性
getters: {
isLogin: (state) => state.token && true,
},
// 定义 actions,有同步和异步两种类型
actions: {
login(username: string, password: string) {
return new Promise((resolve, reject) => {
login(username, password).then(response => {
const data = response.data
const tokenStr = data.tokenHead + data.token
//登录成功后,将包含头信息的token值存在cookie和状态管理中
setToken(tokenStr) //将token保存到cookie
this.token = tokenStr //token保存到pinia
resolve()
}).catch(error => {
reject(error)
})
})
},
//退出登录,先在后台退出登录,后在前台删除token值
async logout() {
await logout(this.username)
this.token = null
removeToken()
},
//获取当前登录用户信息
getInfo() {
return new Promise((resolve, reject) => {
//在headers['Authorization']中携带了包含用户信息的参数,直接在后台解析即可
getInfo().then(response => {
const data = response.data
if (data.roles && data.roles.length > 0) { // 验证返回的roles是否是一个非空数组
this.roles = data.roles
} else {
console.log('角色信息必须是非空数组')
}
this.username = data.username
this.avatar = data.icon
resolve(data)
}).catch(error => {
console.log(error)
reject(error)
})
})
},
setRoles() { //省略... }
},
})
(三) 添加导航守卫
路由对象router给我们提供了beforeEach方法,可以在每次路由之前进行一些相关处理,也叫导航守卫。
导航守卫的思路逻辑:
(1)用户不管是去进行登录、填入页面地址,或者是刷新页面,首先第一个是判断页面路由是否需要授权才能访问,在白名单中的页面不需要授权访问,或者路由对象中的元信息meta中标明有是否需要验证授权,这一步由导航守卫进行验证。
(2)若用户进入白名单页面,则分两种情况:一是跳转的非登录页面,则直接路由到目标页面。二是跳转的登录页面,这时获取登录状态,若登录有效,则禁止进行登录并路由到后台首页;若登录状态无效,则跳转到登录页面。
(3)若用户进入非白名单页面,则检查用户登录状态,若登录状态有效,则获取动态菜单,判断用户是否有权限到目标页面;若会话过期等登录无效状态,则跳到登录页面要求登录。
(4)在登录页面的表单填写账号密码后,需要将登录后返回的身份信息传递给状态管理库store,原因是状态管理需要保存用户信息。所以应该由状态管理访问api,接受返回的数据。
我们这里就通过导航守卫实现在每次路由时,判断用户会话是否过期以及动态菜单的加载。
修改router/index.ts文件,添加导航守卫。
/**
* (1)进行登录操作时,会先访问api再进行跳转。进行刷新或跳转时,会先跳转再访问api。两者都需要进行验证。
* (2)如果登录有效且跳转的是登录界面,则禁止进行登录并路由到后台首页;若跳转的非登录页面,则获取用户信息并加载动态菜单,并路由到目标页面。
* (3)如果会话过期等登录无效状态且跳转到白名单路径,就路由到白名单所在的路径;否则,就跳到登录页面要求登录。
* (4)注意这里其实跳转了两次路由,第1次路由中store中没有值,这时去获取getInfo信息,有了值之后,再次重定向目标页面,重新判断权限
*/
const whiteList = ['/login'] // 不重定向白名单
router.beforeEach(async (to, from, next) => {
const user = useUserStore()
//如果访问的是白名单,则继续判断是否登录页
if (whiteList.indexOf(to.path) !== -1) {
//如果访问的是登录页,且登录信息存在,则重定向到主页
if (to.path === '/login' && user.isLogin) {
next({
path: '/'
})
}
//其他情况,则放行
next()
} else {
//访问非白名单页面,检查用户登录状态
if (user.isLogin) {
//用户登录成功后,有cookie的token信息,但是不一定获取到了身份,应该去数据库获取身份信息
if (user.roles.length === 0) {
try {
//重新获取用户信息
const data = await user.getInfo()
addDynamicRoutes(data.menus)
next({
...to,
replace: true
})
} catch (error) {
//出现异常,退出登录状态
await user.logout()
next({
path: '/login'
})
}
} else {
next()
}
} else {
//未登录,去登录
next('/login')
}
}
})
/**
* 获取route动态菜单
*/
function addDynamicRoutes() {
//...
}
(四)动态加载菜单
由于状态管理在刷新时,不会在store保存用户身份信息,会去重新获取用户身份信息时,同时因为基于RBAC模型的权限控制。这时用户重新获取所有角色所能访问的动态菜单资源。
1.使用动态菜单的原因及流程
(1)在页面中用户是通过router跳转的,如果后台导航目录运营人员新增菜单后,前端人员得在路由表中手动添加上,这样导航才能点击才能对应上页面,比较麻烦。
(2)因此在中大型项目采用的都是添加动态路由的方式解决的。用户在未登录前,可以访问静态路由列表constantRouterMap
。登录成功后,需要去后台获取权限允许的菜单添加到路由列表router中。
(3) 在初始化菜单中,首先判断store中的数据是否存在,如果存在,则说明这次跳转是正常的跳转,而不是用户按F5键或者直接在地址栏输入某个地址进入的,这时直接返回,不必执行菜单初始化。
(4)若store中不存在菜单数据,则需要初始化菜单数据,通过服务端查询菜单的api方法获得菜单的JSON数据之后,首先通过router.addRoute(element)
方法将服务器返回的JSON转为router需要的格式,这里主要是转component,因为服务端返回的component是一个字符串,而router中需要的却是一个组件,因此我们根据服务端返回的component动态加载需要的组件即可。
(5)首先,后台给的菜单数据应该具有树形结构,数据格式准备成功之后,一方面将数据存到store中,提供给菜单栏使用,才能方便渲染到导航菜单栏<a-menu>
中。另一方面,利用路由中的addRoutes方法将之动态添加到路由中,数据需要经过处理成route对象,包括path、name、component等数据,添加到routes数组中。
(6)当没有匹配到任何路径时候应该前往404路由,且必须最后再添加404路由。因为若该路由定义存在,则在动态路由未添加完成之前,访问的系统内页路由将找不到匹配路由,会直接匹配该路由定义而跳转404页面,无法达到正确跳转,所以将其抽出来,待权限路由添加完成后,再手动添加404路由。
首先判断动态菜单是否已经存在,如果存在就不再重复加载损耗性能,否则调用后台接口加载数据库存储菜单数据,加载成功之后通过router.addRoutes方法将菜单数据动态添加到路由器并同时保存菜单数据及加载状态以备后用。导航菜单加载成功之后,调用后台接口查找用户权限数据并保存起来,供权限判断时读取。
2.详细流程案例
(1)访问接口返回树形菜单数据
通过访问接口,应该返回如下形式的树形菜单数据:
export const menuTreeData= [
{
id:1,
parentId:0,
name:'App',
path:"/app",
icon:"menu",
children:[
{
id: 11,
parentId:1,
name:'AppUser',
path:"/app/user",
icon :"user"
},
{
id: 12,
parentId:1,
name:"AppDept",
path:"/app/dept",
icon :"office-building"
},
{
id:13,
parentId:1,
name:"AppRole",
path:"/app/role",
icon:"avatar",
}]
},
{
id:2,
parentId:0,
name :'sys',
path:"/sys"
icon:"setting"
children:[
{
id:21,
parentId:2,
name:"SysUser",
path:"/sys/user",
icon:"user-filled"
}]
}]
(2)管理菜单加载状态
将菜单加载状态看成系统全局的状态,在stores目录下新建menu.ts文件,里面保存着加载后的导航菜单树数据。
export const useMenuStore = defineStore('menu', {
// 定义状态:一个函数,返回一个对象
state: () => ({
menuTree: [],//导航树,由
routeLoaded:false,//判断动态路由是否已加载
}),
getters: {
},
actions: {
setMenuTree(state, menuTree){ // 设置导航菜单树
state.menuTree= menuTree;
}
},
})
(3)动态添加路由
因为我们使用的是动态路由,因此去掉src/router/index.ts中不需要的路由,包括需要根据权限返回的路由定义和全匹配404路由定义path: "/:pathMatch(.*)*"
。仅留下任何人都可以访问的全局静态路由。
export const constantRouterMap = [
{
path: '/',
name: 'Home',
component: Layout,
redirect: '/home',
children: [
{
path: '/home',
name: 'home',
component: () => import('@/views/Home.vue'),
meta: {
title: '首页',
icon: 'home'
}
},
{
path: '/personal',
name: 'personal',
component: () => import('@/views/personal/index.vue'),
children:[]
},
]
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/404',
name: 'NotFound',
component: NotFound
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes : constantRouterMap
})
export default router
将树形菜单列表转换为Route对象,并注册到router中。
/**
* 添加动态(菜单)路由,递归的将树形菜单列表转换为Route对象,并添加到routes中
* @param {*} menuList 树形菜单列表
* @param {*} routes 递归创建的动态(菜单)路由
*/
function addDynamicRoutes (menuList = [], routes = []) {
var temp = []
for (var i = 0; i < menuList.length; i++) {
if (menuList[i].children && menuList[i].children.length >= 1) {
temp = temp.concat(menuList[i].children)
} else if (menuList[i].url && /\S/.test(menuList[i].url)) {
menuList[i].url = menuList[i].url.replace(/^\//, '')
// 创建路由配置
var route = {
path: menuList[i].url,
component: null,
name: menuList[i].name,
meta: {
icon: menuList[i].icon,
index: menuList[i].id
}
}
try {
// 根据菜单URL动态加载vue组件,这里要求vue组件须按照url路径存储
// 如url="sys/user",则组件路径应是"@/views/sys/user.vue",否则组件加载不到
let array = menuList[i].url.split('/')
let url = ''
for(let i=0; i<array.length; i++) {
url += array[i].substring(0,1).toUpperCase() + array[i].substring(1) + '/'
}
url = url.substring(0, url.length - 1)
route['component'] = resolve => require([`@/views/${url}`], resolve)
} catch (e) {}
routes.push(route)
}
}
if (temp.length >= 1) {
addDynamicRoutes(temp, routes)
} else {
console.log('动态路由加载...')
console.log(routes)
console.log('动态路由加载完成.')
}
return routes
}
注意将动态路由注册到首页的路径上,即挂在“/”路径下面,这样就可以应用到通用布局Layout上。
// 添加动态路由
let dynamicRoutes = addDynamicRoutes(res.data)
router.options.routes[0].children = router.options.routes[0].children.concat(dynamicRoutes)
router.addRoutes(router.options.routes)
(4)渲染菜单到侧边栏
修改src/layout/components/PageSidebar.vue的脚本,菜单数据取自