管理系统项目
电商后台管理
技术功能
token 验证
cdn 加速静态资源
设置开发模式和生产模式
路由懒加载
mixin 使用 统一管理表单验证规则
面包屑导航抽离
时间格式转换过滤器
生成树形结构
设计问题
token 怎么做登录验证
前后端完全分离的情况下,Vue 项目中实现 token 验证大致思路如下:
- 第一次登录的时候,前端调后端的登陆接口,发送用户名和密码
- 后端收到请求,验证用户名和密码,验证成功,就给前端返回一个 token
- 前端拿到 token,将 token 存储到 localStorage 和 vuex 中,并跳转路由页面
- 前端每次跳转路由,就判断 localStroage 中有无 token ,没有就跳转到登录页面,有则跳转到对应路由页面
- 每次调后端接口,都要在请求头中加 token
- 后端判断请求头中有无 token,有 token,就拿到 token 并验证 token,验证成功就返回数据,验证失败(例如:token 过期)就返回 401,请求头中没有 token 也返回 401
- 如果前端拿到状态码为 401,就清除 token 信息并跳转到登录页面
- 调取登录接口成功,会在回调函数中将 token 存储到 localStorage 和 vuex 中
我把 Token 存在 sessionStorage,检查有无 Token ,每次请求在 Axios 请求头上进行携带
怎么进行判断处理
router/index.js 文件
//挂载路由导航守卫
router.beforeEach((to, from, next) => {
if (to.path === "/login") return next();
const tokenStr = window.sessionStorage.getItem("token");
if (!tokenStr) return next("/login");
next();
});
main.js 文件里面
//拦截器给header写入token
axios.interceptors.request.use((config) => {
config.headers.Authorization = window.sessionStorage.getItem("token");
return config;
});
Vue.prototype.$http = axios;
使用 router.beforeEach 注册一个全局前置守卫,判断用户是否登陆
token 有自己的过期时限,并且是在后台实现,前台虚无考虑那么多,具体前台的步骤分为三部
在登陆的时候后台会给一个 token 码,前台将其存储在 cookie,localstroage 或者 localsession 中即可
请注意需要在 tooken 的前边拼接字符串'Bearer '+,固定格式
login(){
axios.post('/user/login',this.user).then((res)=>{
localStorage.setItem('token',"Bearer "+res.data.res.token)
})
}在 router 中设置守卫导航
判断 token 是否存在,如果存在将携带 token 进行下一簇的操作,如果不存在,则返回登陆
router.beforeEach((to, from, next) => {
if (to.matched.some((route) => route.meta.Auth)) {
if (localStorage.getItem("token")) {
next();
} else {
next({
path: "/login",
query: {
returnURL: to.path,
},
});
}
} else {
next();
}
});
在 axios 的请求拦截器中携带 tooken 进行请求
config.headers.Authorization
axios.interceptors.request.use(config=>{
const token=localStorage.getItem('token')
// if(token){
token?config.headers.Authorization=token:null;
// }
return config
})
每次请求时都会携带 token,后台验证不验证 token 就是后台的问题了
设置 token 的回复拦截器,对回执码错误的进行操作处理
axios.interceptors.response.use((res) => {
if (res.data.res_code === 401) {
router.replace("/login");
localStorage.removeItem("token");
}
return res;
});
token 的基本原理
access token 用来访问业务接口,由于有效期足够短,盗用风险小,也可以使请求方式更宽松灵活
refresh token 用来获取 access token,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的 session 一样处理
Acesss Token
- 访问资源接口(API)时所需要的资源凭证
- 简单 token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)
- 特点:
- 服务端无状态化、可扩展性好
- 支持移动端设备
- 安全
- 支持跨程序调用
- token 的身份验证流程:
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名与密码
- 验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端
- 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里
- 客户端每次向服务端请求资源的时候需要带着服务端签发的 token
- 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据
- 每一次请求都需要携带 token,需要把 token 放到 HTTP 的 Header 里
- 基于 token 的用户认证是一种服务端无状态的认证方式,服务端不用存放 token 数据。用解析 token 的计算时间换取 session 的存储空间,从而减轻服务器的压力,减少频繁的查询数据库
- token 完全由应用管理,所以它可以避开同源策略
Refresh Token
- 另外一种 token——refresh token
- refresh token 是专用于刷新 access token 的 token。如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码,会很麻烦。有了 refresh token,可以减少这个麻烦,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作。
- Access Token 的有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了。
- Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求。
Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。
Session 和 Token 并不矛盾,作为身份认证 Token 安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重放攻击,而 Session 就必须依赖链路层来保障通讯安全了。如果你需要实现有状态的会话,仍然可以增加 Session 来在服务器端保存一些状态。
所谓 Session 认证只是简单的把 User 信息存储到 Session 里,因为 SessionID 的不可预测性,暂且认为是安全的。而 Token ,如果指的是 OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对 App 。其目的是让某 App 有权利访问某用户的信息。这里的 Token 是唯一的。不可以转移到其它 App 上,也不可以转到其它用户上。Session 只提供一种简单的认证,即只要有此 SessionID ,即认为有此 User 的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方 App。所以简单来说:如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。
Token 和 JWT 的区别
相同:
- 都是访问资源的令牌
- 都可以记录用户的信息
- 都是使服务端无状态化
- 都是只有验证成功后,客户端才能访问服务端上受保护的资源
区别:
- Token:服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户信息,然后验证 Token 是否有效。
- JWT: 将 Token 和 Payload 加密后存储于客户端,服务端只需要使用密钥解密进行校验(校验也是 JWT 自己实现的)即可,不需要查询或者减少查询数据库,因为 JWT 自包含了用户信息和加密的数据。
优化问题
细节处理
打包是 console 的处理: babel-plugin-transform-remove-console
- plugins: [ "transform-remove-console" ]
生成打包报告
- vue-cli-service build --report
- UI 面版
cdn 怎么引入的
通过 externals 加载外部 CDN 资源
vue.config.js 里 在生产模式 config.set(externals{键值对}) 在生产模式
index.html 里面在设置 cdn 链接
默认情况下,通过 import 语法导入的第三方依赖包,最终会被打包合并到同一个文件中,从而导致打包成功后,单文件体积过大的问题。
为了解决上述问题,可以通过 webpack 的 externals 节点,来配置并加载外部的 CDN 资源。凡是声明在 externals 中的第三方依赖包,都不会被打包。
开发时直接下载引用
- 发布时把直接引入可以省的包 使用 window 全局的方式来查找 也就是说 CDN 挂载 通过 CDN 挂载的方式进行引用
通过执行 npm run preview – --report 来分析 webpack 打包之后的结果,观察各个静态资源的大小。可以发现占用空间最多的是第三方依赖。如 vue、element-ui、 ECharts 等。
你可以使用 CDN 外链的方式引入这些第三方库,这样能大大增加构建的速度(通过 CDN 引入的资源不会经 webpack 打包)。如果你的项目没有自己的 CDN 服务的话,使用一些第三方的 CDN 服务,如 unpkg 等是一个很好的选择,它提供过了免费的资源加速,同时提供了缓存优化,由于你的第三方资源是在 html 中通过 script 引入的,它的缓存更新策略都是你自己手动来控制的,省去了你需要优化缓存策略功夫。
cdn 缓存优化
如果没有使用 cdn
- 用户在浏览器中输入要访问的域名。
- 浏览器向 DNS 服务器请求对该域名的解析。
- DNS 服务器返回该域名的 IP 地址给浏览器。
- 浏览器使用该 IP 地址向服务器请求内容。
- 服务器将用户请求的内容返回给浏览器。
如果使用了 cdn
- 用户在浏览器中输入要访问的域名。
- 浏览器向 DNS 服务器请求对域名进行解析。由于 CDN 对域名解析进行了调整,DNS 服务器会最终将域名的解析权交给 CNAME指向的 CDN 专用 DNS 服务器。
- CDN 的 DNS 服务器将CDN 的负载均衡设备 IP 地址返回给用户。
- 用户向 CDN 的负载均衡设备发起内容 URL 访问请求。
- CDN 负载均衡设备会为用户选择一台合适的缓存服务器提供服务。 选择的依据包括: 根据用户 IP 地址,判断哪一台服务器距离用户最近; 根据用户所请求的 URL 中携带的内容名称,判断哪一台服务器上有用户所需内容; 查询各个服务器的负载情况,判断哪一台服务器的负载较小。 基于以上这些依据的综合分析之后,负载均衡设置会把缓存服务器的 IP 地址返回给用户。
- 用户向缓存服务器发出请求。
- 缓存服务器响应用户请求,将用户所需内容传送到用户。 如果这台缓存服务器上并没有用户想要的内容,而负载均衡设备依然将它分配给了用户,那么这台服务器就要向它的上一级缓存服务器请求内容,直至追溯到网站的源服务器将内容拉取到本地。
使用 CDN 服务的网站,只需将其域名的解析权交给 CDN 的负载均衡设备,CDN 负载均衡设备将为用户选择一台合适的缓存服务器,用户通过访问这台缓存服务器来获取自己所需的数据。 由于缓存服务器部署在网络运营商的机房,而这些运营商又是用户的网络服务提供商,因此用户可以以最短的路径,最快的速度对网站进行访问。因此,CDN 可以加速用户访问速度,减少源站中心负载压力。
路由来加载
// 分组名生成文件
const Login = () => import(/* webpackChunkName: "login_home_welome" */ 'components/login/Login')
const Home = () => import(/* webpackChunkName: "login_home_welome" */ 'components/home/Home')
const Welcome = () => import(/* webpackChunkName: "login_home_welome" */ 'components/home/welcome/Welcome')
const Users = () => import(/* webpackChunkName: "Users_Rights_Roles" */ 'components/home/users/Users')
const Rights = () => import(/* webpackChunkName: "Users_Rights_Roles" */ 'components/home/power/rights/Rights')
const Roles = () => import(/* webpackChunkName: "Users_Rights_Roles" */ 'components/home/power/roles/Roles')
const Cate = () => import(/* webpackChunkName: "Cate_Params" */ 'components/home/goods/cate/Cate')
const Params = () => import(/* webpackChunkName: "Cate_Params" */ 'components/home/goods/params/Params')
const GoodsList = () => import(/* webpackChunkName: "GoodsList_Add" */ 'components/home/goods/list/List')
const Add = () => import(/* webpackChunkName: "GoodsList_Add" */ 'components/home/goods/list/children/Add')
const Order = () => import(/* webpackChunkName: "Order_Report" */ 'components/home/order/Order')
const Report = () => import(/* webpackChunkName: "Order_Report" */ 'components/home/report/Report')
// 独立生成一个文件
const Login = () => import('components/login/Login')
const Home = () => import('components/home/Home')
const Welcome = () => import('components/home/welcome/Welcome')
const Users = () => import('components/home/users/Users')
const Rights = () => import('components/home/power/rights/Rights')
const Roles = () => import('components/home/power/roles/Roles')
const Cate = () => import('components/home/goods/cate/Cate')
const Params = () => import('components/home/goods/params/Params')
const GoodsList = () => import('components/home/goods/list/List')
const Add = () => import('components/home/goods/list/children/Add')
const Order = () => import('components/home/order/Order')
const Report = () => import('components/home/report/Report')
cdn 原理
CDN (全称 Content Delivery Network),即内容分发网络
构建在现有网络基础之上的智能虚拟网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN
的关键技术主要有内容存储和分发技术
简单来讲,CDN
就是根据用户位置分配最近的资源
于是,用户在上网的时候不用直接访问源站,而是访问离他“最近的”一个 CDN 节点,术语叫边缘节点,其实就是缓存了源站内容的代理服务器。
在没有应用CDN
时,我们使用域名访问某一个站点时的路径为
用户提交域名 → 浏览器对域名进行解释 →
DNS
解析得到目的主机的 IP 地址 → 根据 IP 地址访问发出请求 → 得到请求数据并回复
应用CDN
后,DNS
返回的不再是 IP
地址,而是一个CNAME
(Canonical Name ) 别名记录,指向CDN
的全局负载均衡
CNAME
实际上在域名解析的过程中承担了中间人(或者说代理)的角色,这是CDN
实现的关键
由于没有返回IP
地址,于是本地DNS
会向负载均衡系统再发送请求 ,则进入到CDN
的全局负载均衡系统进行智能调度:
- 看用户的 IP 地址,查表得知地理位置,找相对最近的边缘节点
- 看用户所在的运营商网络,找相同网络的边缘节点
- 检查边缘节点的负载情况,找负载较轻的节点
- 其他,比如节点的“健康状况”、服务能力、带宽、响应时间等
结合上面的因素,得到最合适的边缘节点,然后把这个节点返回给用户,用户就能够就近访问CDN
的缓存代理
怎么设置生产模式
process.env.NODE_ENV===productions
设置打包入口
在 vue.config.js 中设置 configureWebpack 节点 以操作对象 来定义 webpack 的打包配置
chainwebpack 根据 process.env.NODE_ENV 来 设置打包入口
开发模式的入口文件为 main.-dev.js
发布生成模式入口文件 main-prod.js
module.exports = {
chainWebpack: (config) => {
// 发布模式
config.when(process.env.NODE_ENV === "production", (config) => {
config.entry("app").clear().add("./src/main-prod.js");
config.set("externals", {
vue: "Vue",
"vue-router": "VueRouter",
axios: "axios",
echarts: "echarts",
"vue-quill-editor": "VueQuillEditor",
});
config.plugin("html").tap((args) => {
args[0].isProd = true;
return args;
});
});
// 开发模式
config.when(process.env.NODE_ENV === "development", (config) => {
config.entry("app").clear().add("./src/main-dev.js");
config.plugin("html").tap((args) => {
args[0].isProd = false;
return args;
});
});
},
};
mixin 的使用
mixin 基本特点
Mixins:则是在引入组件之后与组件中的对象和方法进行合并,相当于扩展了父组件的对象与方法,可以理解为形成了一个新的组件
1、方法和参数在各组件中不共享
如混入对象中有一个 cont:1 的变量,在组件 A 中改变 cont 值为 5,这时候在组件 B 中获取这个值,拿到的还是 1,还是混入对象里的初始值,数据不共享
2、值为对象的选项
如 methods,components 等,选项会被合并,键冲突的组件会覆盖混入对象的,比如混入对象里有个方法 A,组件里也有方法 A,这时候在组件里调用的话,执行的是组件里的 A 方法
3、值为函数的选项
如 created,mounted 等,就会被合并调用,混合对象里的钩子函数在组件里的钩子函数之前调用,同一个钩子函数里,会先执行混入对象的东西,再执行本组件的
4、与 vuex 的区别
vuex:用来做状态管理的,里面定义的变量在每个组件中均可以使用和修改,在任一组件中修改此变量的值之后,其他组件中此变量的值也会随之修改。
Mixins:可以定义共用的变量,在每个组件中使用,引入组件中之后,各个变量是相互独立的,值的修改在组件中不会相互影响。
5、与公共组件的区别
组件:在父组件中引入组件,相当于在父组件中给出一片独立的空间供子组件使用,然后根据 props 来传值,但本质上两者是相对独立的。
对表单规则的使用 mixin
导出不同的表单规则对象
验证手机号和邮箱对象中设置 data 函数 可以进行正则的验证 返回添加表单的验证规则 和修改表单的规则
验证其他表单直接可以在 data 函数中 可以直接返回 表单的验证规则对象
export const userAddFormRulesMixin = {
data() {
// 验证邮箱的规则
var checkEmail = (rule, value, callback) => {
const regEmail = /^\w+@\w+(\.\w+)+$/; // 验证邮箱的正则表达式
if (regEmail.test(value)) {
return callback(); // 合法邮箱
}
// 返回一个错误提示
callback(new Error("请输入合法的邮箱"));
};
// 验证手机的规则
var checkMobeli = (rule, value, callback) => {
const regMobile = /^1[34578]\d{9}$/;
if (regMobile.test(value)) {
return callback();
}
// 返回一个错误提示
callback(new Error("请输入合法的手机号码"));
};
return {
// 添加表单的验证规则对象
addFormRules: {
username: [
{ required: true, message: "请输入登录名称", trigger: "blur" },
{
min: 3,
max: 10,
message: "长度在 3 到 10 个字符",
trigger: "blur",
},
],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{
min: 6,
max: 15,
message: "用户名长度在 6 到 15 个字符",
trigger: "blur",
},
],
email: [
{ required: true, message: "请输入邮箱", trigger: "blur" },
{ validator: checkEmail, trigger: "blur" },
],
mobile: [
{ required: true, message: "请输入手机", trigger: "blur" },
{ validator: checkMobeli, trigger: "blur" },
],
},
// 修改用户数据验证规则
editFormRules: {
email: [
{ required: true, message: "请输入用户邮箱", trigger: "blur" },
{ validator: checkEmail, trigger: "blur" },
],
mobile: [
{ required: true, message: "请输入用户手机", trigger: "blur" },
{ validator: checkMobeli, trigger: "blur" },
],
},
};
},
};
export const rolesFormRulesMixin = {
data() {
return {
addFormRules: {
roleName: [
{ required: true, message: "请输入角色名字", trigger: "blur" },
{
min: 3,
max: 10,
message: "输入的范围是 3 ~ 10 为字符",
triggetr: "nlur",
},
],
roleDesc: [
{ required: true, message: "请输入角色描述", trigger: "blur" },
{
min: 5,
max: 20,
message: "输入的范围是 5 ~ 20 为字符",
triggetr: "nlur",
},
],
},
editFormRules: {
roleName: [
{ required: true, message: "请输入角色名字", trigger: "blur" },
{
min: 3,
max: 10,
message: "输入的范围是 3 ~ 10 为字符",
triggetr: "nlur",
},
],
roleDesc: [
{ required: true, message: "请输入角色描述", trigger: "blur" },
{
min: 5,
max: 20,
message: "输入的范围是 5 ~ 20 为字符",
triggetr: "nlur",
},
],
},
};
},
};
export const paramsFormRulesMixin = {
data() {
return {
// 添加表单的验证规则
addFormRules: {
attr_name: [
{ required: true, message: "请输入添加的分类", trigger: "blur" },
{
min: 2,
max: 10,
message: "长度在 2 到 10 个字符",
trigger: "blur",
},
],
},
// 修改表单的验证
editFormRules: {
attr_name: [
{ required: true, message: "请输入修改的信息", trigger: "blur" },
{
min: 2,
max: 10,
message: "长度在 2 到 10 个字符",
trigger: "blur",
},
],
},
};
},
};
export const goodsAddFormRulesMixin = {
data() {
return {
// 添加商品验证规则
addFormRules: {
goods_name: [
{ required: true, message: "请输入商品的名称", trigger: "blur" },
{
min: 2,
max: 30,
message: "请输入 2 ~ 30 范围的字符",
trigger: "blur",
},
],
goods_price: [
{ required: true, message: "请输入商品的价格", trigger: "blur" },
],
goods_weight: [
{ required: true, message: "请输入商品的重量", trigger: "blur" },
],
goods_number: [
{ required: true, message: "请输入商品的数量", trigger: "blur" },
],
goods_cat: [
{ required: true, message: "请选择商品分类", trigger: "blur" },
],
},
};
},
};
在需要的组建里面使用
mxins:[导出的表单验证对象名]
给 form 设置一个 ref 再通过 this.$refs.refname.validate 来进行表单预校验
面包屑导航组件抽离
考虑到每个页面都有面包屑导航 每个页面代码有高度重复性 所以想办法抽离出来各个 只需要传入配置信息 面包屑导航的名字就行了
设置一个公共组件 通过 props 来接受其他组件传递过来的导航名
<template>
<div>
<!-- 面包屑导航区域 -->
<el-breadcrumb separator-class="el-icon-arrow-right">
<el-breadcrumb-item :to="{ path: '/home' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ name1 }}</el-breadcrumb-item>
<el-breadcrumb-item>{{ name2 }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</template>
<script>
export default {
name: "Breadcrumb",
props: {
name1: String,
name2: String,
},
};
</script>
<style lang="less" scoped></style>
在每个组件中设置
components:{ Breadcrumb }
设置统一的提示和对话框
Vue.prototype.$message = Message;
Vue.prototype.$confirm = MessageBox.confirm;
时间格式过滤器
Vue.filter("dataFormat", function (originVal) {
const dt = new Date(originVal * 1000); //这里的问题是 要给时间戳乘以1000 如果不是10位时间戳的话(不包含毫秒) 要把10位时间戳*1000
const y = dt.getFullYear();
const m = (dt.getMonth() + 1 + "").padStart(2, "0");
const d = (dt.getDate() + "").padStart(2, "0");
const hh = (dt.getHours() + "").padStart(2, "0");
const mm = (dt.getMinutes() + "").padStart(2, "0");
const ss = (dt.getSeconds() + "").padStart(2, "0");
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
});
生成树形结构
<!-- 分配权限的对话框 -->
<el-dialog
title="分配权限"
:visible.sync="SetRightDialogVisible"
width="50%"
@close="SetRightDialogVisibleClosed"
>
<!-- 树形控件 -->
<el-tree :data="rightsList" :props="treeProps" show-checkbox node-key="id" :default-expand-all="true" :default-checked-keys="defKeys" ref="treeRef"></el-tree>
<span slot="footer" class="dialog-footer">
<el-button @click="SetRightDialogVisible = false">取 消</el-button>
<el-button type="primary" @click="allotRights">确 定</el-button>
</span>
</el-dialog>
// 树形控件的绑定对象
treeProps: {
children: 'children',
label: 'authName'
},
// 树形控件 -> 默认选中的节点id值数组
defKeys: [],
// 当前即将分配权限的角色的ID
rolesId: ''
// 展示分配权限的对话框
async showSetRightDialog(roles) {
this.rolesId = roles.id
// 获取所有权限列表
const { data: res } = await this.$http.get('rights/tree')
if (res.meta.status !== 200) {
return this.$message.error('获取权限列表失败!')
}
// 获取到的权限数据保存
this.rightsList = res.data
console.log(this.rightsList)
// 递归获取三级节点
this.getLeafKeys(roles, this.defKeys)
this.SetRightDialogVisible = true
},
// 递归的形式,获取角色下所有的三级权限的id,并保存到 defKeys数组中
getLeafKeys(node, arr) {
// 如果当前node没有children属性则是三级节点
if (!node.children) {
return arr.push(node.id)
}
node.children.forEach(item => this.getLeafKeys(item, arr))
},
// 监听分配权限对话框的关闭事件
SetRightDialogVisibleClosed() {
// 清空 defkeys 数组 避免累积
this.defKeys = []
},
// 点击为角色分配权限
async allotRights() {
const keys = [...this.$refs.treeRef.getCheckedKeys(), ...this.$refs.treeRef.getHalfCheckedKeys()]
const idStr = keys.join(',')
const { data: res } = await this.$http.post(`roles/${this.rolesId}/rights`, { rids: idStr })
if (res.meta.status !== 200) {
return this.$message.error('分配权限失败!')
}
this.$message.success('分配权限成功!')
this.getRolesList()
this.SetRightDialogVisible = false
}
内容管理系统
Typescript 使用
typescript 和 javascript 的区别
TypeScript 简称 TS,JavaScript 的超集,就是在 JavaScript 的基础上做一层封装,封装出 TS 的特性,最终可以编译为 JavaScript
TS 最初是为了让习惯编写强类型语言的后端程序员能快速编写 web 应用,因为 JavaScript 没有强数据类型,所以 TypeScript 提供了静态数据类型,这是 TypeScript 的核心
Typescript 的优势在哪
静态类型
静态类型化是一种功能,可以在开发人员编写脚本是检测错误,有了这项功能,就会允许开发人员编写更健壮的代码并对其进行维护,以便使得代码质量更好、更清晰。
函数 fun 接受两个类型的 number 的参数,传入非 number 的参数报错
TypeScript 能使用 JavaScript 中的所有代码和编码概念
TypeScript 从核心语言方面和类概念的模塑方面对 JavaScript 对象模型进行扩展
JavaScript 代码可以在无需任何修改的情况下与 TypeScript 一同工作,同时可以使用编译器将 TypeScript 代码转换为 JavaScript
TypeScript 通过类型注解提供编译时的静态类型检查
TypeScript 中的数据要求带有明确的类型,JavaScript 不要求
TypeScript 为函数提供了缺省参数值
TypeScript 引入了 JavaScript 中没有的“类”概念
TypeScript 中引入了模块的概念,可以把声明、数据、函数和类封装在模块中
基本功能
登录的逻辑业务
localStorage 的封装
class LocalCache {
setCache(key: string, value: any) {
window.localStorage.setItem(key, JSON.stringify(value));
}
getCache(key: string) {
const value = window.localStorage.getItem(key);
if (value) {
return JSON.parse(value);
}
}
deleteCache(key: string) {
window.localStorage.removeItem(key);
}
clearCache() {
window.localStorage.clear();
}
}
export default new LocalCache();
对网络请求的数据用 Vuex 分模块进行存储,同时需要在本地缓存一份,因为有时已经登陆,在其他页面点击刷新按钮,vuex 内的数据是在内存里面的,那么刷新后数据就会消失,此时就可以从本地的缓存的数据进行重新加载。
登陆的逻辑步骤:
1、将账号密码进行验证,返回结果 2、获取用户的基本信息 3、获取主页的信息。(因为是管理系统,那么不同角色的主页面是不一致的) 4、数据的保存 5、页面跳转
如果同时在 login 组件内进行如此多的步骤,有些步骤是和本组件的关系是不相关的,其实这部分逻辑可以在 vuex 的 action 内进行。
import { Module } from "vuex";
import router from "@/router";
import {
accountLoginRequest,
userInfoRequest,
userMenuRequest,
} from "@/service/login";
import { AccountLoginType } from "@/service/login/types";
import localCache from "@/utils/cache";
import { RootStateType } from "../types";
import { LoginStateType } from "./types";
const loginModule: Module<LoginStateType, RootStateType> = {
namespaced: true,
state() {
return {
token: "",
userInfo: {},
userMenus: [],
};
},
getters: {},
mutations: {
changeToken(state, token: string) {
state.token = token;
},
changeUserInfo(state, payload: any) {
state.userInfo = payload;
},
changeUserMenus(state, payload: any) {
state.userMenus = payload;
},
},
actions: {
async accountLoginAction(context, payload: AccountLoginType) {
// 登陆信息
const loginResult = await accountLoginRequest(payload);
const { id, token } = loginResult.data;
context.commit("changeToken", token);
localCache.setCache("token", token);
// 登陆后用户信息
const userInfoResult = await userInfoRequest(id);
context.commit("changeUserInfo", userInfoResult.data);
localCache.setCache("userInfo", userInfoResult.data);
//请求用户菜单
const userMenuResult = await userMenuRequest(id);
context.commit("changeUserMenus", userMenuResult.data);
localCache.setCache("userMenus", userMenuResult.data);
// 路由跳转
router.push("/main");
},
uploadAction(context) {
// 刷新时直接从本地获取
const token = localCache.getCache("token");
if (token) {
context.commit("changeToken", token);
}
const userInfo = localCache.getCache("userInfo");
if (userInfo) {
context.commit("changeUserInfo", userInfo);
}
const userMenus = localCache.getCache("userMenus");
if (userMenus) {
context.commit("changeUserMenus", userMenus);
}
},
},
};
export default loginModule;
遇到的问题
主页面刷新问题
保存在 vuex 中的 token userinfo usermenu 会在用户刷新之后 消失
localstorage 里面还有 这表明是登录状态 vuex 里面没有加载数据
在 vuex 的 index.ts 里面设置一个 setupStore
然后 再 maints 里面使用这个 setupstore
export function setupStore() {
store.dispatch("login/loadLocalLogin");
}
刷新就会直接跳转到 notfound 页面
setupstore 和 use(router)的顺序问题
在 router 的导航守卫
进行页面跳转之前,通过 getRoutes 打印路径信息 得到数组 由路径匹配路由
再打印 to 输出即将跳转到的对象 path 正常 但是匹配到的 name 没找到而是 not found
在 main.ts 中 app.use(router) 刷新后 页面程序加载 会重新执行 ts 文件
执行 app.use(router) 注册路由 执行 install 函数 获取当前的 path
获取完成之后立马会去匹配路径 当前没有我们动态注册的路径 就匹配到 not found 路由守卫是回调函数
执行 setupstore 里面已经注册了动态路由 routes 已经确定了
执行路由守卫 但是在上一步已经把 to 匹配好了 确定是 notfound
解决方法是把 setupstore()放到 app.use(router)前面
在 path 和路由匹配之前 path : /user -> user
setupstore()
权限管理
方法一:不管什么角色登陆,在开发的时候,在前端都全部配置好路由的映射关系,只是在展示的时候,对应路由的跳转不展现出来。 引发的问题: 虽然页面没有展示,但是可以通过浏览器的地址栏进行 “套”,就会显示对应映射的组件,但组件上可能是没有什么东西的,虽然这样但也不好,会不安全。
方法二:在前端这里,为不同的角色设置好不同的映射关系(映射数组),请求数据用户的角色是什么,再把该角色的数组加入到 main 对应的 children 内。 引发问题: 若后端又有新的角色出现,那么前端这边也要跟着进行修改,重新进行部署。
方法三:在前端创建好所有路径对应的组件,但是根据后端返回的菜单数据,进行动态的生成路由。后端在返回对应的路径信息时,也要把该路径映射组件的路径一起返回。但是也要后端要增加一个字段,来放置组件的位置。
方法四:在前端创建好所有路径对应的组件,但是根据后端返回的菜单数据,进行动态的生成路由。后端在返回对应的路径信息时,在前端就设置好路径和组件之间的映射关系,在前端根据传过来的路径进行查找,查找到就找到了该路径和组件的映射关系。
菜单动态生成路由映射
项目难点
类型确定
Exclude < T, U > -用于从类型T中去除不在U类型中的成员;
Extract < T, U > -用于从类型T中取出可分配给U类型的成员;
NonNullable < T > -用于从类型T中去除undefined和null类型;
ReturnType < T > -获取函数类型的返回类型;
InstanceType < T > -获取构造函数的实例类型;
ref 可以传入一个泛型 写错了可以报错 拼写错误
InstanceType<T>
获取构造函数的实例类型
例如 demo.vue 组件在导出时候 组件名只是一个描述 </demo>
是一个组件实例 demo 相当于一个类
ref<InstanceType<typeof ElForm>>();
在 ref 进行绑定组件或这元素的时候,在 ts 中为了更好的推测,和代码规范,需要具体指明绑定的类型,比如:
// element-plus组件
const elFormRef = ref<InstanceType<typeof ElForm>>();
// 自定义组件
let loginUserRef = ref<InstanceType<typeof LoginUser>>();
let loginPhoneRef = ref<InstanceType<typeof LoginPhone>>();
组件使用 instanceType<typeof .... >: .vue 文件 export 的对象是组件的描述,是有着具体值的对象,在 template 内使用的是根据组件描述创建的实例,这个组件描述是不能直接作为一个类型的。该语法可以得到具有构造函数的实例对象,这时才可以作为一个类型传入 ref
想对创建出来的 store 对象进行类型的约束,可以这样做
// 模块内的state的类型
import type { LoginStateType } from "./login/types";
// 根模块内的state类型
export interface RootStateType {}
// 定义一个接口,集合展示模块内的类型
interface ModuleType {
login: LoginStateType;
}
// 导出交叉类型
export type RootWithModule = RootStateType & ModuleType;
router/index.ts
// 模块内的state的类型
import type { LoginStateType } from "./login/types";
// 根模块内的state类型
export interface RootStateType {}
// 定义一个接口,集合展示模块内的类型
interface ModuleType {
login: LoginStateType;
}
// 导出交叉类型
export type RootWithModule = RootStateType & ModuleType;
知识点补充一:PropType
vue3 为结合 ts ,props 设置自定类型
- 用 vue3 封装组件时,难免需要规定较为复杂的数据类型,用于声明组件接受的参数类型,比如下面这种数据类型接口
- 假设我现在需要指定一个变量 list 的数据类型为 Array,如何规范 Array 的格式呢? 错误示例:type: Array: ColumnProps[], 正确示例:type: Array as PropType<ColumnProps[]>, 原因:此处 Array 并非作为数据类型存在,而是作为 构造函数 存在,为构造函数指定类型,应用 PropType 这个 API
import { defineComponent, PropType } from "vue";
// ....代码省略
props: {
formItem: {
type: Array as PropType < IFormItemType[] > ,
default:() = >[]
}
}
知识点补充二:require.context 方法
webpack 的函数:根据传入的参数,获取对应文件的相对路径,返回值调用.keys()方法和得到路径的数组。
权限管理:根据传递对来的路径,去匹配对应的 route,设置动态路由
import type { RouteRecordRaw } from "vue-router";
export default function (useMenus: any[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = [];
// 加入全部路由
const allRoutes: RouteRecordRaw[] = [];
const routeFiles = require.context("@/router/main", true, /\.ts$/);
routeFiles.keys().forEach((filePath) => {
const routeModule = require("@/router/main" + filePath.split(".")[1]);
allRoutes.push(routeModule.default);
});
console.log(useMenus);
// 递归函数 获取可点击部分的url,并进行筛选
function findRouteFun(useMenus: any[]) {
for (const menu of useMenus) {
if (menu.type === 1) {
findRouteFun(menu.children);
} else if (menu.type === 2) {
const route = allRoutes.find((item) => item.path === menu.url);
if (route) {
routes.push(route);
}
}
}
}
findRouteFun(useMenus);
console.log(routes);
return routes;
}
Vuex 使用
vuex 现在对 ts 的支持性比较差
在 store 文件夹下 设置不同功能的文件夹
types.ts 下 设置接口IRootState
createStore 的时候可以传入一个泛型
可以对 state 进行限制
index.ts 下
import { createStore } from "vuex";
import login from "./login/login";
import { IRootState } from "./types";
const store =
createStore <
IRootState >
{
state() {
return {
name: "coderwhy",
age: 18,
};
},
mutations: {},
getters: {},
actions: {},
modules: {
login,
},
};
export function setupStore() {
store.dispatch("login/loadLocalLogin");
}
export default store;
login 中
types.ts
export interface ILoginState {
token: string
userInfo: any
userMenus: any
}
login.ts
import { Module } from "vuex";
import {
accountLoginRequest,
requestUserInfoById,
requestUserMenusByRoleId,
} from "@/service/login/login";
import localCache from "@/utils/cache";
import router from "@/router";
import { IAccount } from "@/service/login/type";
import { ILoginState } from "./types";
import { IRootState } from "../types";
//模块类型 和根类型
const loginModule: Module<ILoginState, IRootState> = {
namespaced: true,
state() {
return {
token: "",
userInfo: {},
userMenus: [],
};
},
getters: {},
mutations: {
changeToken(state, token: string) {
state.token = token;
},
changeUserInfo(state, userInfo: any) {
state.userInfo = userInfo;
},
changeUserMenus(state, userMenus: any) {
state.userMenus = userMenus;
},
},
actions: {
async accountLoginAction({ commit }, payload: IAccount) {
// 1.实现登录逻辑
const loginResult = await accountLoginRequest(payload);
const { id, token } = loginResult.data;
commit("changeToken", token);
localCache.setCache("token", token);
// 2.请求用户信息
const userInfoResult = await requestUserInfoById(id);
const userInfo = userInfoResult.data;
commit("changeUserInfo", userInfo);
localCache.setCache("userInfo", userInfo);
// 3.请求用户权限菜单
const userMenusResult = await requestUserMenusByRoleId(userInfo.role.id);
const userMenus = userMenusResult.data;
commit("changeUserMenus", userMenus);
localCache.setCache("userMenus", userMenus);
// 4.跳到首页
router.push("/main");
},
loadLocalLogin({ commit }) {
const token = localCache.getCache("token");
if (token) {
commit("changeToken", token);
}
const userInfo = localCache.getCache("userInfo");
if (userInfo) {
commit("changeUserInfo", userInfo);
}
const userMenus = localCache.getCache("userMenus");
if (userMenus) {
commit("changeUserMenus", userMenus);
}
},
},
};
export default loginModule;
在 service/login 中
types.ts 中设置接口
export interface IAccount {
name: string;
password: string;
}
export interface ILoginResult {
id: number;
name: string;
token: string;
}
export interface IDataType<T = any> {
code: number;
data: T;
}
import hyRequest from '../index'
import { IAccount, IDataType, ILoginResult } from './type'
enum LoginAPI {
AccountLogin = '/login',
LoginUserInfo = '/users/', // 用法: /users/1
UserMenus = '/role/' // 用法: role/1/menu
}
export function accountLoginRequest(account: IAccount) {
return hyRequest.post<IDataType<ILoginResult>>({
url: LoginAPI.AccountLogin,
data: account
})
}
export function requestUserInfoById(id: number) {
return hyRequest.get<IDataType>({
url: LoginAPI.LoginUserInfo + id,
showLoading: false
})
}
export function requestUserMenusByRoleId(id: number) {
return hyRequest.get<IDataType>({
url: LoginAPI.UserMenus + id + '/menu',
showLoading: false
})
}
模块设置 system good login 等
模块划分
service 里面 system.ts
import hyRequest from "../../index";
import { IDataType } from "../../types";
export function getPageListData(url: string, queryInfo: any) {
return (
hyRequest.post <
IDataType >
{
url: url,
data: queryInfo,
}
);
}
export interface ISystemState {
userList: any[];
userCount: number;
}
import { Module } from "vuex";
import { IRootState } from "@/store/types";
import { ISystemState } from "./types";
import { getPageListData } from "@/service/main/system/system";
const systemModule: Module<ISystemState, IRootState> = {
namespaced: true,
state() {
return {
userList: [],
userCount: 0,
};
},
mutations: {
changeUserList(state, userList: any[]) {
state.userList = userList;
},
changeUserCount(state, userCount: number) {
state.userCount = userCount;
},
},
actions: {
async getPageListAction({ commit }, payload: any) {
console.log(payload.pageUrl);
console.log(payload.queryInfo);
// 1.对页面发送请求
const pageResult = await getPageListData(
payload.pageUrl,
payload.queryInfo
);
const { list, totalCount } = pageResult.data;
commit("changeUserList", list);
commit("changeUserCount", totalCount);
},
},
};
export default systemModule;
form 组件的二次封装
传入配置信息就可以获取一个新的组件
baseUI 下面
设置一个 types 文件
指定一个表单项目的接口 接口中设置 item 类型
index.ts 总出口文件
import MsiForm from "./src/MsiForm.vue";
export * from "./types";
export default MsiForm;
types.ts 文件
type InputType = "input" | "select" | "password" | "datapicker";
export interface IFormItemType {
type: InputType;
label: string;
rules?: any[];
placeholder?: string;
options?: any[];
otherOptions?: any;
}
interface colLayoutType {
xs?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
}
export interface IForm {
formItem: IFormItemType[];
labelWidth?: string;
itemStyle?: any;
colLayout?: colLayoutType;
}
hyForm.vue 组件
template 进行 v-if 选择那种类型进行展示
<template>
<div class="msi-form">
<el-form :label-width="labelWidth">
<el-row>
<template v-for="item in formItem" :key="item.label">
<el-col :="colLayout">
<el-form-item :label="item.label" :style="itemStyle">
<template v-if="item.type === 'input'">
<el-input
:placeholder="item.placeholder"
v-bind="item.otherOptions"
/></template>
<template v-else-if="item.type === 'password'">
<el-input
show-password
:placeholder="item.placeholder"
v-bind="item.otherOptions"
/></template>
<template v-else-if="item.type === 'select'">
<el-select
v-bind="item.otherOptions"
:placeholder="
item.placeholder ? item.placeholder : '请选择..'
"
>
<el-option
v-for="option in item.options"
:key="option.value"
:value="option.value"
>{{ option.title }}</el-option
>
</el-select>
</template>
<template v-else-if="item.type === 'datapicker'">
<el-date-picker v-bind="item.otherOptions"
/></template>
</el-form-item>
</el-col>
</template>
</el-row>
</el-form>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import type { IFormItemType } from "../types";
export default defineComponent({
name: "index",
props: {
formItem: {
type: Array as PropType<IFormItemType[]>,
default: () => []
},
labelWidth: {
type: String,
default: "80px"
},
itemStyle: {
type: Object,
default: () => ({ padding: "10px 20px" })
},
colLayout: {
type: Object,
default: () => ({
xs: 24,
sm: 24,
md: 12,
lg: 8,
xl: 6
})
}
},
setup() {
return {};
}
});
</script>
<style lang="less" scoped>
.msi-form {
padding-top: 20px;
width: 100%;
background-color: #fff;
border-radius: 10px;
}
</style>
使用配置方法
导入 baseui 里面的 form 组件
再导入相关配置文件 给 form 组件绑定 配置信息
<template>
<div class="user">
<hy-form v-bind="searchFormConfig" />
<div class="content"></div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import HyForm from "@/base-ui/form";
import { searchFormConfig } from "./config/search.config";
export default defineComponent({
name: "user",
components: {
HyForm,
},
setup() {
return {
searchFormConfig,
};
},
});
</script>
<style scoped></style>
配置信息
设置 type label width height 等组件信息
import { IForm } from "@/base-ui/form";
export const searchFormConfig: IForm = {
labelWidth: "120px",
itemLayout: {
padding: "10px 40px",
},
colLayout: {
span: 8,
},
formItems: [
{
type: "input",
label: "id",
placeholder: "请输入id",
},
{
type: "input",
label: "用户名",
placeholder: "请输入用户名",
},
{
type: "password",
label: "密码",
placeholder: "请输入密码",
},
{
type: "select",
label: "喜欢的运动",
placeholder: "请选择喜欢的运动",
options: [
{ title: "篮球", value: "basketball" },
{ title: "足球", value: "football" },
],
},
{
type: "datepicker",
label: "创建时间",
otherOptions: {
startPlaceholder: "开始时间",
endPlaceholder: "结束时间",
type: "daterange",
},
},
],
};
针对组件封装插槽问题
分为上下 2 个部分 上部是搜索部分 baseui/form 和 components/page-search
form 下使用
<slot name="header"> </slot>
<el-form></el-form>
<slot name="footer"> </slot>
page-search 下使用
<form>
<template #header> </template>
<template #footer> </template>
</form>
baseui/table 和 components/page-content table 下使用
<slot name="header">
<div class="title">{{ title }}</div>
<div class="handler">
<slot name="headerHandler"></slot>
</div>
</slot>
<el-table>
<el-table-column>
<template #default="scope">
<slot :name="propItem.slotName" :row="scope.row">
{{ scope.row[propItem.prop] }}
</slot>
</template>
</el-table-column>
</el-table>
<slot name="footer"> <slot>
page-content 下使用 table 包裹
<table>
<!-- 1.header中的插槽 -->
<template #headerHandler> </template>
<template #status="scope"> {scope.row} </template>
<template #createAt="scope"> </template>
<template #updateAt="scope"> </template>
<template #handler="scope"> </template>
<!-- 在page-content中动态插入剩余的插槽 -->
<template
v-for="item in otherPropSlots"
:key="item.prop"
#[item.slotName]="scope"
>
</table>
在 role.vue 下 search 组件 导入相关配置
<page-search :searchFormConfig="searchFormConfig"></page-search>
content 组件 导入相关配置 config 绑定相关方法
<page-content
:contentTableConfig="contentTableConfig"
pageName="role"
@newBtnClick="handleNewData"
@editBtnClick="handleEditData">
</page-content>
<PageModal
ref="pageModalRef"
:defaultInfo="defaultInfo"
:modalConfig="modalConfig"
:otherInfo="otherInfo"
pageName="role"
>
modal 组件
<template>
<div class="page-modal">
<el-dialog
title="新建"
v-model="dialogVisible"
width="30%"
center
destroy-on-close
>
<SEForm v-model="formData" v-bind="modalConfig"> </SEForm>
<slot> </slot>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirmClick">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
双向绑定问题
<el-input
:placeholder="item.placeholder"
v-bind="item.otherOptions"
:show-password="item.type === 'password'"
v-model="formData[`${item.field}`]"
/>
emits: ['update:modelValue'],
setup(props, { emit }) { props里面接受modelValue
const formData = ref({ ...props.modelValue })
watch(
formData,
(newValue) => {
console.log(newValue)
emit('update:modelValue', newValue)
},
{
deep: true
}
)
pagesearch
面包屑导航二次封装
baseui 里面
<template>
<div class="nav-breadcrumb">
<el-breadcrumb separator="/">
<template v-for="item in breadcrumbs" :key="item.name">
<el-breadcrumb-item :to="{ path: item.path }">{{
item.name
}}</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { IBreadcrumb } from '../types'
export default defineComponent({
props: {
breadcrumbs: {
type: Array as PropType<IBreadcrumb[]>,
default: () => []
}
},
setup() {
return {}
}
})
</script>
<style scoped></style>
设置接口类型
export interface IBreadcrumb {
name: string
path?: string
}
转换函数
export function pathMapBreadcrumbs(userMenus: any[], currentPath: string) {
const breadcrumbs: IBreadcrumb[] = [];
pathMapToMenu(userMenus, currentPath, breadcrumbs);
return breadcrumbs;
}
// /main/system/role -> type === 2 对应menu
export function pathMapToMenu(
userMenus: any[],
currentPath: string,
breadcrumbs?: IBreadcrumb[]
): any {
for (const menu of userMenus) {
if (menu.type === 1) {
const findMenu = pathMapToMenu(menu.children ?? [], currentPath);
if (findMenu) {
breadcrumbs?.push({ name: menu.name });
breadcrumbs?.push({ name: findMenu.name });
return findMenu;
}
} else if (menu.type === 2 && menu.url === currentPath) {
return menu;
}
}
}
双向数据流问题
父给子传一个引用 子组件不能直接更改 而是要 emit 一个函数 父组件接受 然后进行更改
table 组件二次封装
proplist datalist
proplist 里面写组件的配置信息 可以设置一个 slotname
table 里面 table-column 里面传入一个插槽 拿到每一行的数据 每一行的 user 数据 接受一个 scope
scope.row[propItem.prop] 相当于从一行数据里面取出 name cellphone 等数据
但是不能写死 要使用 slot 动态给一个名字 可以对某一列进行更改
:row="scope.row" 传到上一层 然后再在 pagecontent 里面用 scope.row 来拿取
<template v-for="propItem in propList" :key="propItem.prop">
<el-table-column v-bind="propItem" align="center">
table-column里面传入一个插槽
<template #default="scope">
<slot :name="propItem.slotName" :row="scope.row">
{{ scope.row[propItem.prop] }}
</slot>
</template>
</el-table-column>
</template>
pagecontent
<template>
<div class="page-content">
<hy-table :listData="dataList" v-bind="contentTableConfig">
<!-- 1.header中的插槽 -->
<template #headerHandler>
<el-button type="primary" size="medium">新建用户</el-button>
</template>
<!-- 2.列中的插槽 -->
<template #status="scope">
这里可以指定那些需要修改
<el-button
plain
size="mini"
:type="scope.row.enable ? 'success' : 'danger'"
>
{{ scope.row.enable ? "启用" : "禁用" }}
</el-button>
</template>
<template #createAt="scope">
<span>{{ $filters.formatTime(scope.row.createAt) }}</span>
</template>
<template #updateAt="scope">
<span>{{ $filters.formatTime(scope.row.updateAt) }}</span>
</template>
<template #handler>
<div class="handle-btns">
<el-button icon="el-icon-edit" size="mini" type="text"
>编辑</el-button
>
<el-button icon="el-icon-delete" size="mini" type="text"
>删除</el-button
>
</div>
</template>
</hy-table>
</div>
</template>
index.ts 统一出口文件
import MsiTabel from "./src/MsiTabel.vue";
export * from "./types/types";
export default MsiTabel;
types.ts
export interface ITabeleTitle {
prop: string;
label: string;
minWidth?: string;
slotName?: string;
}
hytable.vue
<template>
<div class="msi-tabel">
<el-table :data="tableData" border style="width: 100%">
<template v-for="title in propList" :key="title.prop">
<el-table-column
:label="title.label"
align="center"
:min-width="title.minWidth"
>
<template #default="scope">
<slot :name="title.slotName" :row="scope.row">{{
scope.row[title.prop]
}}
</slot>
</template>
</el-table-column>
</template>
</el-table>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { ITabeleTitle } from "../types/types";
export default defineComponent({
name: "MsiTabel",
props: {
tableData: {
type: Array,
default: () => []
},
propList: {
type: Array as PropType<ITabeleTitle[]>,
default: () => []
}
},
setup() {
return {};
}
});
</script>
<style lang="less" scoped></style>
然后再 user.vue 里面 只用使用 pagecontent pagesearch 然后再绑定相关的 config 文件
pagecontent 里面导入 table 指定 pagename pagesearch 里面导入 form
<template>
<div class="user">
<page-search :searchFormConfig="searchFormConfig" />
<page-content
:contentTableConfig="contentTableConfig"
pageName="users"
></page-content>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import PageSearch from "@/components/page-search";
import PageContent from "@/components/page-content";
import { searchFormConfig } from "./config/search.config";
import { contentTableConfig } from "./config/content.config";
export default defineComponent({
name: "users",
components: {
PageSearch,
PageContent,
},
setup() {
return {
searchFormConfig,
contentTableConfig,
};
},
});
</script>
<style scoped></style>
ts 封装 axios
- 无处不在的代码提示;
- 灵活的拦截器;
- 可以创建多个实例,灵活根据项目进行调整;
- 每个实例,或者说每个接口都可以灵活配置请求头、超时时间等;
- 取消请求(可以根据 url 取消单个请求也可以取消全部请求)。
基本使用
import axios from "axios";
// axios的实例对象
// 1.模拟get请求
axios.get("http://123.207.32.32:8000/home/multidata").then((res) => {
console.log(res.data);
});
// 2.get请求,并且传入参数
// axios
// .get('http://httpbin.org/get', {
// params: {
// name: 'coderwhy',
// age: 18
// }
// })
// .then((res) => {
// console.log(res.data)
// })
// // 3.post请求
// axios
// .post('http://httpbin.org/post', {
// data: {
// name: 'why',
// age: 18
// }
// })
// .then((res) => {
// console.log(res.data)
// })
// 额外补充的Promise中类型的使用
// Promise本身是可以有类型
// new Promise<string>((resolve) => {
// resolve('abc')
// }).then((res) => {
// console.log(res.length)
// })
// 4.axios的配置选项
// 4.1. 全局的配置
axios.defaults.baseURL = "http://httpbin.org";
axios.defaults.timeout = 10000;
// axios.defaults.headers = {}
// 4.2. 每一个请求单独的配置
// axios
// .get('/get', {
// params: {
// name: 'coderwhy',
// age: 18
// },
// timeout: 5000,
// headers: {}
// })
// .then((res) => {
// console.log(res.data)
// })
// 3.post请求
// axios
// .post('/post', {
// data: {
// name: 'why',
// age: 18
// }
// })
// .then((res) => {
// console.log(res.data)
// })
// 5.axios.all -> 多个请求, 一起返回
axios
.all([
axios.get("/get", { params: { name: "why", age: 18 } }),
axios.post("/post", { data: { name: "why", age: 18 } }),
])
.then((res) => {
console.log(res[0].data);
console.log(res[1].data);
});
// 6.axios的拦截器
// fn1: 请求发送成功会执行的函数
// fn2: 请求发送失败会执行的函数
axios.interceptors.request.use(
(config) => {
// 想做的一些操作
// 1.给请求添加token
// 2.isLoading动画
console.log("请求成功的拦截");
return config;
},
(err) => {
console.log("请求发送错误");
return err;
}
);
// fn1: 数据响应成功(服务器正常的返回了数据 20x)
axios.interceptors.response.use(
(res) => {
console.log("响应成功的拦截");
return res;
},
(err) => {
console.log("服务器响应失败");
return err;
}
);
基础封装
首先我们实现一个最基本的版本,实例代码如下:
//index.ts
import axios from "axios";
import type { AxiosInstance, AxiosRequestConfig } from "axios";
class Request {
//axios实例
instance: AxiosInstance;
constructor(config: AxiosRequestConfig) {
this.instance = axios.create(config);
}
request(config: AxiosRequestConfig) {
this.instance.request(config);
}
}
export default Request;
其封装为一个类,而不是一个函数的原因是因为类可以创建多个实例,适用范围更广,封装性更强一些
创建一个对象 然后可以指定 baseURl Timeout 拦截器{}对象方法·
封装一个接口 指定拦截器 request 拦截器 类型是 axiosrequestconfig 类型
catch 拦截器 响应拦截 响应错误拦截
继承类型 axiosrequestconfig 然后可以设置不同的拦截器
axios 拦截器原理
。Axios 是基于 Promise 机制实现的异步的链式请求框架。体积小,源码易懂。非常适合做基础的请求库。
Axios 结构
代码结构
axios.js
:入口文件,将Axios
实例的request
函数绑定为入口函数,axios.create
其实返回的是一个function
,就是Axios
实例的Axios.prototype.request
lib/Axios.js :真正的 Axios 的实例,用于拼接拦截器的调用链,关键代码如下:
// Hook up interceptors middleware
var chain = [dispatchRequest, undefined];
var promise = Promise.resolve(config);
this.interceptors.request.forEach(function unshiftRequestInterceptors(
interceptor
) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
this.interceptors.response.forEach(function pushResponseInterceptors(
interceptor
) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;lib/InterceptorManager.js
:拦截器管理,是一个对[]
的封装lib/dispatchRequest.js
:发送请求的Promise
,完成发送请求的逻辑。注意看Axios.js
中的var chain = [dispatchRequest, undefined];
adapter/*
:适配器,这里的代码保证了 Axios 在 ssr 模式下和浏览器环境中区分环境实现请求返送的逻辑。里面存放了两个定义好的适配器,可以参照README.md
中的描述自定义适配器
拦截器模型
Axios 拦截器示意图.png
- request 和 response 的拦截器都可以有多对,其中每一个点都会挂在一个
then()
的调用上,promise.then(chain.shift(), chain.shift());
- request 和 response 的拦截器都可以有多对,其中每一个点都会挂在一个
使用场景:应对 OAuth 中refresh_token
换access_token
时其他请求需等待的问题
根据场景来看,我们需要有一下几个能力
Request
拦截器中任意的请求(比如请求 A)进入之后,如果主动检测到了access_token
的超时,那么停止当前请求A,开启refresh_token
的请求,当成功之后再执行A请求- 当请求已发送,服务端识别到了 token 失效,
Response
拦截器中的处理跟Request
拦截器要做的事一样 - 当有进行中的
refresh_token
请求时,此请求需要等待这个进行中的refresh_token
的请求成功之后再进行发送
那我们一个一个来处理
当请求进入拦截器,主动发现需要
refresh_token
时(比如access_token
有效期临近)需要将请求放置在refresh_token
成功之后- 处理方式可以采用在
then()
调用拦截器的方法时返回一个Promise
,然后在Promise
中等待refresh_token
的请求成功之后再进行当前进入的请求的发送
// axios 的 request拦截器
axios.interceptors.request.use((config) => {
return new Promise((resolve) => {
// 模拟等待refresh_token
setTimeout(
function (config_param) {
resolve(config_param);
},
2000,
config
);
});
});上面的代码只是一个简单的示意,实际处理中要注意以下几点,
- 刷新 token 之后
config_param
要处理新 Token 的拼装; - 请求拦截器中要能识别出是否是
refresh_token
的请求; - 能识别出是否正在进行
refresh_token
,并能正确处理其他进入的请求,这个后面会讲到
- 刷新 token 之后
处理之后调用链会变成这样
请求拦截器中加入 Promise
- 处理方式可以采用在
当请求已发送,服务端识别到了 Token 失效时(这个情况比较多,服务器时间与本地有间隙;Token 不支持多点登陆等等),需要先
refresh_token
,然后重发请求- 可以采用与
Request
拦截器相似的处理,在拦截器中同样开启refresh_token
,成功之后重新创建已经失败的请求,执行完请求之后将重新创建的请求获取到的返回值 resolve 给 response 的返回值
- 可以采用与
let res = response.data;
switch (res.code) {
case RespStatus.UNAUTHORIZED.code: {
let respConfig = response.config;
if (isRefreshTokenReq(respConfig.url)) {
//刷新Token的请求如果出现401直接退出登录
showLoginOut();
} else {
logDebug(
"请求的返回值出现401,由请求" +
config.url +
"的返回值触发,开始进行refresh_token!"
);
let auth = storage.state.user.auth;
try {
res = doRefreshToken(
auth.refresh_token,
auth.wmq_d_current_username,
respConfig
)
.then((config) => {
return wmqhttp(
attachAuthInfoToConfig(storage.state.user.auth, config)
);
})
.then((value) => {
return Promise.resolve(value);
});
} catch (e) {
console.log("无法等待刷新Token!", e);
showLoginOut();
}
}
break;
}
default:
logDebug("Axios response default data:", res);
break;
}
return res;
处理之后调用链会变成这样
响应拦截器中加入 Promise 和二次请求
- 对于在
refresh_token
时其他请求的进入需要安排这个请求动作,让请求发生在refresh_token
之后进行 - 解决思路如下,在全局的状态中记录是否正在刷新请求,并且保存refresh_token
的Promise
。当遇到请求之后新创建一个Promise
交给拦截器,在新创建的Promise
中用then()
等待 refresh_token。
new Promise((resolve) => {
pendingPromise.then(() => {
logDebug("刷新Token成功,开始处理之前等待的请求", config.url);
resolve(attachAuthInfoToConfig(storage.state.user.auth, config));
});
});
Axios 封装
别人传进来什么样的拦截 就可以应用什么样的拦截
不仅可以传入基本属性还可以传入拦截器
针对每个拦截器可以做自己的东西 比如输出不同的东西 进行不同的代码提示
在开发的时候控制台输出很多,自定义拦截器之后 可以对不同接口进行不同代码提示
针对不同的方法可以设置不同的拦截器 request get post delete patch 等等
请求很多 请求之前做的东西不一样的 每个请求不一样 所有请求都有的处理 所有都共有 没有的再加
比如返回的时候就拿到 res.data 自己可以预先处理得到的数据 比如可以设置是否显示加载 也可以拿到其他的比如状态码
定义一个拦截器接口 设置请求和响应的拦截器和错误拦截
再定义一个配置接口继承至 AxiosRequestConfig 可以设置拦截器和是否显示加载 showLoading
在 type.ts 文件里面
import type { AxiosRequestConfig, AxiosResponse } from "axios";
//interface指定一个类型传入 也可以设置默认类型
export interface HYRequestInterceptors<T = AxiosResponse> {
requestInterceptor?: (config: AxiosRequestConfig) => AxiosRequestConfig;
requestInterceptorCatch?: (error: any) => any;
responseInterceptor?: (res: T) => T;
responseInterceptorCatch?: (error: any) => any;
}
export interface HYRequestConfig<T = AxiosResponse> extends AxiosRequestConfig {
interceptors?: HYRequestInterceptors<T>;
showLoading?: boolean;
}
好处就是我可以给每个拦截器里面写自己的处理
得到的结果可能不符合我们的使用要求 可以直接解析数据 进行数据的初步处理 获取不同的数据部分
可以给请求携带 token
可以在开发的时候直接自定义显示或者打印错误信息 elmessage elloading
每个请求可能返回不同的错误码 可以
可以直接控制发起请求时是否显示 loading 效果
import axios from 'axios'
import type { AxiosInstance } from 'axios'
import type { HYRequestInterceptors, HYRequestConfig } from './type'
import { ElLoading } from 'element-plus'
import { ILoadingInstance } from 'element-plus/lib/el-loading/src/loading.type'
const DEAFULT_LOADING = true
class HYRequest {
instance: AxiosInstance
interceptors?: HYRequestInterceptors
showLoading: boolean
loading?: ILoadingInstance
constructor(config: HYRequestConfig) {
// 创建axios实例
this.instance = axios.create(config)
// 保存基本信息
this.showLoading = config.showLoading ?? DEAFULT_LOADING
this.interceptors = config.interceptors
// 使用拦截器
// 1.从config中取出的拦截器是对应的实例的拦截器
this.instance.interceptors.request.use(
this.interceptors?.requestInterceptor,
this.interceptors?.requestInterceptorCatch
)
this.instance.interceptors.response.use(
this.interceptors?.responseInterceptor,
this.interceptors?.responseInterceptorCatch
)
// 2.添加所有的实例都有的拦截器
this.instance.interceptors.request.use(
(config) => {
console.log('所有的实例都有的拦截器: 请求成功拦截')
if (this.showLoading) {
this.loading = ElLoading.service({
lock: true,
text: '正在请求数据....',
background: 'rgba(0, 0, 0, 0.5)'
})
}
return config
},
(err) => {
console.log('所有的实例都有的拦截器: 请求失败拦截')
return err
}
)
this.instance.interceptors.response.use(
(res) => {
console.log('所有的实例都有的拦截器: 响应成功拦截')
// 将loading移除
this.loading?.close()
const data = res.data
if (data.returnCode === '-1001') {
console.log('请求失败~, 错误信息')
} else {
return data
}
},
(err) => {
console.log('所有的实例都有的拦截器: 响应失败拦截')
// 将loading移除
this.loading?.close()
// 例子: 判断不同的HttpErrorCode显示不同的错误信息
if (err.response.status === 404) {
console.log('404的错误~')
}
return err
}
)
}
request<T>(config: HYRequestConfig<T>): Promise<T> {
return new Promise((resolve, reject) => {
// 1.单个请求对请求config的处理
if (config.interceptors?.requestInterceptor) {
config = config.interceptors.requestInterceptor(config)
}
// 2.判断是否需要显示loading
if (config.showLoading === false) {
this.showLoading = config.showLoading
}
this.instance //指定T 需要外部传入 再到intercepter 再到res
.request<any, T>(config)
.then((res) => {
// 1.单个请求对数据的处理
if (config.interceptors?.responseInterceptor) {
res = config.interceptors.responseInterceptor(res)
}
// 2.将showLoading设置true, 这样不会影响下一个请求
this.showLoading = DEAFULT_LOADING
// 3.将结果resolve返回出去
resolve(res)
})
.catch((err) => {
// 将showLoading设置true, 这样不会影响下一个请求
this.showLoading = DEAFULT_LOADING
reject(err)
return err
})
})
}
get<T>(config: HYRequestConfig<T>): Promise<T> {
return this.request<T>({ ...config, method: 'GET' })
}
post<T>(config: HYRequestConfig<T>): Promise<T> {
return this.request<T>({ ...config, method: 'POST' })
}
delete<T>(config: HYRequestConfig<T>): Promise<T> {
return this.request<T>({ ...config, method: 'DELETE' })
}
patch<T>(config: HYRequestConfig<T>): Promise<T> {
return this.request<T>({ ...config, method: 'PATCH' })
}
}
export default HYRequest
index.ts 做统一出口 新建一个对象 然后设置 url time interceptors
// service统一出口
import HYRequest from "./request";
import { BASE_URL, TIME_OUT } from "./request/config";
//在新建的时候传入配置 相当于config
const hyRequest = new HYRequest({
baseURL: BASE_URL,
timeout: TIME_OUT,
interceptors: {
requestInterceptor: (config) => {
// 携带token的拦截
const token = "";
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log("请求成功的拦截");
return config;
},
requestInterceptorCatch: (err) => {
console.log("请求失败的拦截");
return err;
},
responseInterceptor: (res) => {
console.log("响应成功的拦截");
return res;
},
responseInterceptorCatch: (err) => {
console.log("响应失败的拦截");
return err;
},
},
});
export default hyRequest;
管理权限
动态路由组件权限管理
表述:一开始想到 如果自己开发的时候就一次配置好所有的路由映射 展示的时候 对应的页面不展示 但是会出现问题 虽然没显示 但是可以在浏览器地址栏直接进行输入 然后进行跳转 这是个安全问题
前端这里,为不同的角色设置好不同的映射关系(映射数组),请求数据用户的角色是什么,再把该角色的数组加入到 main 对应的 children 内。 引发问题: 若后端又有新的角色出现,那么前端这边也要跟着进行修改,重新进行部署
在前端创建好所有路径对应的组件,但是根据后端返回的菜单数据,进行动态的生成路由。后端在返回对应的路径信息时,也要把该路径映射组件的路径一起返回。
在前端创建好所有路径对应的组件,但是根据后端返回的菜单数据,进行动态的生成路由。后端在返回对应的路径信息时,在前端就设置好路径和组件之间的映射关系,在前端根据传过来的路径进行查找,查找到就找到了该路径和组件的映射关系。
菜单到路由的映射
菜单-->url->路由->path->components
角色管理->/main/role->path->components 这个 componets 是一个数组 routes 然后我再动态添加给 main 的 children
后端设计的时候对于 componets 也有多种方法
一种是菜单中就有加载组件的名称 问题 前端开发的时候 路径和名称必须一致 一旦不一样 就会出错
另外一种就是 菜单中只有 url 而我们可以在前端代码中设置 path->component 的映射关系
根据服务器的 url 去动态加载 这个映射关系 有哪些映射关系 再构成数组 routes 再加到 main 里面
在 view 下面创建组件 mian/system/各个组件文件夹
在 router 文件夹下 设置相似的路径 然后写一下比如 user/user.ts
导出 name 和 path 就是 user.vue 的目录 比如 /main/system/user
component 进行懒加载的形式 import 组件
menu-->url->path->components
url 找到相应的 path path 对相应的 vue 组件
有哪些权限 注册哪些组件进行跳转
代码实现
在 vuex 中
构建一个方法 进行路由添加
import { RouteRecordRaw } from "vue-router";
export function mapMenusToRoutes(userMenus: any[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = [];
// 1.先去加载默认所有的routes
const allRoutes: RouteRecordRaw[] = [];
const routeFiles = require.context("../router/main", true, /\.ts/);
routeFiles.keys().forEach((key) => {
const route = require("../router/main" + key.split(".")[1]);
allRoutes.push(route.default);
});
// 2.根据菜单获取需要添加的routes
// userMenus:
// type === 1 -> children -> type === 1
// type === 2 -> url -> route
const _recurseGetRoute = (menus: any[]) => {
for (const menu of menus) {
if (menu.type === 2) {
const route = allRoutes.find((route) => route.path === menu.url);
if (route) routes.push(route);
} else {
_recurseGetRoute(menu.children);
}
}
};
_recurseGetRoute(userMenus);
return routes;
}
store/login.ts 里面
mutations
changeUserMenus(state, userMenus: any) {
state.userMenus = userMenus
console.log('注册动态路由')
// userMenus => routes
const routes = mapMenusToRoutes(userMenus)
// 将routes => router.main.children
routes.forEach((route) => {
router.addRoute('main', route)
})
}
actions
const userMenusResult = await requestUserMenusByRoleId(userInfo.role.id);
const userMenus = userMenusResult.data;
commit("changeUserMenus", userMenus);
localCache.setCache("userMenus", userMenus);
路径
选择方法多种
方法一:不管什么角色登陆,在开发的时候,在前端都全部配置好路由的映射关系,只是在展示的时候,对应路由的跳转不展现出来。 引发的问题: 虽然页面没有展示,但是可以通过浏览器的地址栏进行 “套”,就会显示对应映射的组件,但组件上可能是没有什么东西的,虽然这样但也不好,会不安全。
方法二:在前端这里,为不同的角色设置好不同的映射关系(映射数组),请求数据用户的角色是什么,再把该角色的数组加入到 main 对应的 children 内。 引发问题: 若后端又有新的角色出现,那么前端这边也要跟着进行修改,重新进行部署。
方法三:在前端创建好所有路径对应的组件,但是根据后端返回的菜单数据,进行动态的生成路由。后端在返回对应的路径信息时,也要把该路径映射组件的路径一起返回。但是也要后端要增加一个字段,来放置组件的位置。
方法四:在前端创建好所有路径对应的组件,但是根据后端返回的菜单数据,进行动态的生成路由。后端在返回对应的路径信息时,在前端就设置好路径和组件之间的映射关系,在前端根据传过来的路径进行查找,查找到就找到了该路径和组件的映射关系。
优化问题
权限受控解决方案之分级分控权限管理
开篇
那么从这一章开始我们就来解决我们的权限控制问题。
本章以权限控制为主,整个章节会分成三部分来去讲解:
- 权限理论:明确什么是
RBAC
权限控制体现 - 辅助业务:完善 用户、角色、权限 三个页面功能
- 核心功能:落地实现
RBAC
权限控制系统
列举出来这三部分的目的是为了让大家能够对本章的内容有个清楚的认知,那么接下来我们就先来看第一部分 权限理论
权限理论:RBAC 权限控制体系
权限控制在开发中一直是一个比较复杂的问题,甚至有很多同学对什么是权限控制还不是很了解。所以我们需要先来统一一下认知,明确项目中的权限控制系统。
在我们当前的项目中,我们可以通过:
- 员工管理为用户指定角色
- 通过角色列表为角色指定权限
- 通过权限列表查看当前项目所有权限
那么换句话而言,以上三条就制定了一个用户由:用户 -> 角色 -> 权限 的一个分配关系。
当我们通过角色为某一个用户指定到不同的权限之后,那么该用户就会在 项目中体会到不同权限的功能
那么这样的一套关系就是我们的 RBAC 权限控制体系,也就是 基于 角色的权限 控制 用户的访问
通过以下图片可以很好的说明这种权限控制体系的含义:
辅助业务:角色列表展示
那么明确好了 RBAC
的概念之后,接下来我们就可以来去实现我们的辅助业务了,所谓辅助业务具体指的就是:
- 员工管理(用户列表)
- 为用户分配角色
- 角色列表
- 角色列表展示
- 为角色分配权限
- 权限列表
- 权限列表展示
那么这一小节我们就先来实现其中的 角色列表展示
创建
api/role
接口文件:import request from "@/utils/request";
/**
* 获取所有角色
*/
export const roleList = () => {
return request({
url: "/role/list",
});
};在
views/role-list
中获取数据import { roleList } from "@/api/role";
import { watchSwitchLang } from "@/utils/i18n";
import { ref } from "vue";
const allRoles = ref([]);
const getRoleList = async () => {
allRoles.value = await roleList();
};
getRoleList();
watchSwitchLang(getRoleList);通过 el-table 进行数据展示
<template>
<div class="">
<el-card>
<el-table :data="allRoles" border style="width: 100%">
<el-table-column
:label="$t('msg.role.index')"
type="index"
width="120"
>
</el-table-column>
<el-table-column :label="$t('msg.role.name')" prop="title">
</el-table-column>
<el-table-column :label="$t('msg.role.desc')" prop="describe">
</el-table-column>
<el-table-column
:label="$t('msg.role.action')"
prop="action"
width="260"
>
<el-button type="primary" size="mini">
{{ $t("msg.role.assignPermissions") }}
</el-button>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
辅助业务:权限列表展示
创建
api/permission
文件import request from "@/utils/request";
/**
* 获取所有权限
*/
export const permissionList = () => {
return request({
url: "/permission/list",
});
};在
views/permission-list
获取数据<script setup>
import { permissionList } from "@/api/permission";
import { watchSwitchLang } from "@/utils/i18n";
import { ref } from "vue";
/**
* 权限分级:
* 1. 一级权限为页面权限
* permissionMark 对应 路由名称
* 2. 二级权限为功能权限
* permissionMark 对应 功能权限表
*/
// 所有权限
const allPermission = ref([]);
const getPermissionList = async () => {
allPermission.value = await permissionList();
};
getPermissionList();
watchSwitchLang(getPermissionList);
</script>通过 el-table 进行数据展示
<template>
<div class="">
<el-card>
<el-table
:data="allPermission"
style="width: 100%; margin-bottom: 20px"
row-key="id"
border
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column
prop="permissionName"
:label="$t('msg.permission.name')"
width="180"
>
</el-table-column>
<el-table-column
prop="permissionMark"
:label="$t('msg.permission.mark')"
width="180"
>
</el-table-column>
<el-table-column
prop="permissionDesc"
:label="$t('msg.permission.desc')"
>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
辅助业务:为用户分配角色
创建为用户分配角色弹出层
views/user-manage/components/roles
<template>
<el-dialog
:title="$t('msg.excel.roleDialogTitle')"
:model-value="modelValue"
@close="closed"
>
内容
<template #footer>
<span class="dialog-footer">
<el-button @click="closed">{{
$t("msg.universal.cancel")
}}</el-button>
<el-button type="primary" @click="onConfirm">{{
$t("msg.universal.confirm")
}}</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
defineProps({
modelValue: {
type: Boolean,
required: true,
},
});
const emits = defineEmits(["update:modelValue"]);
/**
确定按钮点击事件
*/
const onConfirm = async () => {
closed();
};
/**
* 关闭
*/
const closed = () => {
emits("update:modelValue", false);
};
</script>
<style lang="scss" scoped></style>在
user-manage
中点击查看,展示弹出层<roles-dialog v-model="roleDialogVisible"></roles-dialog>
import RolesDialog from './components/roles.vue' /** * 查看角色的点击事件 */
const roleDialogVisible = ref(false) const onShowRoleClick = row => {
roleDialogVisible.value = true }在弹出层中我们需要利用 el-checkbox 进行数据展示,此时数据分为两种:
- 所有角色(已存在)
- 用户当前角色
所以我们需要先获取对应数据
在
api/user-manage
中定义获取用户当前角色接口/**
* 获取指定用户角色
*/
export const userRoles = (id) => {
return request({
url: `/user-manage/role/${id}`,
});
};在
roles
组件中获取所有角色数据import { defineProps, defineEmits, ref } from 'vue'
import { roleList } from '@/api/role'
import { watchSwitchLang } from '@/utils/i18n'
...
// 所有角色
const allRoleList = ref([])
// 获取所有角色数据的方法
const getListData = async () => {
allRoleList.value = await roleList()
}
getListData()
watchSwitchLang(getListData)
// 当前用户角色
const userRoleTitleList = ref([])利用 el-checkbox 渲染所有角色
<el-checkbox-group v-model="userRoleTitleList">
<el-checkbox
v-for="item in allRoleList"
:key="item.id"
:label="item.title"
></el-checkbox>
</el-checkbox-group>接下来渲染选中项,即:用户当前角色
调用
userRoles
接口需要 当前用户 ID,所以我们需要定义对应的props
const props = defineProps({ ... userId: { type: String, required: true } })
接下来我们可以根据
userId
获取数据,但是这里大家要注意:因为该userId
需要在user-manage
用户点击之后获取当前点击行的id
。所以在roles
组件的初始状态下,获取到的userId
为null
。 因此我们想要根据userId
获取用户当前角色数据,我们需要watch userId
在userId
有值的前提下,获取数据// 当前用户角色
const userRoleTitleList = ref([]);
// 获取当前用户角色
const getUserRoles = async () => {
const res = await userRoles(props.userId);
userRoleTitleList.value = res.role.map((item) => item.title);
};
watch(
() => props.userId,
(val) => {
if (val) getUserRoles();
}
);在
user-manage
中传递数据<roles-dialog
v-model="roleDialogVisible"
:userId="selectUserId"
></roles-dialog>
const selectUserId = ref('') const onShowRoleClick = row => {
selectUserId.value = row._id }在
dialog
关闭时重置selectUserId
// 保证每次打开重新获取用户角色数据
watch(roleDialogVisible, (val) => {
if (!val) selectUserId.value = "";
});在
api/user-manage
中定义分配角色接口/**
* 分用户分配角色
*/
export const updateRole = (id, roles) => {
return request({
url: `/user-manage/update-role/${id}`,
method: "POST",
data: {
roles,
},
});
};点击确定调用接口
/**
确定按钮点击事件
*/
const i18n = useI18n();
const onConfirm = async () => {
// 处理数据结构
const roles = userRoleTitleList.value.map((title) => {
return allRoleList.value.find((role) => role.title === title);
});
await updateRole(props.userId, roles);
ElMessage.success(i18n.t("msg.role.updateRoleSuccess"));
closed();
};修改成功后,发送事件
const emits = defineEmits(['update:modelValue', 'updateRole'])
const onConfirm = async () => {
...
// 角色更新成功
emits('updateRole')
}在
user-manage
中监听角色更新成功事件,重新获取数据<roles-dialog
v-model="roleDialogVisible"
:userId="selectUserId"
@updateRole="getListData"
></roles-dialog>
辅助业务:为角色指定权限
为角色指定权限通过 弹出层中的 树形控件 处理,整体的流程与上一小节相差无几。
创建 为角色指定权限弹出层
<template>
<el-dialog
:title="$t('msg.excel.roleDialogTitle')"
:model-value="modelValue"
@close="closed"
>
内容
<template #footer>
<span class="dialog-footer">
<el-button @click="closed">{{
$t("msg.universal.cancel")
}}</el-button>
<el-button type="primary" @click="onConfirm">{{
$t("msg.universal.confirm")
}}</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
defineProps({
modelValue: {
type: Boolean,
required: true,
},
});
const emits = defineEmits(["update:modelValue"]);
/**
确定按钮点击事件
*/
const onConfirm = async () => {
closed();
};
/**
* 关闭
*/
const closed = () => {
emits("update:modelValue", false);
};
</script>在
roles-list
中点击查看,展示弹出层<template>
<div class="">
<el-card>
<el-table :data="allRoles" border style="width: 100%">
...
<el-table-column ... #default="{ row }">
<el-button
type="primary"
size="mini"
@click="onDistributePermissionClick(row)"
>
{{ $t("msg.role.assignPermissions") }}
</el-button>
</el-table-column>
</el-table>
</el-card>
<distribute-permission
v-model="distributePermissionVisible"
></distribute-permission>
</div>
</template>
<script setup>
...
import DistributePermission from './components/DistributePermission.vue'
...
/**
* 分配权限
*/
const distributePermissionVisible = ref(false)
const onDistributePermissionClick = row => {
distributePermissionVisible.value = true
}
</script>在弹出层中我们需要利用 el-tree 进行数据展示,此时数据分为两种:
- 所有权限(已存在)
- 角色对应的权限
所以我们需要先获取对应数据
在
api/role
中定义获取角色当前权限/**
* 获取指定角色的权限
*/
export const rolePermission = (roleId) => {
return request({
url: `/role/permission/${roleId}`,
});
};在
DistributePermission
组件中获取所有权限数据<script setup>
import { defineProps, defineEmits, ref } from 'vue'
import { permissionList } from '@/api/permission'
import { watchSwitchLang } from '@/utils/i18n'
...
// 所有权限
const allPermission = ref([])
const getPermissionList = async () => {
allPermission.value = await permissionList()
}
getPermissionList()
watchSwitchLang(getPermissionList)
...
</script>使用 el-tree 渲染权限数据
<template>
...
<el-tree
ref="treeRef"
:data="allPermission"
show-checkbox
check-strictly
node-key="id"
default-expand-all
:props="defaultProps"
>
</el-tree>
...
</template>
<script setup>
...
// 属性结构配置
const defaultProps = {
children: 'children',
label: 'permissionName'
}
...
</script>接下来渲染选中项,即:角色当前权限
调用
rolePermission
接口需要 当前角色 ID,所以我们需要定义对应的props
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
roleId: {
type: String,
required: true,
},
});在
role-list
中传递角色 ID<distribute-permission
v-model="distributePermissionVisible"
:roleId="selectRoleId"
></distribute-permission>
/** * 分配权限 */ const selectRoleId = ref('') const
onDistributePermissionClick = row => { selectRoleId.value = row.id }调用
rolePermission
接口获取数据import { rolePermission } from "@/api/role";
// 获取当前用户角色的权限
const getRolePermission = async () => {
const checkedKeys = await rolePermission(props.roleId);
console.log(checkedKeys);
};
watch(
() => props.roleId,
(val) => {
if (val) getRolePermission();
}
);根据获取到的数据渲染选中的
tree
// tree 节点
const treeRef = ref(null);
// 获取当前用户角色的权限
const getRolePermission = async () => {
const checkedKeys = await rolePermission(props.roleId);
treeRef.value.setCheckedKeys(checkedKeys);
};在
api/role
中定义分配权限接口/**
* 为角色修改权限
*/
export const distributePermission = (data) => {
return request({
url: "/role/distribute-permission",
method: "POST",
data,
});
};点击确定调用接口
import { rolePermission, distributePermission } from "@/api/role";
import { useI18n } from "vue-i18n";
import { ElMessage } from "element-plus";
/**
确定按钮点击事件
*/
const i18n = useI18n();
const onConfirm = async () => {
await distributePermission({
roleId: props.roleId,
permissions: treeRef.value.getCheckedKeys(),
});
ElMessage.success(i18n.t("msg.role.updateRoleSuccess"));
closed();
};
基于 RBAC 的权限控制体系原理与实现分析
那么接下来就进入我们本章中的核心内容 基于 RBAC 的权限控制 ,在之前我们的 权限理论 这一小节的时候说过 RBAC
是基于 用户 -> 角色 -> 权限 的 基于 角色的权限 控制 用户的访问 的体系。
在这套体系中,最基层的就是 权限部分 。那么这个权限部分在我们的项目中具体的呈现是什么呢?那么下面我们就来看一下:
- 我们可以先为 员工角色 指定 空权限
- 然后为我们的 测试用户 指定指定 员工角色
- 此时我们重新登录 测试用户
- 可以发现左侧菜单中仅存在 个人中心 页面
- 然后我们重新登录 超级管理员 账号
- 为 员工角色 指定 员工管理 && 分配角色 权限
- 然后为我们的 测试用户 指定指定 员工角色
- 此时我们重新登录 测试用户
- 可以发现左侧菜单中多出 员工管理 页面,并且页面中仅存在指定的 分配角色 功能
以上就是我们权限系统中的具体呈现。
那么由此呈现我们可以看出,整个权限系统其实分成了两部分:
- 页面权限:比如 员工管理
- 功能权限:比如 分配角色
其中 页面权限 表示:当前用户可以访问的页面
功能权限 表示:当前用户可以访问的权限功能(PS:并非所有功能有需要权限)
那么明确好了以上内容之后,接下来我们来看下,以上功能如何进行实现呢?
首先我们先来看 页面权限:
所谓页面权限包含两部分内容:
- 用户可看到的:左侧
menu
菜单的item
展示 - 用户看不到的:路由表配置
我们知道 左侧 menu
菜单是根据路由表自动生成的。 所以以上第一部分的内容其实就是由第二部分引起的。
那么我们就可以来看一下 路由表配置了。
不知道大家还记不记得,之前我们设置路由表的时候,把路由表分成了两部分:
- 私有路由表
privateRoutes
:依据权限进行动态配置的 - 公开路由表
publicRoutes
:无权限要求的
那么想要实现我们的 页面权限 核心的点就是在我们的 私有路由表 privateRoutes
那么在 私有路由表 privateRoutes
这里我们能做什么呢?
时刻记住我们最终的目的,我们期望的是:不同的权限进入系统可以看到不同的路由 。那么换句话而言是不是就是:根据不同的权限数据,生成不同的私有路由表?
对于 vue-router 4
而言,提供了 addRoute API ,可以 动态添加路由到路由表中,那么我们就可以利用这个 API
生成不同的路由表数据。
那么现在我们来总结一下以上所说的内容:
- 页面权限实现的核心在于 路由表配置
- 路由表配置的核心在于 私有路由表
privateRoutes
- 私有路由表
privateRoutes
的核心在于 addRoute API
那么简单一句话总结,我们只需要:根据不同的权限数据,利用 addRoute API 生成不同的私有路由表 即可实现 页面权限 功能
那么接下来我们来明确 功能权限:
功能权限 的难度低于页面权限,所谓功能权限指的只有一点:
- 根据不同的 权限数据,展示不同的 功能按钮
那么看这一条,依据我们刚才所说的 页面权限 经验,估计大家就应该比较好理解了。
对于 功能权限 而言,我们只需要:根据权限数据,隐藏功能按钮 即可
那么到这里我们已经分析完了 页面权限 与 功能权限
那么接下来我们就可以分别来看一下两者的实现方案了。
首先我们来看 页面权限:
整个 页面权限 实现分为以下几步:
- 获取 权限数据
- 私有路由表 不再被直接加入到
routes
中 - 利用 addRoute API 动态添加路由到 路由表 中
接下来是 功能权限:
整个 功能权限 实现分为以下几步:
- 获取 权限数据
- 定义 隐藏按钮方式(通过指令)
- 依据数据隐藏按钮
业务落地:定义页面权限控制动作,实现页面权限受控
那么这一小节我们来实现 页面权限
首先我们先来明确前两步的内容:
页面权限数据在
userInfo -> permission -> menus
之中私有路由表 不再被直接加入到
routes
中export const privateRoutes = [...]
export const publicRoutes = [...]
const router = createRouter({
history: createWebHashHistory(),
routes: publicRoutes
})
最后我们来实现第三步:利用 addRoute API 动态添加路由到 路由表 中
定义添加的动作,该动作我们通过一个新的
vuex
模块进行创建
store/modules/permission
模块// 专门处理权限路由的模块
import { publicRoutes, privateRoutes } from "@/router";
export default {
namespaced: true,
state: {
// 路由表:初始拥有静态路由权限
routes: publicRoutes,
},
mutations: {
/**
* 增加路由
*/
setRoutes(state, newRoutes) {
// 永远在静态路由的基础上增加新路由
state.routes = [...publicRoutes, ...newRoutes];
},
},
actions: {
/**
* 根据权限筛选路由
*/
filterRoutes(context, menus) {},
},
};那么
filterRoutes
这个动作我们怎么制作呢?我们可以为每个权限路由指定一个
name
,每个name
对应一个 页面权限通过
name
与 页面权限 匹配的方式筛选出对应的权限路由所以我们需要对现有的私有路由表进行重制
创建
router/modules
文件夹写入 5 个页面权限路由
UserManage.js
import layout from "@/layout";
export default {
path: "/user",
component: layout,
redirect: "/user/manage",
name: "userManage",
meta: {
title: "user",
icon: "personnel",
},
children: [
{
path: "/user/manage",
component: () => import("@/views/user-manage/index"),
meta: {
title: "userManage",
icon: "personnel-manage",
},
},
{
path: "/user/info/:id",
name: "userInfo",
component: () => import("@/views/user-info/index"),
props: true,
meta: {
title: "userInfo",
},
},
{
path: "/user/import",
name: "import",
component: () => import("@/views/import/index"),
meta: {
title: "excelImport",
},
},
],
};RoleList.js
import layout from "@/layout";
export default {
path: "/user",
component: layout,
redirect: "/user/manage",
name: "roleList",
meta: {
title: "user",
icon: "personnel",
},
children: [
{
path: "/user/role",
component: () => import("@/views/role-list/index"),
meta: {
title: "roleList",
icon: "role",
},
},
],
};PermissionList.js
import layout from "@/layout";
export default {
path: "/user",
component: layout,
redirect: "/user/manage",
name: "roleList",
meta: {
title: "user",
icon: "personnel",
},
children: [
{
path: "/user/permission",
component: () => import("@/views/permission-list/index"),
meta: {
title: "permissionList",
icon: "permission",
},
},
],
};Article.js
import layout from "@/layout";
export default {
path: "/article",
component: layout,
redirect: "/article/ranking",
name: "articleRanking",
meta: { title: "article", icon: "article" },
children: [
{
path: "/article/ranking",
component: () => import("@/views/article-ranking/index"),
meta: {
title: "articleRanking",
icon: "article-ranking",
},
},
{
path: "/article/:id",
component: () => import("@/views/article-detail/index"),
meta: {
title: "articleDetail",
},
},
],
};ArticleCreate.js
import layout from "@/layout";
export default {
path: "/article",
component: layout,
redirect: "/article/ranking",
name: "articleCreate",
meta: { title: "article", icon: "article" },
children: [
{
path: "/article/create",
component: () => import("@/views/article-create/index"),
meta: {
title: "articleCreate",
icon: "article-create",
},
},
{
path: "/article/editor/:id",
component: () => import("@/views/article-create/index"),
meta: {
title: "articleEditor",
},
},
],
};以上内容存放于 课程资料 -> 动态路由表 中
在
router/index
中合并这些路由到privateRoutes
中import ArticleCreaterRouter from "./modules/ArticleCreate";
import ArticleRouter from "./modules/Article";
import PermissionListRouter from "./modules/PermissionList";
import RoleListRouter from "./modules/RoleList";
import UserManageRouter from "./modules/UserManage";
export const asyncRoutes = [
RoleListRouter,
UserManageRouter,
PermissionListRouter,
ArticleCreaterRouter,
ArticleRouter,
];此时所有的 权限页面 都拥有一个名字,这个名字与 权限数据 匹配
所以我们就可以据此生成 权限路由表数据
/**
* 根据权限筛选路由
*/
filterRoutes(context, menus) {
const routes = []
// 路由权限匹配
menus.forEach(key => {
// 权限名 与 路由的 name 匹配
routes.push(...privateRoutes.filter(item => item.name === key))
})
// 最后添加 不匹配路由进入 404
routes.push({
path: '/:catchAll(.*)',
redirect: '/404'
})
context.commit('setRoutes', routes)
return routes
}在
store/index
中设置该modules
...
export default createStore({
getters,
modules: {
...
permission
}
})在
src/permission
中,获取用户数据之后调用该动作// 判断用户资料是否获取
// 若不存在用户信息,则需要获取用户信息
if (!store.getters.hasUserInfo) {
// 触发获取用户信息的 action,并获取用户当前权限
const { permission } = await store.dispatch("user/getUserInfo");
// 处理用户权限,筛选出需要添加的权限
const filterRoutes = await store.dispatch(
"permission/filterRoutes",
permission.menus
);
// 利用 addRoute 循环添加
filterRoutes.forEach((item) => {
router.addRoute(item);
});
// 添加完动态路由之后,需要在进行一次主动跳转
return next(to.path);
}
next();因为我们主动获取了
getUserInfo
动作的返回值,所以不要忘记在getUserInfo
中return res
那么到这里,当我们更换用户之后,刷新页面,路由表即可动态生成。
但是此时大家应该可以发现,如果不刷新页面得话,左侧菜单是不会自动改变的?那么这是怎么回事呢?大家可以先思考一下这个问题,然后我们下一节再来处理。
业务落地:重置路由表数据
在上一小节中我们遇到了一个问题:重新登录权限账户,不刷新页面,左侧菜单不会自动改变。
那么出现这个问题的原因其实非常简单:退出登录时,添加的路由表并未被删除
所以想要解决这个问题,我们只需要在退出登录时,删除动态添加的路由表即可。
那么删除动态添加的路由可以使用 removeRoute 方法进行。
在
router/index
中定义resetRouter
方法/**
* 初始化路由表
*/
export function resetRouter() {
if (
store.getters.userInfo &&
store.getters.userInfo.permission &&
store.getters.userInfo.permission.menus
) {
const menus = store.getters.userInfo.permission.menus
menus.forEach((menu) => {
router.removeRoute(menu)
})
}在退出登录的动作下,触发该方法
import router, { resetRouter } from '@/router'
logout(context) {
resetRouter()
...
}
业务落地:创建功能受控指令
在前面分析 功能权限 时,我们说过,实现功能权限的核心在于 根据数据隐藏功能按钮,那么隐藏的方式我们可以通过指令进行。
所以首先我们先去创建这样一个指令(vue3 自定义指令)
我们期望最终可以通过这样格式的指令进行功能受控
v-permission="['importUser']"
以此创建对应的自定义指令
directives/permission
import store from "@/store";
function checkPermission(el, binding) {
// 获取绑定的值,此处为权限
const { value } = binding;
// 获取所有的功能指令
const points = store.getters.userInfo.permission.points;
// 当传入的指令集为数组时
if (value && value instanceof Array) {
// 匹配对应的指令
const hasPermission = points.some((point) => {
return value.includes(point);
});
// 如果无法匹配,则表示当前用户无该指令,那么删除对应的功能按钮
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
// eslint-disabled-next-line
throw new Error('v-permission value is ["admin","editor"]');
}
}
export default {
// 在绑定元素的父组件被挂载后调用
mounted(el, binding) {
checkPermission(el, binding);
},
// 在包含组件的 VNode 及其子组件的 VNode 更新后调用
update(el, binding) {
checkPermission(el, binding);
},
};在
directives/index
中绑定该指令...
import permission from './permission'
export default (app) => {
...
app.directive('permission', permission)
}在所有功能中,添加该指令
views/role-list/index
<el-button ... v-permission="['distributePermission']">
{{ $t('msg.role.assignPermissions') }}
</el-button>views/user-manage/index
<el-button ... v-permission="['importUser']">
{{ $t('msg.excel.importExcel') }}</el-button
><el-button ... v-permission="['distributeRole']"
>{{ $t('msg.excel.showRole') }}</el-button
><el-button ... v-permission="['removeUser']"
>{{ $t('msg.excel.remove') }}</el-button
>
总结
那么到这里我们整个权限受控的章节就算是全部完成了。
整个这一大章中,核心就是 RBAC
的权限受控体系 。围绕着 用户->角色->权限 的体系是现在在包含权限控制的系统中使用率最广的一种方式。
那么怎么针对于权限控制的方案而言,除了课程中提到的这种方案之外,其实还有很多其他的方案,大家可以在我们的话题讨论中踊跃发言,多多讨论。