人力资源管理项目

一、前端项目搭建

1、搭建项目前的一些基本准备

前端使用的是Vue3框架,所以开始先使用Vue脚手架来创建前端的项目。

确保在电脑中安装了对应的脚手架vue/cli

1
npm install -g @vue/cli

当然,首先必须在电脑中安装node.js环境

查看nodenpm的版本

1
2
$ node -v #查看node版本
$ npm -v #查看npm版本

**npm淘宝镜像**

npm是非常重要的npm管理工具,由于npm的服务器位于国外, 所以一般建议 将 npm设置成国内的淘宝镜像

设置淘宝镜像

1
2
$ npm config set registry  https://registry.npm.taobao.org/  #设置淘宝镜像地址
$ npm config get registry #查看镜像地址

2、使用脚手架搭建Vue3开发环境

在指定的目录下面执行

1
vue create 项目名称

使用以上命令创建项目的时候,出现了如下错误

1
vue : 无法加载文件 C:\XXX\AppData\Roaming\npm\vue.ps1,因为在此系统上禁止运行脚本。

参考解决方案:

1
https://blog.csdn.net/ying456baby/article/details/126379392

第一:以管理身份启动命令窗口

第二:在终端窗口输入 **set-ExecutionPolicy RemoteSigned**

输入命令查看 **get-ExecutionPolicy**“**RemoteSigned**”

项目创建的步骤如下

第一步:打开命令行窗口(以管理员身份)

第二步:在打开的命令行窗口中输入vue create hr-front

第三步:选择自定义创建

第四:选中vue-router,vuex,css Pre-processors选项

第五步:选择vue3.0版本

第六步:选择hash模式的路由

第七步:选择less作为预处理器

第八步:选择 standard 标准代码风格

第九步:保存代码校验代码风格,代码提交时候校验代码风格

第十步:依赖插件或者工具的配置文件分文件保存

第十一步:是否记录以上操作,选择否

第十二步:等待安装…

最后:安装完毕

这里可以cdhr-front目录,然后通过执行npm run serve命令来启动项目

3、API模块和请求封装模块介绍

1
npm i axios

src目录下面创建utils目录,在该目录下面创建request.js文件,该文件中的初步代码如下所示:

先创建axios的基本结构,后面在完善

1
2
3
4
5
6
// 导出一个axios实例,这个实例中要求有请求拦截器与响应拦截器
import axios from 'axios'
const service = axios.create(); // 创建一个axios的实例
service.interceptors.request.use(); // 请求拦截器
service.interceptors.response.use();// 响应拦截器
export default service;

api模块的单独封装

我们习惯性的将所有的网络请求 放置在api目录下统一管理,按照模块进行划分

src目录下面创建api目录,在该目录下面创建user.js文件,该文件中封装的就是获取用户信息,或者是对用户信息进行操作的api接口。

初步代码如下所示:

1
2
3
4
5
6
7
import request from "@/utils/request";

export function login(data) {}

export function getInfo() {}

export function logout() {}

4、公共资源图片和统一样式

将文件夹拷贝到assets目录下面

src目录下面创建styles目录,把样式都拷贝到该目录下面

5、修改网站名称

public/index.html文件中,可以在title标签中看到如下的代码:

1
<title><%= htmlWebpackPlugin.options.title %></title>

这里获取的是webpack中的htmlWebpackPlugin插件中配置的标题

默认情况下,项目显示的标题为项目路径对应的名称,下面介绍修改htmlWebpackPlugin.options.title对应的值。

修改项目中vue.config.js文件中的配置

vue.config.js 就是vue项目相关的编译,配置,打包,启动服务相关的配置文件.

在文件中添加如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
lintOnSave: false,
chainWebpack: (config) => {
config.plugin("html").tap((args) => {
// console.log("args===", args);
// // args相当于 html模板中 htmlWebpackplugin.options
args[0].title = "人力资源管理平台"; // 这里修改了网站的标题
return args;
});
},
};

args参数的值为

1
2
3
4
5
6
7
args=== [
{
title: 'hr-front',
templateParameters: [Function: templateParameters],
template: 'index.html文件的路径'
}
]

所以说args参数相当于html模板中的htmlWebpackplugin.options.

配置完成后一定要重新启动项目。

返回浏览器,查看标题。

6、修改端口号

项目启动以后,对应的端口号是8080

如果想修改默认的端口号应该怎样处理呢?

在项目的根目录下面创建两个文件:

分别是.env.development,.env.production

development => 开发环境

production => 生产环境

当我们运行**npm run serve进行开发调试的时候,此时会加载执行.env.development**文件内容

当我们运行**npm run build进行生产环境打包的时候,会加载执行.env.production**文件内容

所以,如果想要设置开发环境的接口,直接在**.env.development**中写入对于port变量的赋值即可

1
2
# 设置端口号
port = 8888

问题是怎样获取该变量的值?

下面继续修改vue.config.js文件中的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const port = process.env.port || 8080;
module.exports = {
lintOnSave: false,
chainWebpack: (config) => {
config.plugin("html").tap((args) => {
// console.log("args===", args);
args[0].title = "人力资源管理平台";
return args;
});
},
devServer: {
port: port, // port:8888 8666
},
};

这里首先通过process.env.port获取到.env.development文件中所定义的port变量值。

然后把该变量的值赋值给devServer配置项中的port属性。

重新启动项目。

二、登录模块

1、登录布局实现

这里我们会使用Element Plus组件来实现登录界面布局。

https://element-plus.gitee.io/zh-CN/

1
npm install element-plus --save

安装好以后,在项目中使用Element Plus组件,这里需要按需导入:

https://element-plus.gitee.io/zh-CN/guide/quickstart.html#%E6%8C%89%E9%9C%80%E5%AF%BC%E5%85%A5

修改vue.config.js这个配置文件,实现组件的按需导入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const AutoImport = require("unplugin-auto-import/webpack");
const Components = require("unplugin-vue-components/webpack");
const { ElementPlusResolver } = require("unplugin-vue-components/resolvers");
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
lintOnSave: false,
chainWebpack: (config) => {
config.plugin("html").tap((args) => {
// console.log("args===", args);
// // args相当于 html模板中 htmlWebpackplugin.options
args[0].title = "人力资源管理平台"; // 这里修改了网站的标题
return args;
});
},
// 实现Element Plus组件的按需导入
// configureWebpack中指定的属性与webpack中的属性完全一致,最后会进行合并
configureWebpack: {
plugins: [
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
},
})

这里你需要安装unplugin-vue-componentsunplugin-auto-import这两款插件

1
npm install -D unplugin-vue-components unplugin-auto-import

下面进行测试:在views目录下面创建login目录,在该目录下面创建index.vue组件,该组件中的初步代码如下:

1
2
3
4
5
6
<template>
<div>
<el-button>按钮</el-button>
hello world
</div>
</template>

在模板中直接使用了按钮组件。

这里还需要配置一下路由:

修改src/router/index.js文件中的代码,添加如下路由规则

1
2
3
4
5
6
7
8
9
10
11
12

const routes = [
{
path: "/",
name: "Home",
component: Home,
},
{
path: "/login",
name: "Login",
component: () => import("@/views/login/index.vue"),
},

在上面添加了登录的路由规则。

由于修改了配置文件,需要重新启动项目

http://localhost:8080/#/login 进行访问

下面进行布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
<template>
<div class="login-container">
<el-form class="login-form">
<div class="title-container">
<h3 class="title">
<!-- <img src="@/assets/common/login-logo.png" alt="" /> -->
</h3>
</div>
<el-form-item>
<el-input placeholder="请输入手机号" type="text" v-model="mobile" tabindex="1" />
</el-form-item>

<el-form-item>
<el-input placeholder="请输入密码" name="password" type="password" tabindex="2" v-model="password" />
</el-form-item>

<el-button class="loginBtn" type="primary" style="width: 100%; margin-bottom: 30px" @click="submit">登录</el-button>
<div class="tips">
<span style="margin-right: 20px">账号: 13811111111</span>
<span> 密码: 123</span>
</div>
</el-form>
</div>
</template>
<script>
import { ref } from "vue";
export default {
name: "Login",
setup() {
const mobile = ref("");
const password = ref("");
const submit = () => {
console.log(mobile.value + " " + password.value);
};
return {
mobile,
password,
submit,
};
},
};
</script>

<style lang="scss" scoped>
$bg: #2d3a4b;
$dark_gray: #889aa4;
$light_gray: #eee;

.login-container {
min-height: 100%;
width: 100%;
background-color: $bg;
overflow: hidden;
background-image: url("~@/assets/common/login.jpg"); // 设置背景图片,注意:如需要在样式表中使用**`@`**别名的时候,需要在@前面加上一个**`~`**符号,否则不识别
background-position: center; // 将图片位置设置为充满整个屏幕

.login-form {
position: relative;
width: 520px;
max-width: 100%;
padding: 160px 35px 0;
margin: 0 auto;
overflow: hidden;
}

.el-form-item {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.7); // 输入登录表单的背景色
border-radius: 5px;
color: #454545;
}

.el-form-item__error {
color: #fff;
}

.loginBtn {
background: #407ffe;
height: 64px;
line-height: 32px;
font-size: 24px;
}

.tips {
font-size: 14px;
color: #fff;
margin-bottom: 10px;

span {
&:first-of-type {
margin-right: 16px;
}
}
}

.title-container {
position: relative;

.title {
font-size: 26px;
color: $light_gray;
margin: 0px auto 40px auto;
text-align: center;
font-weight: bold;
}
}

.show-pwd {
position: absolute;
right: 10px;
top: 7px;
font-size: 16px;
color: $dark_gray;
cursor: pointer;
user-select: none;
}
}
</style>

1
2
Failed to resolve loader: sass-loader
Can't resolve 'sass-loader'

编译项目的时候,会出现如上的错误,原因:我们使用的是sass来编写样式,当时我们使用脚手架搭建项目的时候,使用的是less.

所以我们现在搭建的项目无法处理sass。这里需要安装sass-loader这个包来进行处理。

1
2
npm i sass
npm i sass-loader

在上面的代码中,完成了表单的布局。(注意:如需要在样式表中使用**@别名的时候,需要在@前面加上一个~**符号,否则不识别)

同时文本框通过v-model进行了双向绑定。

setup入口函数中,通过ref创建响应式对象,由于与文本框进行了双向绑定,所以在单击登录按钮的时候,在所触发的方法中可以获取到文本框中的值。

返回到浏览器中进行测试。

当然我们也可以按照文档中的使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script>
import { reactive } from "vue";
export default {
name: "Login",
setup() {
const ruleForm = reactive({
mobile: "",
password: "",
});
const submit = () => {
console.log(ruleForm.mobile + "...." + ruleForm.password); // 打印在文本框与密码框中输入的内容
};
return {
submit,
ruleForm, // 注意:这里将响应式对象返回到模版中,模版中才会使用
};
},
};
</script>

通过reactive创建一个响应式对象,让后返回到模板中

1
<el-form class="login-form" :model="ruleForm">

ruleFormel-form中的model进行关联。

modelel-form表单提供的属性,含义是:表单数据对象

1
2
3
4
5
6
7
8
9
10
11
12
13
<el-input
placeholder="请输入手机号"
type="text"
v-model="ruleForm.mobile"
tabindex="1"
/>
<el-input
placeholder="请输入密码"
name="password"
type="password"
tabindex="2"
v-model="ruleForm.password"
/>

通过v-modelruleForm中的属性进行了双向绑定

返回浏览器中进行测试

官方文档案例:https://element-plus.gitee.io/zh-CN/component/form.html#%E8%A1%A8%E5%8D%95%E6%A0%A1%E9%AA%8C

2、表单校验

关于对手机号码的正则表达式的校验,单独的封装到了一个js文件中。

utils目录下面创建validate.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
/*
校验手机号
**/
export function validMobile(str) {
return /^1[3-9]\d{9}$/.test(str);
}

返回到views/login/index.vue文件中继续修改代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<script>
import { reactive } from "vue";
import { validMobile } from '@/utils/validate' // 导入validMobile
export default {
name: "Login",
setup() {
const ruleForm =reactive({
mobile:"",
password:""
});
// 手机号码的验证:
const validateMobile =(rule,value,callback)=>{
console.log('rule =',rule);
console.log("value = ",value);
console.log("callback = ",callback);
// 调用所导入的validMobile这个函数,对传递过来的手机号进行校验
validMobile(value)?callback():callback(new Error("手机号码错误"));
}
// 定义校验的规则
const loginRules =reactive({
mobile:[{
required:true,
message:'请输入手机号码',
trigger:"blur"
},{
trigger:"blur",
validator: validateMobile // 当手机号文本框失去焦点以后,会自动调用validateMobile这个函数。
}],
password:[{
required:true,
message:'请输入密码',
trigger:"blur"
},{
min:6,
max:16,
message:'密码的长度在6-16位之间',
trigger:"blur"
}]
});
const submit = () => {
console.log(ruleForm.mobile + " " + ruleForm.password);
};
return {
ruleForm,
submit,
loginRules // 返回定义的校验规则
};
},
};
</script>

在上面的代码中,先将validMobile方法导入进来。

定义了校验的响应式对象loginRules ,并且返回

在该对象中定义了校验规则,注意mobilepassword 一定要与el-form-item中的prop属性保持一致,校验才会起作用。

在校验手机号码的时候,要求必填,同时指定了validator属性,在失去焦点后,会执行validator属性对应的validateMobile方法,

1
2
3
4
5
6
7
8
// 手机号码的验证:
const validateMobile =(rule,value,callback)=>{
console.log('rule =',rule);
console.log("value = ",value);
console.log("callback = ",callback);
// 调用所导入的validMobile这个函数,对传递过来的手机号进行校验
validMobile(value)?callback():callback(new Error("手机号码错误"));
}

validateMobile 方法有三个参数,第一个参数:表示校验的规则,第二个参数,用户输入的手机号码,第三个参数callback是一个函数。

validateMobile 方法内部,调用validMobile方法校验手机号码,如果通过校验了,调用callback函数,如果没有通过校验,也会调用callback函数,只不过会传递错误对象。

模板中的代码

1
<el-form class="login-form" :model="ruleForm" :rules="loginRules">

这里将定义的规则对象与el-form组件中的rules属性关联在一起。

1
2
3
4
5
6
7
<el-form-item prop ="mobile">
<el-input placeholder="请输入手机号" type="text" v-model="ruleForm.mobile" tabindex="1" />
</el-form-item>

<el-form-item prop ="password">
<el-input placeholder="请输入密码" name="password" type="password" tabindex="2" v-model="ruleForm.password" />
</el-form-item>

注意:el-form-item中的prop属性一定要与校验规则对象loginRules中定义的属性保持一致,校验才起作用。

返回浏览器中进行测试。

修饰符

这里还有一个小问题就是,用户一般都是输入完密码以后,按下回车键,就可以发送请求,也就是相当于单击了登录按钮。

要想实现这样的效果,需要用到修饰符

1
https://cn.vuejs.org/guide/essentials/event-handling.html#event-modifiers
1
<el-input placeholder="请输入密码" name="password" type="password" tabindex="2" v-model="ruleForm.password" @keyup.enter="submit"/>

在上面的代码中添加了@keyup.enter修饰符

输入完密码,按下回车键,也要调用submit方法。

返回到浏览器中进行测试,在浏览器的控制台中查看输入的结果。

这里有一个小的问题,当在密码框中输入完内容以后,按下回车键会调用submit打印所输入的手机号码与密码。

但是,如果密码没有按照上面定义的验证规则进行输入,按下回车键以后,也会打印出来,这样就不行了,而这种情况应该

给出相应的错误提示。也就是说,在按下了登录按钮把用户在表单中所输入的内容发送到服务端之前,也需要进行校验

这里也是参考element-ui文档中的校验方式

1
2
3
4
5
6
7
8
9
10
import { reactive,ref } from "vue"; // 导入ref
import { validMobile } from '@/utils/validate'
export default {
name: "Login",
setup() {
const ruleForm =reactive({
mobile:"",
password:""
});
const ruleFormRef=ref(); // 这里创建了一个ref对象ruleFormRef

上面的代码中,我们创建了一个ruleFormRef对象,创建该对象的目的就是与form表单进行关联,通过该对象获取form表单,完成校验。

所以这里需要将ruleFormRef这个对象返回

1
2
3
4
5
6
return {
ruleForm,
submit,
loginRules,
ruleFormRef // 将ruleFormRef对象返回
};
1
<el-form class="login-form" :model="ruleForm" :rules="loginRules" ref="ruleFormRef">

el-form这个表单组件,添加ref属性与ruleFormRef进行关联。

1
2
3
4
<el-input placeholder="请输入密码" name="password" type="password" tabindex="2" v-model="ruleForm.password" @keyup.enter="submit(ruleFormRef)"/>


<el-button class="loginBtn" type="primary" style="width: 100%; margin-bottom: 30px" @click="submit(ruleFormRef)">登录</el-button>

不管是在按下回车键的时候,还是单击登录按钮的时候,都把ruleFormRef这个对象,传递到submit方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 进行表单的提交
const submit = (loginForm) => { // ----接收表单
// 调用validate方法进行校验
loginForm.validate((valid)=>{
// 校验通过valid这个参数的值就是true,否则就是false
if(valid){
// 打印用户输入的手机号与密码(这里把手机号码的空格去掉了)
console.log(ruleForm.mobile.trim() + " " + ruleForm.password);
}else{
// 如果出错了给出错误的提示
ElMessageBox.alert("密码没有通过验证!","数据校验",{
confirmButtonText:"OK"
});
}
}
)
};

submit这个方法中,接收传递过来的ruleFormRef对象,也就是form表单,然后进行校验。

启动项目进行测试。

3、服务端环境搭建

这里的服务端架构还是以前的架构设计。

这里首先修改一下数据库链接字符串appsettings.jsonMyDbContextDesign.cs两个文件中的字符串都需要修改

第二点:创建一个WebApi项目,并且添加如下的包

1
2
3
4
5
6
7
8
9
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Autofac" Version="7.0.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="8.0.0" />

第三点:把Attributes,AutofaceDI,Filters三个文件夹都拷贝到WebApi项目中,并且修改这些文件夹中所有类的命名空间。

第四点:在Program.cs文件中完成相应的注册操作

4、服务端登录接口开发

创建一个login控制器

并且在WebApi项目中,安装Jwt所需要的包

1
Install-Package  Microsoft.AspNetCore.Authentication.JwtBearer

这里为了简化项目的复杂度,我们不再创建DTO数据传输对象,大家在做的时候,可以根据情况自行添加

针对登录,很难创建一个标注的Restful的接口,但是我们前面也已经讲解过,不用为了RestfulRestful

这里创建一个LoginController.cs控制器,该控制器中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using Cms.Entity;
using Cms.IService;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace Cms.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class LoginController : ControllerBase
{
private readonly IUserInfoService _userInfoService;
private readonly IConfiguration configuration;
public LoginController(IUserInfoService userInfoService, IConfiguration configuration)
{
this._userInfoService = userInfoService;
this.configuration = configuration;
}
[HttpPost]
public async Task<IActionResult> login([FromBody] UserInfo userInfo)
{
if (string.IsNullOrWhiteSpace(userInfo.UserPhone) || string.IsNullOrWhiteSpace(userInfo.UserPassword))
{
return BadRequest("手机号与密码不能为空!");
}
//校验用户
var loginUser = await _userInfoService.LoadEntities(u=>u.UserPhone==userInfo.UserPhone&&u.UserPassword==userInfo.UserPassword).FirstOrDefaultAsync();
if (loginUser == null)
{
return BadRequest("手机号与密码错误");
}
// 2、创建JWT
// 创建header,内部定义了JWT编码的算法
var securityAlgorithm = SecurityAlgorithms.HmacSha256;
// 创建payload,需要根据项目的需求来进行创建,例如可能会使用到用户的编号,用户名,邮箱等
// 创建payload的内容,需要使用到Claim(每创建一个Claim对象,表示用户的一个信息)
var claims = new[]
{
// 第一项是,用户的ID,但是在JWT中,关于ID在JWT中有一个专用的名词叫做sub
new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub,loginUser.Id.ToString())
};
// 创建签名,签名会使用到私钥,可以把私钥保存在配置文件中
// 读取配置文件中的私钥,然后转成字节
var secretByte = Encoding.UTF8.GetBytes(configuration["Authentication:SecretKey"]!);
// 使用加密算法,对私钥进行加密
var signingKey = new SymmetricSecurityKey(secretByte);
// 构建数字签名
var signingCredentials = new SigningCredentials(signingKey, securityAlgorithm);
// 构建token 内容
var token = new JwtSecurityToken(
// 谁发布的token数据,一般是服务端的地址
issuer: configuration["Authentication:Issuer"],
// 把token数据发布给谁,一般就是前端项目,这里也可以填写服务端的地址,或者不填写也可以
audience: configuration["Authentication:Audience"],
claims,
// 发布时间
notBefore: DateTime.Now,
// 有效期
expires: DateTime.Now.AddDays(1),
// 数字签名
signingCredentials

);
// 将token生成字符串的形式进行输出
var tokenStr = new JwtSecurityTokenHandler().WriteToken(token);
//3、返回 200状态码和JWT内容
return Ok(tokenStr);
}

}
}

在上面的代码中,我们构建数字签名的时候,使用了私钥,私钥必须保存到服务端,同时这里我们将其保存在了appsettings.json文件中

1
2
3
4
5
6
7
8
"ConnectionStrings": {
"StrConn": "server=localhost;database=HrDb;uid=sa;pwd=123456;TrustServerCertificate=true"
},
"Authentication": {
"SecretKey": "abc123456789@126.com", // 这里的值一定要长,否则会出错。
"Issuer": "xxx.com",
"Audience": "xxx.com"
}

启动授权

当用户访问某些资源的时候,需要进行验证。

这就是使用前面所生成的token.

当然,这里需要注入jwt的认证服务。

修改Program.cs文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 注入jwt的认证服务(这里采用默认的认证)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
// 获取配置文件中存储的密钥
var secretByte = Encoding.UTF8.GetBytes(builder.Configuration["Authentication:SecretKey"]!);
options.TokenValidationParameters = new TokenValidationParameters()
{
// 验证token的发布者
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Authentication:Issuer"],
// 验证token的持有者
ValidateAudience = true,
ValidAudience = builder.Configuration["Authentication:Audience"],
// 验证toen是否过期
ValidateLifetime = true,
// 使用私钥
IssuerSigningKey = new SymmetricSecurityKey(secretByte)

};

});


//注入DbContext,同时获取数据库链接字符串
builder.Services.AddDbContext<MyDbContext>(opt =>
{

以上代码就是在注入DbContext服务的上面,注入了JWT的认证服务。

1
2
3
4
5
6
7
8
9
10
11
12
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// 你是谁,是否登录
app.UseAuthentication();
// 你可以干什么,有什么权限?
app.UseAuthorization();

app.MapControllers();

同时,在Program.cs文件的下面添加了app.UseAuthentication(),app.UseAuthorization();app.MapControllers();

下面,我们进行测试。

这里我们可以把登录成功后,返回前端的数据格式修改一下:

WebApi项目中创建models文件夹,在该文件夹中创建ApiResult.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
namespace Cms.WebApi.models
{
public class ApiResult<T>
{
public bool Success { get; set; }
public string? Message { get; set; }
public T? Data { get; set; }
}
}

下面修改一下LoginController控制器中的login方法中的代码,如下所示:

1
2
3
4
// 将token生成字符串的形式进行输出
var tokenStr = new JwtSecurityTokenHandler().WriteToken(token);
//------------------3、返回 200状态码和JWT内容,这里返回的是ApiResult中定义的格式
return Ok( new ApiResult<string> (){ Success=true, Message="登录成功",Data=tokenStr});

在上面的代码中,返回的就是ApiResult定义的格式。

5、前端–封装单独的登录接口

src/api/user.js文件中,完善login方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import request from "@/utils/request";

export function login(data) {
// 返回一个promise对象
return request({
url:'/api/login',
method:'post',
data
})
}

export function getInfo() {}

export function logout() {}

6、Vuex中对token进行管理

关于token信息,我们需要存储到Vuex中,因为需要在不同的组件中使用。将Token信息存储到Vuex中可以实现Token信息的共享,更方便读取。

当然,存储到Vuex中的Token信息,在刷新浏览器后会丢失,所以需要持久化到本地。

src/utils/目录下面创建storage.js 文件,该文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 存储token信息的本地存储名称
const TOKEN_KEY = "hr-token";
// 将token信息存储到本地
export const setTokenInfo =(tokenInfo)=>{
localStorage.setItem(TOKEN_KEY,JSON.stringify(tokenInfo));
}
// 从本地获取token信息
export const getTokenInfo=()=>{
return JSON.parse(localStorage.getItem(TOKEN_KEY));
}
// 删除本地token信息
export const removeTokenInfo=()=>{
localStorage.removeItem(TOKEN_KEY);
}

以上定义了相关方法,实现将token信息存储到本地,以及从本地读取token信息和删除token信息。

store目录下面创建modules目录,在该目录下面创建的文件都是Vuex的模块。

在这里我们先创建user.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { getTokenInfo, removeTokenInfo, setTokenInfo } from "@/utils/storage";
export default {
namespaced: true,
state() {
return {
token: getTokenInfo(), // 获取token信息,初始化vuex
};
},
mutations: {
setToken(state, payload) {
state.token = payload; // 将token信息保存到vuex容器中
setTokenInfo(payload); // 将token信息持久化到本地
},
removeToken(state) {
state.token = null; // 清空vuex中的token信息
removeTokenInfo(); // 删除本地token信息
},
},
};

这里我们需要将上面创建的user模块,注册到vuex容器中。

修改src/store/index.js文件中的代码,如下所示:

1
2
3
4
5
6
7
import { createStore } from "vuex";
import user from "./modules/user";
export default createStore({
modules: {
user,
},
});

将创建的user模块,添加到了modules这个选项中,完成了模块的注册操作。

当然,在入口文件main,js文件中,也已经将vuex容器注册到了应用实例中。

1
2
3
4
5
6
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

createApp(App).use(store).use(router).mount('#app') // use(store)
1
2
3
4
5
6
7
8
9
10
localstorage和cookie都可以用来做本地存储,实现数据持久化。它们的区别还是很多的,主要体现在以下几个方面。

第一:能保存的数据大小不同。localstorage能保存的内容更多一些,查资料差不多是5M;cookie能保存的内容少一些,差不多4K

第二: 有效时间不同。cookie的有效期可以自行设置,而localstorage可以一直生效。

第三:在请求时,cookie会被携带,而localstorage不会。同源的cookie信息会自动作为请求头的一部分发给服务器,如果过多设置cookie,会额外增加通信负荷。而localstorage没有这个问题,它会一直存在于浏览器端。

在实际开发中,我会根据具体情况来选择使用它们。如果不需要与服务器通信并且可以长时间保存在客户端的信息就可以采用localstorage来保存,例如:网站中,提供的个人设置信息。

7、创建登录的Action

修改store/modules/user.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { getTokenInfo, removeTokenInfo, setTokenInfo } from "@/utils/storage";
import { login } from "@/api/user";
export default {
namespaced: true,
state() {
return {
token: getTokenInfo(), // 获取token信息,初始化vuex
};
},
mutations: {
setToken(state, payload) {
state.token = payload; // 将token信息保存到vuex容器中
setTokenInfo(payload); // 将token信息持久化到本地
},
removeToken(state) {
state.token = null; // 清空vuex中的token信息
removeTokenInfo(); // 删除本地token信息
},
},
actions: {
async userLogin(context, payload) {
const result = await login(payload);
// // axios默认给数据加了一层data
if (result.data.success) {
context.commit("setToken", result.data.data);
}
},
},
};

在上面的代码中,导入了api/user.js文件中定义的login方法。

然后创建actions,在actions中创建userLogin方法,在调用该方法的时候,会传递手机号和密码。

在该方法中调用login方法发送请求,实现登录。

登录成功以后,提交setToken这个mutations方法,这样就可以将服务端返回的token数据存储到vuex中,同时也实现了本地持久化操作。

除此之外,为了更好的让其他模块和组件更好的获取token数据,我们可以在**store/getters.js**中将token值作为公共的访问属性放出.

store目录下面创建getters.js文件,该文件中的代码如下所示:

1
2
3
4
5
const getters = {
token: (state) => state.user.token,
};
export default getters;

在上面的代码中从user模块中获取token这个状态数据。

当然,也需要将上面定义的getters注册到vuex容器中。

所以还需要修改store/index.js文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
import { createStore } from "vuex";
import user from "./modules/user";
import getters from "./getters";
export default createStore({
modules: {
user,
},
getters,
});

在上面的代码中,导入了getters,同时在创建store容器的时候,完成了对应的注册操作。

8、完善axios配置

8.1 区分axios在不同环境中的请求地址

在开发环境与生产环境中请求的服务端接口是不一样的。

怎样进行区分呢?

通过**.env.development.env.production**两个文件进行区分。

.env.development是在开发环境中执行的文件,而.env.production是在生产环境中执行的文件。

所以:我们可以在**.env.development.env.production**定义变量,变量自动就为当前环境的值

下面修改根目录下面的.env.development文件中的配置:

1
2
3
4
port = 8888

# 开发环境的基础地址和代理对应
VUE_APP_BASE_API = 'http://localhost:5105/api'

但是我们的开发环境代理是**/api**,所以可以采用以上的统一方式。

如果是生产环境,需要修改.env.production文件,如下所示:

1
2
# 生产环境的基础地址和代理对应
VUE_APP_BASE_API = 'http://localhost:8105/api'

以上是生产环境下的接口地址。

下面修改utils/request.js文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
// 导出一个axios的实例  而且这个实例要有请求拦截器 响应拦截器
import axios from "axios";
const service = axios.create({
// 如果执行 npm run serve 值为 /api 正确 /api 这个代理只是给开发环境配置的代理
// 如果执行 npm run build 值为 /prod-api 没关系 运维应该在上线的时候 给你配置上 /prod-api的代理
baseURL: process.env.VUE_APP_BASE_API,
timeout: 5000,
}); // 创建一个axios的实例
service.interceptors.request.use(); // 请求拦截器
service.interceptors.response.use(); // 响应拦截器
export default service; // 导出axios实例

8.2 响应拦截器配置

修改utils/request.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 导出一个axios的实例  而且这个实例要有请求拦截器 响应拦截器
import axios from "axios";
// import { ElMessage } from "element-plus"; //注意:前面我们针对element-plus已经配置了按需导入,所以这里不需要导入ElMessage,而是直接使用
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 5000,
}); // 创建一个axios的实例
service.interceptors.request.use(); // 请求拦截器
// 配置响应拦截器
service.interceptors.response.use(
(response) => {
// axios 默认添加了一层data
const { success, message, data } = response.data;
if (success) {
return data;
} else {
// 业务已经错误了(例如输入的手机号或者是密码是错误的),需要给出错误提示,并且应该进入catch
ElMessage.error(message);
return Promise.reject(new Error(message));
}
},
(error) => {
ElMessage.error(error.message);
return Promise.reject(error); // 返回执行错误,让当前的执行链跳出成功,直接进行入catch
}
); // 响应拦截器
export default service; // 导出axios实例

在上面的代码中,我们实现了响应拦截,这里use方法需要两个参数,都是函数。

第一个表示处理成功的响应

第二个表示处理失败的响应。

如果处理失败了,首先通过ElMessage弹出错误的消息,同时执行 return Promise.reject(error),表示返回执行的错误,让当前的执行链跳出成功,直接进入catch.

例如:登录的时候,我们执行的是login().then().catch(),如果成功了会进入到then方法,但是如果服务端处理失败了,会执行catch

在第一个处理函数中,我们解构出了服务端返回内容,当前服务端接口,返回的内容包括了success,message,data

这里我们判断一下success如果为true,表示服务端返回了正确的响应数据,这里直接将data返回。

否则表示业务执行错误,需要进行错误处理。

如果响应是成功的,这里我们直接将服务端返回的响应数据返回了,所以在store/modules/user.js文件中的userLogin这个actions方法中,不需要添加判断,并且result中存储的就是服务端返回的响应数据。所以修改成如下的形式。

1
2
3
4
5
6
actions: {
async userLogin(context, payload) {
const result = await login(payload);
context.commit("setToken", result);
},
},

9、实现登录

下面修改views/login/index.vue中的代码

我们知道当我们单击登录按钮的时候,会调用submit方法

在该方法中,我们需要派发action,来发送请求。

修改submit方法中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 进行表单的提交
const submit = (loginForm) => {
loginForm.validate(async(valid)=>{
if(valid){
//-----------------注意:这里重新定义了一个loginForm对象,保存用户在表单中输入的内容,这里的属性一定要是userPhone和userPassword,服务端才会自动进行接收。
const loginForm={
userPhone:ruleForm.mobile,
userPassword:ruleForm.password
}
await store.dispatch("user/userLogin", loginForm);
router.push("/");
}else{
ElMessageBox.alert("密码没有通过验证!","数据校验",{
confirmButtonText:"OK"
});
}
}
)

通过通过校验了这里通过store.dispatch派发的是user模块中的userLogin这个actions方法,同时为该方法传递的是用户在表单中输入的数据.

成功以后进行跳转。

1
2
import { useStore } from "vuex";
import { useRouter } from "vue-router";

导入useStore和useRouter两个方法。

1
2
3
const formRef = ref(null);
const store = useStore();
const router = useRouter();

调用useStore方法创建store实例,调用useRouter创建router实例。

返回到浏览器中进行测试。

先输入错误的密码进行测试

然后输入正确的手机号和密码进行测试。

当登录成功以后,可以查看本地存储的token信息。

注意:在进行测试的时候,一定要重新启动项目,因为前面修改了配置。

但是这里出错了,出错的原因是出现了跨域的问题。

10、跨域

1
2
3
跨源(跨域) HTTP 请求的一个例子:运行在 https://domain-a.com 的 JavaScript 代码使用 XMLHttpRequest 来发起一个到 https://domain-b.com/data.json 的请求。

出于安全性,浏览器限制脚本内发起的跨源 HTTP 请求

它是浏览器同源策略造成的,是浏览器对JS实施的安全限制。

浏览器安全性可防止网页向不处理网页的域发送请求。 此限制称为同域(同源)策略。 同域(同源)策略可防止恶意站点从另一站点读取敏感数据

*什么是同源策略?* (所谓同源是指:“域名”、“协议”、“端口”均为相同)

具体参考微软官方文档:https://learn.microsoft.com/zh-cn/aspnet/core/security/cors?view=aspnetcore-7.0

常见的跨域场景

跨域资源共享CORS

CORS是一个W3C标准,全称是”跨域资源共享”(Cross-origin resource sharing)。 它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。IE8+:IE8/9需要使用XDomainRequest对象来支持CORS。

整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。 因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。

基本流程:

1
2
3
4
5
6
7
8
9
对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。 下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。

GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0

Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。 浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段,就知道出错了,从而抛出一个错误,被XMLHttpRequestonerror回调函数捕获。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

1
2
3
4
5
6
7
8
9
10
11
12
   Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8


上面的头信息之中,有三个与CORS请求相关的字段,都以Access-Control-开头

Access-Control-Allow-Origin :该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求
Access-Control-Allow-Credentials: 该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。
Access-Control-Expose-Headers:该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。

参考:https://juejin.cn/post/6844903521163182088

这里服务端进行解决:(修改Program.cs文件中的代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
builder.Services.AddSwaggerGen();

//跨域
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
builder.Services.AddCors(options =>
{
options.AddPolicy(name: MyAllowSpecificOrigins,
policy =>
{
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
});

});
var app = builder.Build();




app.UseCors(MyAllowSpecificOrigins);// 启用跨域处理的中间件

测试完成,查看本地存储中的token信息。

11、Loading效果实现

1
2
3
4
5
6
7
8
<el-button
class="loginBtn"
type="primary"
style="width: 100%; margin-bottom: 30px"
@click="submit(formRef)"
:loading="loading"
>登录</el-button
>

el-button组件添加loading属性,关联的是loading对象。

1
2
const router = useRouter();
const loading = ref(false);

创建loading对象,默认值是false

1
2
3
4
5
6
7
return {
ruleForm,
submit,
loginRules,
ruleFormRef,
loading
};

并且将loading返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 // 进行表单的提交
const submit = (loginForm) => {
console.log("loginForm = ",loginForm);
loginForm.validate(async(valid)=>{
if(valid){
loading.value = true; // ---------------------开启loading效果
// console.log(ruleForm.mobile.trim() + " " + ruleForm.password);
const loginForm={
userPhone:ruleForm.mobile,
userPassword:ruleForm.password
}
try{
await store.dispatch("user/userLogin", loginForm);
router.push("/");
}catch(error){
console.log(error)
} finally{
loading.value = false;//----------------关闭loading效果
}

}else{
ElMessageBox.alert("密码没有通过验证!","数据校验",{
confirmButtonText:"OK"
});
}
}
)
};

submit方法中,通过表单校验后将loading设置为true.

不管是登录成功还是失败都需要将loading设置为false,也就是关闭loading的效果。

所以这里在finally中将loading设置为false

返回到浏览器中进行测试。(把浏览器的网络调慢,来查看loading的效果)

在测试的时候,可以输入错误的手机号或者是密码,查看控制台中的输出,这时候服务端返回的状态码是400,所以前端项目也需要针对这种这种进行处理,这里我们没有处理,只是打印到了浏览器的控制台中,大家可以自行完善。

三、主页模块

1、主页的token拦截处理

我们已经完成了登录的过程,并且存储了token,但是此时主页并没有因为token的有无而被控制访问权限,

也就是说,如果用户没有登录,是不能直接访问主页的,但是现在却可以。

所以下面我们看一下,怎样对用户是否登录进行判断,从而决定是否允许访问主页。

通过上图,可以看到整个的校验流程

首先判断是否有token,如果有token说明用户已经登录,但是如果用户又访问了登录页面,这时候会直接跳转到主页,但是如果访问的不是登录页面,而是其他的页面,放行,运行访问。也就是说,用户如果登录了,在访问登录页面,是不会出现登录表单的,直接就进入主页了。

如果没有token信息,说明用户没有登录,这时候,我们就需要看一下用户访问的地址是否是在白名单中,所谓的白名单指的就是不需要登录也能够访问的页面。例如404页面,登录页面,这些页面用户不用登录也可以访问。如果访问的地址是在白名单中,就允许访问。如果访问的地址不在白名单中,说明用户访问的地址只能是登录以后才能访问,而这时候用户又没有登录,所以只能跳转到登录页面,进行登录。

以上就是关于用户是否登录的判断流程。

当输入地址访问页面的时候,会执行路由守卫,所以在路由守卫中进行判断。

下面看一下具体代码的实现。

在项目的src目录下面创建permission.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 权限拦截在路由跳转,导航守卫中完成
import router from "@/router";
import store from "@/store";
const whiteList =["/login","/404"]; // 定义白名单。
// 前置守卫
// next() :表示放行
// next(false):表示路由跳转终止
// next(地址):表示跳转到指定的地址
router.beforeEach((to,from,next)=>{

// 通过keys获取对象中的属性,返回值是一个数组,如果length大于0,表示对象中有属性,而不是一个空对象。
if (store.getters.token!=null&& Object.keys(store.getters.token).length) {
// 如果有token
if (to.path === "/login") {
// 如果访问的是登录页面,跳转到主页
next("/");
} else {
next(); // 放行
}
} else {
// 如果没有登录,也就没有token
if (whiteList.indexOf(to.path) > -1) {
// 表示要去的地址在白名单中,放行
next();
} else {
// 用户没有登录,访问的地址也不在白名单中,跳转到登录页面
next("/login");
}
}
})

同时在main.js文件中导入permission.js,如下所示:

1
2
3
4
5
6
7
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import "./permission" // 导入permission
createApp(App).use(store).use(router).mount('#app')

返回到浏览器中进行测试,按照上图的流程进行测试。

下面添加进度条效果,在路由切换的时候,如果加载比较慢,可以出现进度条。

1
npm i nprogress

继续修改permission.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 权限拦截在路由跳转,导航守卫中完成
import router from "@/router";
import store from "@/store";
import NProgress from "nprogress"; // --------------------引入一份进度条插件
import "nprogress/nprogress.css"; // ------------------------引入进度条样式
const whiteList =["/login","/404"]; // 定义白名单。
// 前置守卫
// next() :表示放行
// next(false):表示路由跳转终止
// next(地址):表示跳转到指定的地址
router.beforeEach((to,from,next)=>{
NProgress.start(); //------------------------------------ 开启进度条
// 通过keys获取对象中的属性,返回值是一个数组,如果length大于0,表示对象中有属性,而不是一个空对象。
if (store.getters.token!=null&& Object.keys(store.getters.token).length) {
// 如果有token
if (to.path === "/login") {
// 如果访问的是登录页面,跳转到主页
next("/");
} else {
next(); // 放行
}
} else {
// 如果没有登录,也就没有token
if (whiteList.indexOf(to.path) > -1) {
// 表示要去的地址在白名单中,放行
next();
} else {
// 用户没有登录,访问的地址也不在白名单中,跳转到登录页面
next("/login");
}
}
});
// 后置守卫
router.afterEach(function () {
NProgress.done(); // -----------------------------关闭进度条
});

在最开始导入进度条插件,同时导入进度条所需要的样式。

在前置守卫中开启进度条

在后置守卫中关闭进度条。

返回到浏览器中进行测试。

2、主页布局实现

src目录下面创建layout目录,在该目录下面创建index.vue组件,该组件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<template>
<el-container class="el-container">
<el-header>
<div class="header-div">
<img src="@/assets/logo.png" alt="logo" />
<span><router-link to="/">后台管理系统</router-link></span>
<el-button style="float: right" type="info">退出</el-button>
</div>
</el-header>
<el-container>
<el-aside :width="isCollapse ? '60px' : '200px'">
<div class="toggle-button" @click="openCollapse">|||</div>
<el-menu
active-text-color="#ffd04b"
background-color="#545c64"
class="el-menu-vertical-demo"
default-active="2"
text-color="#fff"
@open="handleOpen"
@close="handleClose"
>
<el-sub-menu index="1">
<template #title>
<el-icon><location /></el-icon>
<span>Navigator One</span>
</template>
<el-menu-item-group title="Group One">
<el-menu-item index="1-1">item one</el-menu-item>
<el-menu-item index="1-2">item one</el-menu-item>
</el-menu-item-group>
<el-menu-item-group title="Group Two">
<el-menu-item index="1-3">item three</el-menu-item>
</el-menu-item-group>
<el-sub-menu index="1-4">
<template #title>item four</template>
<el-menu-item index="1-4-1">item one</el-menu-item>
</el-sub-menu>
</el-sub-menu>
<el-menu-item index="2">
<el-icon><icon-menu /></el-icon>
<span>Navigator Two</span>
</el-menu-item>
<el-menu-item index="3" disabled>
<el-icon><document /></el-icon>
<span>Navigator Three</span>
</el-menu-item>
<el-menu-item index="4">
<el-icon><setting /></el-icon>
<span>Navigator Four</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 所单击菜单对应的组件在该位置进行展示 -->
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script>
import { ref } from "vue";
export default {
name: "Layout",
setup() {
const isCollapse = ref(false);
function openCollapse() {
isCollapse.value = !isCollapse.value;
}
return {
isCollapse,
openCollapse,
};
},
};
</script>
<style>
.el-container {
height: 1000px;
}
.el-aside {
background-color: #333744;
}
.el-main {
background-color: #eaedf1;
}
.el-header {
background-color: #373d41;
color: #fff;
font-size: 20px;
}
a {
text-decoration: none;
color: #fff;
}

.header-div img {
/* 让文字在图片中间对齐 */
vertical-align: middle;
width: 60px;
height: 60px;
margin-right: 10px;
}
.toggle-button {
background-color: #4a5064;
font-size: 10px;
line-height: 24px;
color: #fff;
text-align: center;
letter-spacing: 0.2em;
cursor: pointer;
}
</style>

在上面的代码中指定了头部区域,左侧的区域。以及中间区域

左侧区域展示菜单,所以使用到了menu组件。

中间区域,会展示当单击了菜单后对应的组件。

同时通过isCollapse控制了左侧区域的显示与隐藏。

路由规则配置:

修改router/index.js文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { createRouter, createWebHashHistory } from 'vue-router'
import Layout from '@/layout' //-------导入了layout组件
const routes = [
{
path: '/',
redirect:'/dashboard',
component: Layout,
children:[ //-----------配置子路由
{
path:'dashboard',
name:'dashboard',
component:()=>import("@/views/dashboard/index"),
meta:{title:"首页",icon:'dashboard'}
}
]
},
{
path:'/login',
name:'login',
component:()=>import("@/views/login/index.vue")
},

在上面的代码中,导入了Layout组件,同时配置了相应的路由规则。

这里配置了子路由。

views目录下面创建dashboard目录,在该目录下面创建index.vue组件,该组件中的初步代码如下所示:

1
<template>后台布局</template>

返回到浏览器中进行查看。

修改一下菜单(可以先将菜单修改成如下的形式)

3、获取用户资料信息-前端实现

在登录成功后,可以将用户的信息返回。

当然,这里我们也可以再查询一次。

在前端login/index.vue组件的的submit方法中,我们可以先将用户的手机号码存储到localStorage中,然后根据手机号查询用户的信息。

1
2
3
4
5
6
7
8
9
try{
await store.dispatch("user/userLogin", loginForm);
localStorage.setItem("userPhone",loginForm.userPhone); // 将用户的手机号存储到localStorage中
router.push("/");
}catch(error){
console.log(error)
} finally{
loading.value = false;
}

下面要做的就是定义前端获取用户信息资料的api接口。

修改api/user.js文件中的代码,如下所示:

1
2
3
4
// 获取用户的基本信息
export function getUserInfo(mobile){
return request("/users?userPhone="+mobile);
}

当调用上面的方法发送请求的时候,请求中需要带上token数据。而我们知道后期会有很多的请求接口都需要带上token信息,所以可以在请求拦截器中添加。修改utils/request.js文件中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import store from '@/store';  // -----导入store
import axios from 'axios'
// 创建一个axios的实例
const service = axios.create({
baseURL:process.env.VUE_APP_BASE_API,
timeout:5000
});

// -----------请求拦截器
service.interceptors.request.use(
(config)=>{
if(store.getters.token){
config.headers["Authorization"]=`Bearer ${store.getters.token}`;// 注意 Bearer后面的空格。
}
// 一定要返回配置信息
return config;
},(error)=>{
return Promise.reject(error);
}
);

问题:在什么地方调用api/user.js文件中的getUserInfo方法发送请求呢?

我们知道,用户信息资料,会在很多组件中使用,所以是共享的数据。那么就应该存储到vuex中,所以需要在actions中发送请求。

下面修改store/modules/user.js文件中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
actions: {
// 用户登录
async userLogin(context, payload) {
const result = await login(payload);
// // axios默认给数据加了一层data
//if (result.data.success) {
context.commit("setToken", result);
// }
},
// -----------------获取用户资料信息
async getUserInfo(context){
const userPhone =localStorage.getItem("userPhone");
const result = await getUserInfo(userPhone);
context.commit("setUserInfo",result);
return result;
}
},

actions中也定义了一个getUserInfo方法(从localStorage中获取用户的手机号,传递给该方法),然后调用api/user.js文件中的getUserInfo方法,发送请求获取数据,将获取到的数据提交到mutations中的setUserInfo这个方法中,完成状态的更新。

1
import { login, getUserInfo } from "@/api/user";

这里需要从api/user.js文件中导入getUserInfo方法。

下面要做的就是在state中定义状态,存储从服务端返回的用户信息。

1
2
3
4
5
6
7
namespaced: true,
state() {
return {
token: getTokenInfo(), // 获取token信息,初始化vuex
userInfo:{} //-----------------存储用户的信息
};
},

在上面的代码中,定义了userInfo这个状态属性,默认值是一个空对象。为什么是空对象,而不是null呢?因为后面我们需要从该状态中获取具体的数据,例如用户名,如果userInfo没有获取到服务端的数据,并且默认值是null,哪么就会变成null.name,这样就出错了。

下面实现对应的mutations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mutations: {
setToken(state, payload) {
state.token = payload; // 将token信息保存到vuex容器中
setTokenInfo(payload); // 将token信息持久化到本地
},
removeToken(state) {
state.token = null; // 清空vuex中的token信息
removeTokenInfo(); // 删除本地token信息
},
// ------------------------保存用户信息
setUserInfo(state,payload){
state.userInfo = payload;
},
// --------------------------------删除用户信息
removeUserInfo(state){
state.userInfo ={};
}

在上面的mutations中定义了setUserInfo方法,将用户信息资料存储到了userInfo这个状态属性中。

同时,添加了一个removeUserInfo的方法,删除用户信息,这个会在后面退出的时候使用到。

下面在getters中创建关于用户信息的快捷访问。

这里我们先创建对用户名的快捷访问。

修改store/getters.js文件中的代码,如下所示:

1
2
3
4
5
const getters = {
token: (state) => state.user.token,
name:(state)=>state.user.userInfo.userName// 访问user模块中的userInfo状态对象中的username属性的值(该属性是服务端返回的)
};
export default getters;

问题:在actions中的getUserInfo方法什么时候调用呢?

在下一小节中将实现对该方法的调用。

4、获取用户资料信息2-前端实现

在这一小节中,我们需要实现的就是对store/modules/user.js文件中定义的getUserInfo这个actions方法进行调用。

我们知道,调用该方法,会发送请求获取用户信息,而且请求头中必须有token信息才可以。

所以在调用getUserInfo这个actions方法的时候,必须确保已经获取到了token信息。

下面修改src/permission.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
router.beforeEach(async(to,from,)=>{
NProgress.start(); // 开启进度条
// 通过keys获取对象中的属性,返回值是一个数组,如果length大于0,表示对象中有属性,而不是一个空对象。
if (store.getters.token!=null&& Object.keys(store.getters.token).length) {
// 如果有token
if (to.path === "/login") {
// 如果访问的是登录页面,跳转到主页
next("/");
} else {
// -------------------判断是否能够获取到用户的编号
if(!store.state.user.userInfo.Id){
await store.dispatch("user/getUserInfo");
}
next(); // 放行
}
} else {

如果有token信息了,才会去获取用户的信息资料

同时是在用户访问其它页面的时候判断一下是否能够获取到用户的编号(Id是服务端返回的属性),如果能够获取到说明vuex中已经存储了用户的信息资料,这里就没有必要发送请求了。

否则调用user模块中的getUserInfo这个actions方法进行请求的发送,获取用户的信息资料。

同时还需要注意一个问题,这里我们添加了await,含义就是一定是等待获取到了用户信息资料以后,才会执行next()方法,访问其它页面。

原因是,在其它页面中有可能会使用用户信息资料。如果这里不添加await,就会导致请求还没有结束,就执行了next方法,请求是异步的。

这里还有一个问题,就是用户的资料信息为什么没有存储到本地的localStorage中呢?

因为,我们是在beforeEach方法中发送请求,不管是刷新浏览器还是切换路由,都会重新发送请求来获取用户的资料信息,所以不需要存储到本地的localStoreage中。

下面,在头部展示用户名

1
2
3
4
<span><router-link to="/">HR后台管理系统</router-link></span>
<span style="font-size: 16px; float: right">{{
$store.getters.name
}}</span>

返回到浏览器中进行测试。

这里测试的时候,有可能token已经失效了,所以将本地存储的token信息删除,然后重新登录,监视请求。

5、获取用户资料信息3–服务端接口实现

下面我们需要实现的就是服务端获取用户资料信息的接口。

创建UsersController.cs控制器,该控制器中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

[Route("api/[controller]")]
[ApiController]
public class UsersController : ControllerBase
{
private IUserInfoService _userInfoService;
public UsersController(IUserInfoService userInfoService)
{
this._userInfoService = userInfoService;
}

[HttpGet]
[Authorize]
public async Task<IActionResult> GetUserInfo([FromQuery]string userPhone)
{
if (string.IsNullOrWhiteSpace(userPhone))
{
return BadRequest("手机号不能为空");
}
var user = await _userInfoService.LoadEntities(u => u.UserPhone == userPhone).FirstOrDefaultAsync();
if(user == null)
{
return BadRequest("没有查找到对应用户");
}
return Ok(new ApiResult<UserInfo>() { Success = true, Message = "获取用户成功", Data = user });
}
}

接收前端传递过来的手机号码

然后根据手机号码查询用户的信息。

返回到浏览器中进行测试。

这里测试的时候,有可能token已经失效了,所以将本地存储的token信息删除,然后重新登录,监视请求。

当登录成功以后,可以看到顶部展示了用户名

下面将头像也展示出来。

6、展示用户头像

这里我们已经将登录的用户名展示出来了,下面展示用户的头像

第一:服务端设置

WebApi项目中创建wwwroot目录,在该目录下面创建images文件夹,上传成功的图片都是存储在服务端的,也就是当前的images文件夹中。

1
2
3
4
5
app.UseCors(MyAllowSpecificOrigins);

app.UseStaticFiles();// 处理静态资源

app.UseAuthentication();

同时在Program.cs文件中启用处理静态资源文件的中间件。

一定要将服务端项目重新启动。

第二:前端设置

我们知道,现在在前端已经获取到了登录成功的用户资料信息。

在用户资料信息中有一个属性是photoUrl,存储的是用户的头像地址。

所以这里,我们先修改store/getter.js文件中的代码,如下所示:

1
2
3
4
5
6
const getters = {
token: (state) => state.user.token,
name:(state)=>state.user.userInfo.userName,
photoUrl:(state)=>state.user.userInfo.photoUrl // 获取头像地址,建立用户头像的快捷访问,注意photoUrl是服务端返回的属性
};
export default getters;

这里了能够更好的获取用户的头像,可以把获取用户头像 的代码写到getters中,也就是建立getters的快捷访问的方式。

这里从user模块中找到userInfo对象,获取photoUrl属性的值,也就是头像的路径地址。

返回到layout/index.vue组件中,在头部区域展示登录成功的用户头像。

但是,问题是,我们这里获取到的photoUrl属性的值是数据库中存储的头像路径的地址,是相对路径,例如:/images/f.jpg

而具体的图片文件是存储在服务端的,所以这里我们需要添加上服务端的地址,才能获取到真正的图片文件。

.env.development文件,如下所示:

1
2
3
4
5
port = 8888

# 开发环境的基础地址和代理对应
VUE_APP_BASE_API = 'http://localhost:5105/api'
VUE_APP_BASE_URL='http://localhost:5105' # --------------这里通过该值获取服务端的地址,

注意:一定要以VUE_APP_开头。

同时,.env.production文件中也添加该项

1
2
3
# 生产环境的基础地址和代理对应
VUE_APP_BASE_API = '/api'
VUE_APP_BASE_URL='http://localhost:5105'

下面返回到layout/index.vue中进行代码的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { ref } from "vue";
import { useStore } from "vuex"; // 导入useStore
export default {
name: "Layout",
setup() {
const isCollapse = ref(false);
const store = useStore(); // 创建store仓库对象
const baseUrl = process.env.VUE_APP_BASE_URL; // 获取图片所在的服务端地址
console.log("baseUrl=",baseUrl);
const imgUrl = baseUrl+store.getters.photoUrl; // 拼接完整的地址(这里直接通过Store容器对象获取getter中的photoUrl)
function openCollapse() {
isCollapse.value = !isCollapse.value;
}
return {
isCollapse,
openCollapse,
imgUrl // 将拼接好的图片地址返回。
};

下面看一下模版代码的修改

1
2
3
4
5
<span style="font-size: 16px;color:darkred; float: right">{{
$store.getters.name
}}</span>
<img :src="imgUrl" style="float: right" /> <!----添加img标签,其src属性动态绑定了imgUrl属性的值--->
<el-button style="float: right" type="info">退出</el-button>

注意:由于前端项目也修改了配置文件,也需要重新启动前端项目

返回浏览器中查看效果。

7、用户退出登录

在用户单击了退出按钮后,进行退出。

退出的时候需要删除用户的token以及vuex中存储的当前登录用户的信息。

修改src/store/modules/user.js文件中的代码

1
2
3
4
5
6
7
8
9
// 用户退出
logout(context){
// 删除token信息
context.commit("removeToken");
// 删除用户信息
context.commit("removeUserInfo");
// 删除本地存储的手机号
localStorage.removeItem("userPhone");
}

actions中定义了logout方法。

在该方法中提交了removeToken这个mutations方法,删除了token信息,这里不仅删除了vuex中存储的token信息,同时也删除了本地存储的token信息,以及本地存储的手机号码信息

关于removeToken这个mutations方法,在前面我们已经定义好了。

下面紧跟着提交了removeUserInfo这个mutations方法,删除了用户的资料信息,该方法在前面也已经定义了。

问题是logout这个actions方法在哪调用。

下面修改layout/index.vue组件中的代码

1
2
<img :src="imgUrl" style="float: right" />
<el-button style="float: right" type="info" @click="logout">退出</el-button>

当单击了退出按钮的时候,调用上面所定义的logout这个actions方法。

所以这里我们先给退出按钮注册了单击事件,事件触发调用logout方法,下面在setup入口函数中实现该方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { ref } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router"; //------------------ 导入useRouter方法
export default {
name: "Layout",
setup() {
const isCollapse = ref(false);
const store = useStore();
const router = useRouter(); // ----------------------------获取router实例
const baseUrl = process.env.VUE_APP_BASE_URL;
console.log("baseUrl=",baseUrl);
const imgUrl = baseUrl+store.getters.photoUrl;
function openCollapse() {
isCollapse.value = !isCollapse.value;
}
//------------------------ 用户退出登录
const logout=()=>{
ElMessageBox.confirm("确定要退出系统吗?","退出系统",{
confirmButtonText:"确定",
cancelButtonText:"取消",
type:"questions"
}).then(()=>{ // ------------------ // 单击`确定`按钮会执行then
// ---------------- // 执行了user模块中`actions`中定义的`logout`方法
// 由于在user模块中的logout这个`actions`方法中没有进行异步操作,所以这里不需要添加await
store.dispatch("user/logout");
// 进行跳转,跳转到登录页面。
router.push("/login");
ElMessage({type:'success',message:'退出成功'});
})
.catch(()=>{ //------------------单击`取消`按钮会执行catch
ElMessage({
type:'info',
message:'取消退出'
})
})
}
return {
isCollapse,
openCollapse,
imgUrl,
logout
};
},

进行测试。

8、Token失效的处理

服务端处理

问题:我们知道在登录的时候,我们创建的token数据,并且设置了过期时间,问题是,怎样判断token过期了呢?

这里我们需要注册事件,修改Program.cs文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 注入jwt的认证服务(这里采用默认的认证)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
// 获取配置文件中存储的密钥
var secretByte = Encoding.UTF8.GetBytes(builder.Configuration["Authentication:SecretKey"]!);
options.TokenValidationParameters = new TokenValidationParameters()
{
// 验证token的发布者
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Authentication:Issuer"],
// 验证token的持有者
ValidateAudience = true,
ValidAudience = builder.Configuration["Authentication:Audience"],
// 验证toen是否过期
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
// 使用私钥
IssuerSigningKey = new SymmetricSecurityKey(secretByte)

};
// --------------------------------------jwt 过期后触发该事件。
options.Events = new JwtBearerEvents {
// 授权失败
OnAuthenticationFailed = context =>
{
// 错误的类型是token过期
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
/* context.Response.Headers.Add();*/

context.Response.Headers.Add("Access-Control-Expose-Headers", "act");
context.HttpContext.Response.Headers.Add("act", "expired");
context.Response.Headers.AccessControlAllowOrigin = "*"; // 防止出现跨域的问题
}
return Task.CompletedTask;
}
};

});

在上面的代码中,当授权失败以后,判断错误的类型是否是token过期,如果是token过期,这里会向前端返回act这个响应头,该响应头的值是expired,当然,该响应头大家可以随意命名。目的是,将自定义act这个响应头返回到前端以后,前端获取到该响应头并判断其值是expired,表示的是token过期了,这样前端路由就可以跳转到登录页面。

但是这里有一个问题:要想前端的axios获取到自定义的响应头act,需要添加Access-Control-Expose-Headers,它的值是act这个自定义的响应头,这样前端的axios就可以过去该自定义的响应头了。

为了进行测试,我们修改一下LoginController这个控制器中创建的token的过期时间,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var token = new JwtSecurityToken(
// 谁发布的token数据,一般是服务端的地址
issuer: configuration["Authentication:Issuer"],
// 把token数据发布给谁,一般就是前端项目,这里也可以填写服务端的地址,或者不填写也可以
audience: configuration["Authentication:Audience"],
claims,
// 发布时间
notBefore: DateTime.Now,
// 有效期
expires: DateTime.Now.AddSeconds(30), //--------------------这里为了进行测试,将过期时间设置为了30秒
// 数字签名
signingCredentials

);

这里关于过期时间在强调一下,如果不设置token的过期时间,默认过期时间是5分钟,当然,即使过期的时间到了,也不会马上失效,而是再过5分钟才会失效。

但是,这里我们为了方便测试,在Program.cs中修改了配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 注入jwt的认证服务(这里采用默认的认证)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
// 获取配置文件中存储的密钥
var secretByte = Encoding.UTF8.GetBytes(builder.Configuration["Authentication:SecretKey"]!);
options.TokenValidationParameters = new TokenValidationParameters()
{
// 验证token的发布者
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Authentication:Issuer"],
// 验证token的持有者
ValidateAudience = true,
ValidAudience = builder.Configuration["Authentication:Audience"],
// 验证toen是否过期
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero, // ------------------------这里将ClockSkew属性的值设置为了0时,表示token有效时间到期后,立马生效。
// 使用私钥
IssuerSigningKey = new SymmetricSecurityKey(secretByte)

};

在上面的jwt的认证配置中,我们添加了ClockSkew属性,该属性的值设置为了TimeSpan.Zero

表示的含义就是token到期后就失效,没有缓冲期。

以上是服务端的配置。

下面看一下前端代码的修改

前端处理

修改utils/request.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 响应拦截器
service.interceptors.response.use(
(response) => {
// axios 默认添加了一层data
// console.log('data =',response.data);
const { success, message, data } = response.data;

if (success) {
return data;
} else {
// 业务已经错误了(例如输入的手机号或者是密码是错误的),需要给出错误提示,并且应该进入catch
ElMessage.error(message);
return Promise.reject(new Error(message));
}
},
(error) => { //-----------------错误的处理
if(error.response&& error.response.headers["act"]&&error.response.headers["act"]==='expired'){
store.dispatch("user/logout"); // 退出操作
router.push("/login");
}else{
ElMessage.error(error.message);
}
return Promise.reject(error); // 返回执行错误,让当前的执行链跳出成功,直接进行入catch
});

在上面的代码中,我们修改了响应拦截器中的代码

如果服务端返回的内容有错误,会执行响应拦截器中错误的处理程序,在这里判断error.response对象中是否有act这个响应头,并且该响应头的值是否是expired,如果条件成立,则表示服务端的token信息过期了,这里我们需要退出登录,并且重新跳转到登录页面。

如果以上条件不满足,这里我们只是通过ElMessage这个组件展示错误信息就可以了。

启动项目进行测试,这里可以先将以前登录所保存的localStorage中信息删除,重新进行登录后,等待30秒钟后重新刷新浏览器进行测试。

四、路由与菜单模块

1、路由说明

简单项目中的路由设置

当前项目结构

为什么要拆成若干个路由模块呢?

因为复杂项目的页面众多,不可能把所有的业务都集中在一个文件上进行管理和维护,并且还有最重要的,前端的页面中主要分为两部分,一部分是所有人都可以访问的, 一部分是只有有权限的人才可以访问的,拆分多个模块便于更好的控制

这里我们将所有用户都可以访问的路由称作静态路由

而具有了一定的权限才能访问的路由就是动态路由

**注意**这里的动态路由并不是 路由传参的动态路由

最后,在路由中把404规则添加上。

修改router/index.js文件中的路由规则,如下 所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
path: "/login",
name: "Login",
component: () => import("@/views/login/index.vue"),
},
{
path: "/404",
component: () => import("../views/404.vue"),
},
{
path: "/:pathMatch(.*)*", // 没有其他的路由规则相匹配
redirect: "/404",
},

在上面的代码中,需要指定404路由规则。

views目录下面创建404.vue

返回到浏览器中进行测试。

2、前端项目模块说明

下面,我们将本项目中所要做的模块快速搭建相应的页面和路由

1
2
3
4
5
6
7
8
9
10
11
├── dashboard           # 首页
├── login # 登录
├── 404 # 404
├── departments # 组织架构
├── employees # 员工
├── setting # 公司设置
├── salarys # 工资
├── social # 社保
├── attendances # 考勤
├── approvals # 审批
├── permission # 权限管理

根据上图中的结构,在views目录下,建立对应的目录,给每个模块新建一个**index.vue**,作为每个模块的主页

快速新建文件夹

1
mkdir departments employees setting salarys social attendances approvals permission

注意:找到项目中的views文件夹,按住ctrl键右击,在弹出的快捷菜单中选择Git Bash Here,执行以上创建文件夹的命令。(在win11中以上命令有问题)

每个模块的内容,可以先按照标准的模板建立,如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div class="dashboard-container">
<div class="app-container">
<h2>
员工
</h2>
</div>
</div>
</template>

<script>
export default {

}
</script>

<style>

</style>

根据以上的标准建立好对应页面之后,接下来建立每个模块的路由规则

路由模块目录结构

1
2
3
4
5
6
7
8
9
10
11
├── router               # 路由目录
├── index.js # 路由主文件
├── modules # 模块目录
├── departments.js # 组织架构
├── employees.js # 员工
├── setting.js # 公司设置
├── salarys.js # 工资
├── social.js # 社保
├── attendances.js # 考勤
├── approvals.js # 审批
├── permission.js # 权限管理

3、设置每个模块的路由规则

每个模块导出的内容表示该模块下的路由规则

如果员工的路由规则:router/modules/employees.js文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 导出员工的路由规则
import Layout from "@/layout";
export default {
path: "/employees", // 路由地址
name: "employees", // 给模块的一级路由设置一个name属性,后面设置权限的时候会使用到(注意这里如果只是给父级添加name属性在浏览器的控制台中会给出警告信息,为了解决这个问题,也需要在下面的子路由中添加name,当然这里可以先暂时的将name属性去掉,后面讲解到权限的时候在添加)
// 都采用了Layout组件中的布局
component: Layout,
children: [
{
path: "", // 这里写成一个空字符串,当访问的地址是`/employees`的时候,不但会展示layout布局页面,而会展示下面component中指定的页面
component: () => import("@/views/employees"),
// 路由元信息,可以存储任何的数据
meta: {
title: "员工管理", // 这里保存的是title,目的:会读取该属性作为左侧的菜单项。
},
},
],
};

上述代码中,我们用到了meta属性,该属性为一个对象,里面可放置自定义属性,主要用于读取一些配置和参数,并且值得**注意的是:我们的meta写在了二级默认路由上面,而不是一级路由,因为当存在二级路由的时候,访问当前路由信息访问的就是二级默认路由**

4、路由合并

将静态路由和动态路由的路由表进行临时合并

什么叫临时合并?

在第一个小节中,我们讲过了,动态路由是需要权限进行访问的,但是权限的动态路由访问是很复杂的,我们稍后在进行讲解,所以为了更好地看到效果,我们可以先将 静态路由和动态路由进行合并

修改src/router/index.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { createRouter, createWebHashHistory } from 'vue-router'
import Layout from '@/layout'
import employeesRouter from './modules/employees' // -----------------导入员工的路由信息
const routes = [
{
path: '/',
redirect:'/dashboard',
component: Layout,
children:[
{
path:'dashboard',
name:'dashboard',
component:()=>import("@/views/dashboard/index"),
meta:{title:"首页",icon:'dashboard'}
}
]
},
{
path:'/login',
name:'login',
component:()=>import("@/views/login/index.vue")
},
{
path: "/404",
component: () => import("../views/404.vue"),
},
{
path: "/:pathMatch(.*)*", // 没有其他的路由规则相匹配
redirect: "/404",
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
}
]
// -------------------------// 动态路由(将导入的动态路由规则对象放到数组中)
export const asyncRoutes =[
employeesRouter
];
const router = createRouter({
history: createWebHashHistory(),
routes:[...routes,...asyncRoutes] // 把routes数组中存储的静态路由规则对象与asyncRoutes数组中存储的动态路由规则对象进行合并。
})

export default router

首先导入模块中定义的动态路由规则

然后定义一个asyncRoutes数组,数组中存储的就是定义的动态路由规则对象,

createRouter中将静态路由规则和动态路由规则进行合并。

可以在浏览器中输入:http://localhost:8888/#/employees来进行访问。

5、菜单展示

在这一小节中,我们将所有路由规则中的meta中的title属性的值获取到,然后将其作为左侧的菜单项。

先修改router/index.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const routes = [
{
path: '/',
redirect:'/dashboard',
component: Layout,
children:[
{
path:'dashboard',
name:'dashboard',
component:()=>import("@/views/dashboard/index"),
meta:{title:"首页",icon:'dashboard'}
}
]
},
{
path:'/login',
name:'login',
hidden: true, // -------------------------------------hiddren属性值为true
component:()=>import("@/views/login/index.vue")
},
{
path: "/404",
hidden: true, // ----------------------------------------hiddren属性值为true
component: () => import("../views/404.vue"),
},
{
path: "/:pathMatch(.*)*", // 没有其他的路由规则相匹配
redirect: "/404",
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
}
]

在上面定义的静态路由规则中,每个路由规则都添加了hiddren属性,并且该属性的取值为true.

设置为true的目的表示该项不会在左侧进行展示,也就是不是菜单项。

下面修改src/layout/index.vue组件中的代码,如下所示:

1
2
3
4
5
setup() {
const isCollapse = ref(false);
const store = useStore();
const router = useRouter();
let routes = ref([]);// -----定义routes数组,用来存储路由规则的信息

setup入口函数中,定义routes数组,该数组中会存储获取到的路由规则信息

1
2
3
4
5
6
7
8
9
10
11
12
   import { ref,computed } from "vue"; // 这里需要导入computed
routes = computed(()=>{
return router.options.routes;
})
// console.log("routes = ",routes);
return {
isCollapse,
openCollapse,
imgUrl,
logout,
routes // 返回
};

这里添加了计算属性,在计算属性中通过router对象中的options中的routes属性获取对应的路由规则信息,然后将这些信息存储到了rotues这个数组中,并且返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<el-menu
active-text-color="#ffd04b"
background-color="#545c64"
class="el-menu-vertical-demo"
default-active="2"
text-color="#fff"
@open="handleOpen"
@close="handleClose"
>
<template v-for="route in routes" >
<el-menu-item v-if="!route.hidden && route.children" :index="route.path" >
<el-icon><icon-menu /></el-icon>
<span>
<router-link :to="route.path">
{{ route.children[0].meta.title }}
</router-link>
</span>
</el-menu-item>

</template>
</el-menu>

这里在el-menu-item上面添加了template标签,对routes进行遍历,然后在el-menu-item组件上添加判断,判断hidden是否为false,并且是否有children,该条件满足,展示router-link组件,并且该组件中展示的就是路由规则中的title属性的值

返回到浏览器中进行查看。

6、菜单图标处理

第一:在src目录下面创建icons目录,在该目录下面创建svg目录,将图标拷贝到该目录下面。

第二:修改src/router/index.js文件中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
path: '/',
redirect:'/dashboard',
component: Layout,
children:[
{
path:'dashboard',
name:'dashboard',
component:()=>import("@/views/dashboard/index"),
meta:{title:"首页",icon:'dashboard'}
}
]
},

这里给首页的meta中添加了icon属性,该属性的取值就是图标的名称

下面需要给router/modules目录下面的所有的模块中的meta都添加icon属性,指定图标的名称。

对应的关系如下所示(模块对应的icon图标名称):

1
2
3
4
5
6
7
8
9
├── dashboard           # dashboard
├── departments # tree
├── employees # people
├── setting # setting
├── salarys # money
├── social # table
├── attendances # skill
├── approvals # tree-table
├── permission # lock

第三:在src/icons/目录下面创建index.js文件

该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 下面三行代码的任务是 把 同级目录的 svg目录下的.svg图片引入到项目中来
// 第一个参数表示要查找的目录地址
// 第二个参数:表示是否查找子目录
// 第三个参数:正则表达式,匹配对应的文件
const req = require.context("./svg", false, /\.svg$/);
const requireAll = (requireContext) => {
const result = requireContext.keys().map(requireContext);
return result;
};

requireAll(req);

// 相当于把svg下的所有的svg图片打包到了项目中
// 如果想用svg图片 就在svg目录下去寻找就可以了

什么时候执行上面的代码,把svg目录下的图标文件打包到项目中呢?

main.js这个入口文件中。

修改main.js文件中的代码如下所示:

1
2
3
import store from './store'
import "./permission"
import "@/icons"; // ------------icon

导入ioncs,这时候会执行该目录下的index.js文件

第四:创建图标组件

在项目中需要多次使用图标,可以将其单独的封装成一个公共的组件。

所以在src/components目录下面创建SvgIcon目录,在该目录下面创建index.vue文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<template>
<svg :class="className" aria-hidden="true">
<use :xlink:href="'#icon-' + `${iconClass}`" />
</svg>
</template>

<script>
// import { computed } from "vue";
export default {
name: "SvgIcon",
props: {
iconClass: {
type: String,
required: true,
},
className: {
type: String,
default: "svg-icon",
},
},

};
</script>

<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}

.svg-external-icon {
background-color: currentColor;
mask-size: cover !important;
display: inline-block;
}
</style>

当使用上面的图标组件的时候可以传递className,如果不传递默认是svg-icon.

该组件中的重点就是使用了svg标签,然后通过内部的标签use标签的指定要插入的图标。这里通过iconClass接收传递过来的图标名称。

问题:怎样使用该图标组件?

src/layout/index.vue文件中使用该图标组件。原因是在layout中使用了菜单组件。

思考:由于公共组件使用比较频繁,如果每次使用都导入比较麻烦,所以可以完成统一的注册。

src/components目录下面创建index.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
import SvgIcon from "@/components/SvgIcon";
export default {
install(app) {
app.component(SvgIcon.name, SvgIcon);
},
};

这里导入了图标组件,并且导入一个对象,该对象中有一个install方法(注意必须是install方法),这里相当于做了一个插件,插件可以扩展vue的功能。而插件中必须有install方法,参数是应用实例。这里调用了应用实例中的component方法,进行组件的注册。第一个参数表示要注册的组件的名称,这里获取了其name属性,所以要求定义公共组件的时候,一定要有name名称。第二个参数就是要注册的组件。

问题:以上install方法什么时候被调用呢?

main.js文件中进行代码的修改

1
2
3
4
import "@/icons"; 
import UI from "@/components/index";
createApp(App).use(store).use(router).use(UI).mount('#app')

这里导入了components/index,然后use到了app这个应用实例上。

这样到项目启动后,公共组件都挂载到了app这个应用实例上了,也就是完成了组件的注册。

下面使用该图标组件。

src/layout/index.vue文件中使用以上图标组件。

1
2
3
4
5
6
7
8
9
10
11
12
<template v-for="route in routes" >
<el-menu-item v-if="!route.hidden && route.children" :index="route.path" >
<el-icon><SvgIcon :iconClass="route.children[0].meta.icon" <!--使用SvgIcon组件--->
/></el-icon>
<span>
<router-link :to="route.path">
{{ route.children[0].meta.title }}
</router-link>
</span>
</el-menu-item>

</template>

layout/index.vue中不需要导入图标组件,直接使用就可以了。

这里指定了inconClass,属性。其值就是所获取到的在路由规则中定义的meta对象中的icon属性。

第五:配置loader

这里需要安装第三方包,解析svg文件

1
npm install svg-sprite-loader 

修改vue.config.js文件中的配置:

代码如下所示:

1
2
3
4
5
6
const { defineConfig } = require('@vue/cli-service')
const path =require("path");
function resolve(dir){
// __dirname:获取绝对路径
return path.join(__dirname,dir)
}

导入path,定义resolve方法,拼接目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module.exports = defineConfig({
transpileDependencies: true,
lintOnSave: false,
chainWebpack: (config) => {
// 第一项配置
config.plugin("html").tap((args) => {
// console.log("args===", args);
// // args相当于 html模板中 htmlWebpackplugin.options
args[0].title = "人力资源管理平台"; // 这里修改了网站的标题
return args;
});
// 第二项配置
config.module.rule("svg").exclude.add(resolve("src/icons")).end();
config.module
.rule("icons")
.test(/\.svg$/)
.include.add(resolve("src/icons"))
.end()
.use("svg-sprite-loader")
.loader("svg-sprite-loader")
.options({
symbolId: "icon-[name]", // 注意这里添加了`icon`前缀,所以在定义图标组件的时候 <use :xlink:href="'#icon-' + `${iconClass}`" />这里也添加了icon前缀,注意#不能省略。
})
.end();


},

chainWebpack中完成对svg的配置。

由于修改了配置文件需要重新启动项目

返回到浏览器中进行测试。

五、组织架构模块

1、组织架构简介

2、组织架构头部布局

通过上图可以看到,头部分为两部分,左侧是公司的名称,右侧是负责人

这里我们可以使用element-plus中的Layout布局组件进行布局。

修改src/views/departments/index.vue文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div class="dashboard-container">
<div class="app-container">
<!-- 组织架构--头部布局 -->
<el-card>
<el-row>
<el-col :span="12"> <!--span表示宽度-->
<span>xx教育集团</span>
</el-col>
<el-col :span="12">123</el-col>
</el-row>
</el-card>
</div>
</div>
</template>

el-card就是一个div

el-row:表示行

el-col表示列

上面一行中分为了两部分

但是以上123的内容没有在右侧。

下面,我们查看一下该页面的效果。

需要配置路由, 按照前面的方式进行配置

src/router/modules目录下面创建departments.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 导出部门的路由规则
import Layout from "@/layout";
export default {
path: "/departments", // 路由地址
name: "departments", //
component: Layout,
children: [
{
path: "", // 这里写成一个空字符串,当访问的地址是`/permission`的时候,不但会展示layout布局页面,而会展示下面component中指定的页面
name:'departmentsChild',
component: () => import("@/views/departments"),
// 路由元信息,可以存储任何的数据
meta: {
title: "部门管理", // 这里保存的是title,目的:会读取作该属性作为左侧的菜单项。
icon:"tree" // 部门显示的图标
},
},
],
};

下面在src/routerindex.js文件中添加如下代码

1
2
3
import Layout from '@/layout'
import employeesRouter from './modules/employees'
import departmentsRouter from './modules/departments' // 导入departmentsRouter
1
2
3
4
5
// 动态路由
export const asyncRoutes =[
employeesRouter,
departmentsRouter // 添加departmentsRouter
];

返回到浏览器中查看效果。

下面需要指定对齐方式

这里给第二列又添加了el-row指定了end对齐方式。在右侧展示内容。justify属性只能用在el-row组件上。

右侧内容又分为两列。展示负责人以及下拉框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!-- 组织架构--头部布局 -->
<el-card>
<el-row>
<el-col :span="20"> <!--这里调整了宽度-->
<span>xx教育集团</span>
</el-col>
<el-col :span="4"><!--这里调整了宽度-->
<el-row justify ="end">
<el-col :span="12">负责人</el-col>
<el-col :span="12">
<el-dropdown>
<span class="el-dropdown-link">
操作
<el-icon class="el-icon--right">
<ArrowDown />
</el-icon>
</span>
<!-- 下拉菜单 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>添加子部门</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-col>
</el-row>
</el-col>
</el-row>
</el-card>

这里在操作旁边使用了ArrowDown图标。

这里需要安装图标组件

1
npm install @element-plus/icons-vue

然后在main.js文件中进行图标的注册。(参考文档:’https://element-plus.gitee.io/zh-CN/component/icon.html‘)

1
2
3
4
5
6
7
8
9
import UI from "@/components/index";
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(store).use(router).use(UI).mount('#app')

在上面的代码中使用到了dropdown组件,官方地址如下:

1
https://element-plus.gitee.io/zh-CN/component/dropdown.html#%E5%9F%BA%E7%A1%80%E7%94%A8%E6%B3%95

3、树形组件基本使用

通过前面的介绍,我们知道在展示组织结构信息时需要用到树形组件,这里我们先来看一下树形组件的基本使用

1
https://element-plus.gitee.io/zh-CN/component/tree.html

修改views/departments/index.vue文件中的代码

1
2
3
4
  </el-row>
<!-- 添加树形组件 -->
<el-tree :data="data" :props="defaultProps" />
</el-card>

在原有的el-row组件下面添加了el-tree这个组件

data属性表示该树形组件所展示的数据。

数据的定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script>
import { reactive } from "vue";
export default {
setup() {
const data = reactive([
{
label: "hello",
children: [
{
label: "hello1",
},
],
},
]);
return {
data,
};
},
};
</script>

这里定义了响应式对象data.

该对象的默认值是一个数组,数组中存储了一个对象,包含了label(表示树形结构中展示的文本节点),以及children属性(该属性也是一个数组,表示子节点)。

返回到浏览器中查看效果。

但是如果将上面的数据修改成如下的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
const data = reactive([
{
title: "hello",
child: [
{
title: "hello1",
},
],
},
]);
return {
data,
};

这里的将原来的label修改成了title,将原来的children修改成了child,返回到浏览器中,发现不展示对应的树形结构了。

如果想展示需要定义props属性的值defaultProps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
setup() {
const data = reactive([
{
title: "hello",
child: [
{
title: "hello1",
},
],
},
]);
const defaultProps = {
children: "child",
label: "title",
};
return {
data,
defaultProps,
};
},

上面定义了defaultProps对象,该对象中添加了label属性和children属性。

label属性的取值为title,表示展示的文本节点来自title

children属性的取值为child,表示子节点来自child

返回到浏览器中进行测试,发现树形结构可以展示。

注意:defaultProps对象中的label属性默认值是labelchildren属性默认值是children.

所以我们也可以不在树形组件中添加props属性并且指定其值为defaultProps

只需要在创建数据结构的时候,使用labelchildren就可以了。

4、树形组件构建组织架构

在上一小节中,我们了解了树形组件的基本使用。

但是结构不是我们想要的,这里会使用到树形组件中的自定义节点内容,地址如下:

1
https://element-plus.gitee.io/zh-CN/component/tree.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E8%8A%82%E7%82%B9%E5%86%85%E5%AE%B9

树形结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- 添加树形组件 -->
<!--这里的data关联的数据是departs,同时添加default-expand-all属性,让树形默认全部展开-->
<el-tree :data="departs" :props="defaultProps" default-expand-all>
<template #default="{ node, data }"> <!--解构出传递给作用域插槽中的数据,data是每个节点的数据对象--->
<el-row style="height: 40px; width: 100%" align="middle"><!--这里展示的结构与头部类似,所以将头部中使用的el-row直接拷贝到当前的树形组件中,然后进行调整。同时这里设置了el-row的高度与宽度 以及垂直居中--->
<el-col :span="20">
<span>{{ data.name }}</span>
</el-col>
<el-col :span="4">
<el-row justify="end">
<el-col :span="12">{{ data.manager }}</el-col>
<el-col :span="12">
<el-dropdown>
<span class="el-dropdown-link">
操作
<el-icon class="el-icon--right">
<ArrowDown />
</el-icon>
</span>
<!-- 下拉菜单 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>添加子部门</el-dropdown-item>
<el-dropdown-item>编辑部门</el-dropdown-item>
<el-dropdown-item>删除部门</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-col>
</el-row>
</el-col>
</el-row>
</template>
</el-tree>

数据结构调整成如下的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default {
setup() {
const departs = reactive([
{
name: "总裁办",
manager: "曹操",
children: [{ name: "董事会", manager: "曹丕" }],
},
{ name: "行政部", manager: "刘备" },
{ name: "人事部", manager: "孙权" },
]);
const defaultProps = {
children: "children",
label: "name",
};
return {
departs,
defaultProps,
};
},
};

返回到浏览器中进行测试

5、将树形结构单独封装成组件

通过上一小节,我们发现整个组织架构的顶部与具体内容展示的结构是一样的,所以这里可以单独的抽离出一个组件。

src/views/departments目录下面创建一个components目录,在该目录下面创建tree-tools.vue组件。

该组件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<el-row style="height: 40px; width: 100%" align="middle">
<el-col :span="20">
<span>{{ treeNode.name }}</span>
</el-col>
<el-col :span="4">
<el-row justify="end">
<el-col :span="12">{{ treeNode.manager }}</el-col>
<el-col :span="12">
<el-dropdown>
<span class="el-dropdown-link">
操作
<el-icon class="el-icon--right">
<ArrowDown />
</el-icon>
</span>
<!-- 下拉菜单 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>添加子部门</el-dropdown-item>
<el-dropdown-item>编辑部门</el-dropdown-item>
<el-dropdown-item>删除部门</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-col>
</el-row>
</el-col>
</el-row>
</template>
<script>
// 该组件需要对外开放属性 外部需要提供一个对象 对象里需要有name manager
export default {
name: "TreeTools",
props: {
// 定义一个props属性
treeNode: {
type: Object, // 对象类型
required: true, // 要求对方使用您的组件的时候 必须传treeNode属性 如果不传 就会报错
},
},
};
</script>

这里我们将el-row组件中的内容拷贝过来了。

同时定义了props接收传递过来的数据。

下面看一下该组件的使用。

在**src/views/departments/index.vue**进行代码的简化

1
2
3
4
5
6
import { reactive, ref } from "vue";
import TreeTools from "./components/tree-tools.vue";
export default {
components: {
TreeTools,
},

导入TreeTools组件,并且完成注册。

1
2
3
4
5
6
<!-- 添加树形组件 -->
<el-tree :data="departs" :props="defaultProps" default-expand-all>
<template #default="{ node, data }">
<TreeTools :treeNode="data"></TreeTools>
</template>
</el-tree>

el-tree中使用了TreeTools组件,并且传递了具体的数据。

下面,我们来看一下组织架构中顶部的改造,顶部也使用TreeTools这个组件。

只不过顶部需要传递公司的信息。

views/deparments/index.vue中修改代码

1
2
3
4
5
setup() {
const company=ref({
name:"xxx教育集团",
manager:"负责人"
})

setup入口函数中定义了company 对象,该对象中存储了公司名称

1
2
3
4
5
return {
departs,
defaultProps,
company // 返回公司信息
};

company返回到模板中

1
2
3
4
5
<div class="app-container">
<!-- 组织架构--头部布局 -->
<el-card>
<TreeTools :treeNode="company" :isRoot="true" ></TreeTools>
<!-- 添加树形组件 -->

这里将company作为treeNode的属性值,传递到TreeTools这个组件中。

这里同时给isRoot属性值为true

原因是:在上下两个位置都使用了下拉框组件,但是放置在最上层的组件是不需要显示 **删除部门编辑部门**的。所以下面在使用TreeTools组件的时候没有传递isRoot属性,没有传递,默认值是false

所以,增加一个新的属性 **isRoot(是否根节点)**进行控制。

下面在TreeTools组件中添加该属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
name: "TreeTools",
props: {
// 定义一个props属性
treeNode: {
type: Object, // 对象类型
required: true, // 要求对方使用您的组件的时候 必须传treeNode属性 如果不传 就会报错
},
isRoot:{
type:Boolean,
default:false
}
},
};

在上面的代码中,在props中定义了isRoot接收传递过来的值,然后在模板中进行判断,如下所示

1
2
3
4
5
6
7
8
<!-- 下拉菜单 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>添加子部门</el-dropdown-item>
<el-dropdown-item v-if="!isRoot">编辑部门</el-dropdown-item>
<el-dropdown-item v-if="!isRoot">删除部门</el-dropdown-item>
</el-dropdown-menu>
</template>

返回到浏览器中进行测试。

通过封装,在src/views/departments/index.vue文件中的代码看上去更加紧凑,简洁,这就是封装的魅力

6、部门模型设计

通过前面的学习,我们将组织架构的整体页面结构已经设计出来了,在这一小节中,我们需要在服务端设计部门的模型。

这里需要注意的一点就是先把数据库中T_UserInfos这个表中的测试数据删除,否则无法建立数据表之间的关系。

具体的模型设计如下

Cms.Entity项目中创建Department类、

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

public class Department : BaseEntity<long>
{
/// <summary>
/// 部门名称
/// </summary>
public string? DepartmentName { get; set; }
/// <summary>
/// 部门编号
/// </summary>
public string? DepartmentCode { get; set; }
/// <summary>
/// 部门描述
/// </summary>

public string? DepartmentDescription { get; set; }
/// <summary>
/// 父级部门ID
/// </summary>
public long ParentId { get; set; }
/// <summary>
/// 部门所在城市
/// </summary>

public string? City { get; set; }
/// <summary>
/// 部门负责人
/// </summary>
public string? Manager { get; set; }
/// <summary>
/// 部分负责人编号
/// </summary>
public long ManagerId { get; set; }
/// <summary>
/// 一个部门下面又很多员工
/// </summary>

public List<UserInfo> UserInfos { get; set; }=new List<UserInfo>();
}

同时修改UserInfo.cs实体类中的代码,如下所示:

1
2
3
4
5
6
7
8
9
public int Gender { get; set;}
/// <summary>
/// 用户头像,存储头像路径
/// </summary>
public string? PhotoUrl { get; set;}
/// <summary>
/// -----------------一个员工只能属于一个部门
/// </summary>
public Department? Department { get; set; }

这里添加了一个Department属性。

下面定义DepartmentConfig.cs类,完成相应的配置,该类的代码如所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DepartmentConfig : IEntityTypeConfiguration<Department>
{
public void Configure(EntityTypeBuilder<Department> builder)
{
builder.ToTable("T_Departments");
builder.Property(x => x.DepartmentName).HasMaxLength(20).IsRequired();
builder.Property(x => x.DepartmentCode).HasMaxLength(20).IsRequired();
builder.Property(x => x.DepartmentDescription).IsRequired();
builder.Property(x => x.City).HasMaxLength(16).IsRequired();
builder.Property(x => x.Manager).HasMaxLength(16).IsRequired();

}
}

下面修改UserInfoConfig.cs类中的代码,该类中的代码如下所示:

1
2
3
  builder.Property(x => x.PhotoUrl).HasMaxLength(100);
// 添加了一对多的关系
builder.HasOne<Department>(d=>d.Department).WithMany(u=>u.UserInfos).IsRequired();

修改MyDbContext

1
2
3
4
public class MyDbContext:DbContext
{
public DbSet<UserInfo>UserInfos { get; set; }
public DbSet<Department> DepartmentInfos { get; set; } // 这里指定了`DepartmentInfos`这个DbSet

下面完成数据的迁移操作。

1
2
Add-Migration CreateDepartament
Update-database

7、获取部门信息接口设计

在这一小节中,我们根据前面创建的部门的模型来获取对应的部门信息。

1
2
3
4
5
6
namespace Cms.IRepository
{
public interface IDepartmentRepository:IBaseRepository<Department>
{
}
}

在上面的代码中定义了IDepartmentRepository这个数据仓储接口。

下面看一下具体的实现,如下所示:

1
2
3
4
5
6
7
8
namespace Cms.Repository
{
public class DepartmentRepository : BaseRepository<Department>, IDepartmentRepository
{
public DepartmentRepository(MyDbContext context) : base(context) { }
}
}

下面,实现服务的接口。

1
2
3
4
5
6
7
namespace Cms.IService
{
public interface IDepartmentService:IBaseService<Department>
{
}
}

实现具体的服务类

1
2
3
4
5
6
7
8
9
10
11
12
namespace Cms.Service
{
public class DepartmentService:BaseService<Department>,IDepartmentService
{
private readonly IDepartmentRepository _departmentRepository;
public DepartmentService(IDepartmentRepository departmentRepository)
{
base.repository = departmentRepository;
_departmentRepository = departmentRepository;
}
}
}

创建具体的控制器DepartmentsController,该控制器中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace Cms.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class DepartmentsController : ControllerBase
{
private readonly IDepartmentService _departmentsService;
public DepartmentsController(IDepartmentService departmentService)
{
this._departmentsService = departmentService;
}
[HttpGet]
[Authorize]
public IActionResult GetDepartments()
{
var departs = _departmentsService.LoadEntities(d=>d.DelFlag==Convert.ToBoolean(DelFlagEnum.Normal));
if (departs.Count() > 0)
{
return Ok(new ApiResult<IEnumerable<Department>>() { Success = true, Message = "获取部门成功", Data = departs });
}
else
{
return NotFound("没有部门信息");
}



}
}
}

8、前端获取组织架构数据

在这一小节中,需要构建请求的api接口,向服务端发送请求获取组织架构的数据。

src/api目录下面创建departments.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
import request from "@/utils/request";
/** *
*
* 获取组织架构数据
* **/
export function getDepartments() {
return request({
url: "/departments",
});
}

返回到src/views/departments/index.vue组件中调用上面的方法来发送请求获取组织架构的数据。

1
2
3
import { onMounted, reactive,ref } from "vue";
import TreeTools from './components/tree-tools.vue'
import {getDepartments} from "@/api/departments"

这里导入了getDepartments这个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
setup() {
const company=ref({
name:"xxx教育集团",
manager:"负责人"
})
const departs = reactive([]);
onMounted(()=>{
loadDepartments();
})
const loadDepartments =async()=>{
const result =await getDepartments();
console.log('result=',result);

}
const defaultProps = {
children: "children",
label: "name",
};

这里我们将departs数组清空,然后在onMounted这个钩子函数中调用了loadDepartments这个方法。

在这方法中又调用了getDepartments方法发送请求,获取部门数据。

当然,这里也需要导入onMounted这个钩子函数。

1
import { onMounted, reactive,ref } from "vue";

loadDepartments方法中同时打印了result中的数据,通过查看浏览器的控制台,发现所有的组织架构的数据存储在一个数组中,而我们直接将这个数组给departs,然后交给树形结构是不合适的,因为该数据不是树形结构的数据。

所以在下一小节中,我们需要将数组中的数据转换成树形结构.

9、将数组转换成树形结构

src/utils目录下面创建index.js文件,该文件的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//将数组中的数据转换成树形数据(使用递归算法)
export function tranListToTreeData(list, rootValue) {
var arr = [];
list.forEach((item) => {
// parentId属性值为0,表示跟节点
if (item.parentId === rootValue) {
// 找到之后 就要去找 item 下面有没有子节点
const children = tranListToTreeData(list, item.id);
if (children.length) {
// 如果children的长度大于0 说明找到了子节点
item.children = children;
}
arr.push(item); // 将内容加入到数组中
}
});
return arr;
}

下面返回到views/departments/index.vue组件中调用上面的递归方法。

1
2
import {getDepartments} from "@/api/departments"
import { tranListToTreeData } from "@/utils/index";

首先导入tranListToTreeData方法

1
2
3
4
5
6
7
8
9
const loadDepartments =async()=>{
const result =await getDepartments();
// 将tranListToTreeData方法返回的数组解构以后,在追加到departs这个新组件中,然后在树形结构中进行展示。
departs.push(...tranListToTreeData(result,0));
}
const defaultProps = {
children: "children",// 在导入的tranListToTreeData方法中都把子部门放到了children属性中了。
label: "departmentName", // 注意:这里将label属性的值修改为departmentName,因为服务端返回的departmentName,表示部门名称
};

这里在loadDepartments方法中调用了tranListToTreeData方法,传递的第一个参数是从服务端返回的的组织架构数组数据,第二个参数是0表示根节点,

1
2
3
4
const company=ref({
departmentName:"xxx教育集团", // 这里将name修改为departmentName
manager:"负责人"
})

同时还要修改一下TreeTools.vue这个组件中的代码

1
<span>{{ treeNode.departmentName }}</span> // 将这里修改成departmentName

返回到浏览器中进行测试。

在加载数据的时候,如果数据还没有加载完,可以展示loading效果。

1
https://element-plus.gitee.io/zh-CN/component/loading.html
1
2
const departs = reactive([]);
const loading = ref(true); // 定义loading,默认值是true,表示开启loading效果
1
2
3
4
5
const loadDepartments =async()=>{
const result =await getDepartments();
departs.push(...tranListToTreeData(result,0));
loading.value = false; // 关闭loading效果
}

这里在获取到数据以后loading的值设置为false,表示关闭loading效果。

1
2
3
4
5
6
return {
departs,
defaultProps,
company,
loading,
};

loading返回

1
<el-tree  v-loading="loading"  :data="departs" :props="defaultProps" default-expand-all>

这里给el-tree添加了v-loading属性,取值为loading.

返回到浏览器端进行测试。(可以调整网速进行测试)

10、删除部门信息接口设计

DepartmentsController控制器中添加如下方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
[HttpDelete("{id}")]
[Authorize]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> DeleteDepartments([FromRoute]int id) {
var depart = await _departmentsService.LoadEntities(d => d.Id == id).FirstOrDefaultAsync();
if (depart == null)
{
return NotFound("没有找到要删除的部门");
}
depart!.DelFlag =Convert.ToBoolean(DelFlagEnum.logicDelete);
await _departmentsService.UpdateEntityAsync(depart);
return Ok(new ApiResult<Department>() {Success=true,Message="删除成功",Data=null});
}

11、删除部门功能–前端实现

这里先封装删除部门的接口api

src/api/departments.js文件中添加如下方法:

1
2
3
4
5
6
7
8
9
/** *
* 根据id根据部门 接口是根据restful的规则设计的 删除 delete 新增 post 修改put 获取 get
* **/
export function delDepartments(id) {
return request({
url: `/departments/${id}`,
method: 'delete'
})
}

问题:在什么情况下调用以上方法,发送请求删除部门信息呢?

这里需要修改views/departments/components/tree-tools.vue组件中的代码,这里给下拉框中的删除部门这一项注册单击事件。

1
https://element-plus.gitee.io/zh-CN/component/dropdown.html#%E6%8C%87%E4%BB%A4%E4%BA%8B%E4%BB%B6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<el-dropdown @command="operateDepts"> <!--注册了command事件,该事件触发以后,会调用operateDepts方法-->
<span class="el-dropdown-link">
操作
<el-icon class="el-icon--right">
<ArrowDown />
</el-icon>
</span>
<!-- 下拉菜单 -->
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="add">添加子部门</el-dropdown-item><!--注册了command-->
<el-dropdown-item v-if="!isRoot" command="edit">编辑部门</el-dropdown-item>
<el-dropdown-item v-if="!isRoot" command="del">删除部门</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>

在上面的代码中,给el-dropdown-item组件添加了command属性,并且给el-dropdown组件注册了command事件。当选择下拉框中的某一项的时候会触发该事件,这时候会调用operateDepts函数。

下面实现该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
setup(){
const operateDepts = (type) => {
if (type === "add") {
// 添加子部门
} else if (type === "edit") {
// 编辑子部门
} else {
// 删除操作
alert("删除");
}
};
return {
operateDepts,
};
}

在上面的代码中实现了operateDepts方法,该方法的参数type存储的就是所单击的下拉框中对应项的command属性的值。

这里做了相应的判断。

返回到浏览器中进行测试,当单击了下拉框中的删除部门这一项的时候,就会弹出一个警示框。

12、删除部门功能实现–前端实现2

在这一小节中,将完成部门的真正删除操作。

修改views/departments/components/tree-tools.vue组件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { delDepartments } from '@/api/departments'; // 首先导入delDepartments方法


setup(props,{emit}){
const operateDepts = (type) => {
if (type === "add") {
// 添加子部门
} else if (type === "edit") {
// 编辑子部门
} else {
// 删除操作
ElMessageBox.confirm(`确定要删除${props.treeNode.departmentName}部门?`)
.then(() => {
return delDepartments(props.treeNode.id); // 返回Promise对象
})
.then(() => {
// 如果删除成功了 就会进入这里
ElMessage({
message: "删除部门成功",
type: "success",
});
emit("delDepts"); // 触发自定义事件
});

}
};
return {
operateDepts,
};
}

在上面的代码中,先通过ElMessageBox.confirm方法给出了删除的提示。在提示的信息中,需要展示要删除的部门的名称,所以这里通过props.treeNode.departmentName获取要删除的部门名称。props是通过setup入口函数第一个参数获取到的。

通过文档知道confirm方法返回的是Promise对象。当单击确定按钮的时候,会执行then方法,在该方法的中调用了delDepartments方法发送请求,更加部门编号删除部门信息,同时该方法返回的也是一个Promise对象,所以后面可以继续添加then方法,构成链式调用。

在该then方法中,处理删除成功的情况。给出删除成功的提示。并且触发一个自定义事件delDepts

这里的emit方法是从setup入口函数的第二个参数解构出来的。

这里有两个问题:

第一个问题:删除失败的处理?

第二个问题:为什么触发自定义事件?

关于第一个问题,删除失败的处理,已经在响应拦截中处理了。

第二个问题:当删除成功以后,除了给出相应的删除成功的提示以外,还要让树形组件中展示最新的部门信息。

而树形组件是在父组件中的,所以这里需要触发自定义事件。

返回到父组件,也就是views/delDepartments/index.vue这个文件中修改代码

1
2
3
4
5
<el-tree  v-loading="loading"  :data="departs" :props="defaultProps" default-expand-all>
<template #default="{ node, data }"> <!--解构出传递给作用域插槽中的数据,data是每个节点的数据对象--->
<TreeTools :treeNode="data" @delDepts="loadDepartments"></TreeTools>
</template>
</el-tree>

在上面的代码中,给TreeTools组件,添加了delDepts这个自定义事件,当该事件触发以后,会调用loadDepartments方法重新发送请求,获取最新的部门信息数据。

1
2
3
4
5
6
const loadDepartments =async()=>{
const result =await getDepartments();
departs.length = 0; // -----------------------------注意:这里先将数组清空
departs.push(...tranListToTreeData(result,0));
loading.value = false;
}

在上面的代码中,先将departs数组中的原有数据清空,在添加新的数据,否则数组中会有原有的数据。

1
2
3
4
5
6
7
return {
departs,
defaultProps,
company,
loading,
loadDepartments //-------------返回loadDepartments方法
};

注意这里也需要将loadDepartments方法返回。

返回到浏览器中进行测试。

13、新增部门–展示弹出层

新增部门,需要弹出一个层,也就是弹出一个窗口,在这个窗口中展示表单。

下面在src/views/departments/components目录下面创建add-dept.vue组件,该组件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<template>
<!-- 新增部门的弹层 -->
<el-dialog
title="新增部门"
v-model="dialogVisible"
:close-on-click-modal="false"
>
<!-- 表单组件 el-form label-width设置label的宽度 -->
<!-- 匿名插槽 -->
<el-form label-width="120px">
<el-form-item label="部门名称">
<el-input style="width: 80%" placeholder="1-50个字符" />
</el-form-item>
<el-form-item label="部门编码">
<el-input style="width: 80%" placeholder="1-50个字符" />
</el-form-item>
<el-form-item label="部门负责人">
<el-select style="width: 80%" placeholder="请选择" />
</el-form-item>
<el-form-item label="部门介绍">
<el-input
style="width: 80%"
placeholder="1-300个字符"
type="textarea"
:rows="3"
/>
</el-form-item>
</el-form>
<!-- el-dialog有专门放置底部操作栏的 插槽 具名插槽 -->
<template #footer>
<el-row type="flex" justify="center">
<!-- 列被分为24 -->
<el-col :span="6">
<el-button type="primary" size="small">确定</el-button>
<el-button size="small">取消</el-button>
</el-col>
</el-row>
</template>
</el-dialog>
</template>
<script>
import { ref } from "vue";
export default {
name: "AddDept",
setup() {
const dialogVisible = ref(true); // 展示窗口
return {
dialogVisible,
};
},
};
</script>
<style scoped></style>

返回到views/departments/index.vue这个组件中使用上面的添加部门组件,看一下窗口的效果。

1
2
3
4
5
6
import AddDept from './components/add-dept.vue'
export default {
components:{
TreeTools,
AddDept
},

导入AddDept这个组件,完成AddDept组件的注册。

1
2
3
</el-tree>
</el-card>
<AddDept></AddDept>

在模板中进行渲染。

返回到浏览器中查看效果。

14、新增部门–控制弹出层显示与隐藏

下面先修改views/departments/components/add-dept.vue组件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
import { ref } from "vue";
export default {
name: "AddDept",
props:{
showDialog:{
type:Boolean,
default:false
}
},
setup() {

},
};
</script>

这里我们会接收父组件传递过来的showDialog属性,通过该属性控制添加窗口的显示与隐藏。

1
2
3
4
5
6
<!-- 新增部门的弹层 -->
<el-dialog
title="新增部门"
v-model="showDialog" <!--这里指定了showDialog-->
:close-on-click-modal="false"
>

这里的v-model属性的取值就是showDialog

返回到父组件views/departments/index.vue

1
2
</el-card>
<AddDept :showDialog="showDialog"></AddDept>

这里给AddDept这个子组件传递的属性就是showDialog,下面需要进行定义

1
2
const loading = ref(true); // 定义loading,默认值是true,表示开启loading效果
const showDialog = ref(false); // -------------控制窗口的弹出与隐藏

这里的showDialog默认值是false.也就是添加窗口不展示

1
2
3
4
5
6
7
8
return {
departs,
defaultProps,
company,
loading,
loadDepartments,
showDialog // 返回
};

这里也将showDialog这个对象返回到了模版中,这样在模版中才会使用。

保存代码的时候,会出现如下的错误:

1
2
3

VueCompilerError: v-model cannot be used on a prop, because local prop bindings are not writable.
Use a v-bind binding combined with a v-on listener that emits update:x event instead.

Vue在编译的时候出错了,这里不能使用v-model来绑定props所接收到的值。因为props接收到的值是只读的,而v-model是双向绑定,所以会涉及到数据的更改操作。

所以这里,我们在修改一下add-dept.vue组件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 import { ref,watch } from "vue";  // 需要导入watch

export default {
name: "AddDept",
props:{
showDialog:{
type:Boolean,
default:false
}
},
setup(props) {
const visible = ref(false);
visible.value = props.ShowDialog;
// const visible = props.showDialog; // 将接收到的showDialog赋值给了visible
// 这里还需要通过`watch`监听`props.showDialog`数据的变化,当数据变化以后,重新给visible赋值,它的值就是最新的值
watch(()=>props.showDialog,(newValue,oldValue)=>{
visible.value = newValue;
})
return {
visible // 返回
}
},
};

在上面的代码中,在setup这个入口函数中,通过参数props接收到父组件传递过来的showDialog属性的值,然后将接收到的值,赋值给了变量visible.(这里通过watch来监视props.showDialog值的变化)

最后,返回visible的值。

这里需要修改一下模版中的代码,如下所示:

1
2
3
4
5
6
<!-- 新增部门的弹层 -->
<el-dialog
title="新增部门"
v-model="visible" <!--绑定了visible-->
:close-on-click-modal="false"
>

在上面的代码中,我们可以看到v-model绑定了visible.

保存程序,返回到浏览器中,可以看到新增部门的窗口没有展示,也就是说默认情况下新增部门的窗口是隐藏的。

而当单击添加子部门的时候需要展示窗口。

所以下面需要修改src/views/departments/tree-tools.vue文件中的代码

1
2
3
4
5
6
setup(props,{emit}){
const operateDepts = (type) => {
if (type === "add") {
// 添加子部门
emit('addDepts',props.treeNode);
} else if (type === "edit") {

添加子部门的时候,这里触发了自定义事件addDepts,同时传递了数据,数据就是我们当前所单击的部门,也就是说,是在当前所单击的部门下面添加子部门。

在返回到父组件index.vue

1
2
3
<!-- 组织架构--头部布局 -->
<el-card>
<TreeTools :treeNode="company" :isRoot="true" @addDepts="addDepts" ></TreeTools>

上面是给头部区域所使用的TreeTools组件添加了自定义的事件@addDepts

1
2
3
<template #default="{ node, data }"> <!--解构出传递给作用域插槽中的数据,data是每个节点的数据对象--->
<TreeTools :treeNode="data" @delDepts="loadDepartments" @addDepts="addDepts"></TreeTools>
</template>

上面是对template标签中所使用的TreeTools组件添加了 @addDepts="addDepts"事件。

通过上面的代码可以看到,头部和下面的TreeTools组件中都添加了addDepts事件,因为这两块区域都有添加子部门的功能。

自定义事件触发以后,调用的是addDepts方法,下面实现该方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const addDepts = (nodeValue) => {
showDialog.value = true; // 将showDialog的值修改为true,表示展示窗口
node.value = nodeValue; // 存储当前单击的部门的信息。
console.log('nodeValue = ',node.value );
};

return {
departs,
defaultProps,
company,
loading,
loadDepartments,
showDialog,
addDepts // 返回addDepts方法
};
1
2
const showDialog = ref(false); 
const node = ref(null); // 定义node对象,保存部门信息。

注意:这里我们单击了添加子部门后展示窗口,当单击窗口右上角的叉号图标时可以关闭窗口,但是这时候再单击添加子部门

无法弹出添加部门的窗口,原因是这里没有实现真正的对showDialog属性的控制。

因为:

当我们单击添加子部门的时候,会执行 emit('addDepts',props.treeNode);这行代码,从而触发父组件中对应的addDepts事件,从而调用对应的addDepts方法,从而 showDialog.value = true,这时候showDialog值发生了变化,由于showDialog是响应对象,从而重新渲染模版,这时候,就会将``showDialog中的新值传递给AddDept组件,在该组件中通过watch监视到了showDialog`值的变化,从而将添加窗口弹出来了。

但是,当我们添加窗口右上角的查号图标,将窗口关闭以后,再次单击添加子部门的时候,不会在弹出窗口。

当单击叉号图标关闭窗口的时候,并没有改变showDialog的值,它的值其实一直是true.

所以,这里我们需要在单击叉号图标的时候,改变showDialog的值。

add-dept.vue这个组件中,给dialog窗口添加了close事件,该事件会在单击叉号图标的时候被触发。

当该事件触发以后,会调用closeDialog方法。

1
2
3
4
5
6
7
<!-- 新增部门的弹层 -->
<el-dialog
title="新增部门"
v-model="visible"
:close-on-click-modal="false"
@close="closeDialog"
>

closeDialog方法的实现如下所示:

1
2
3
4
5
6
7
8
const closeDialog=()=>{
//console.log('hello');
emit('closeAddDialog',false);
}
return {
visible,
closeDialog // 返回到模版中,在模版中财可以使用
}

closeDialog方法的内部也是触发了自定义的事件closeAddDialog.并传递了参数false

返回到父组件

1
2
3
</el-card>
<AddDept :showDialog="showDialog" @closeAddDialog="closeAddDialog"></AddDept>
</div>

这里指定了自定义事件closeAddDialog,该事件触发以后会调用closeAddDialog方法,该方法的实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 关闭添加窗口
const closeAddDialog=(state)=>{
showDialog.value = state;
}
return {
departs,
defaultProps,
company,
loading,
loadDepartments,
showDialog,
addDepts,
closeAddDialog //返回
};

closeAddDialog方法中,根据传递过来的state参数值,修改showDialog的值。

15、新增部门–表单基本校验实现

修改views/departments/components/add-dept.vue组件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
setup(props,{emit}) {
const visible = ref(false);

const formData = reactive({
departmentName: "", // 部门名称
departmentCode: "", // 部门编码
manager: "", // 部门管理者
departmentDescription: "", // 部门介绍
});
const formRules = reactive({
departmentName: [
{ required: true, message: "部门名称不能为空", trigger: "blur" },
{ min: 1, max: 50, message: "部门名称要求1-50个字符", trigger: "blur" },
],
departmentCode: [
{ required: true, message: "部门编码不能为空", trigger: "blur" },
{ min: 1, max: 50, message: "部门编码要求1-50个字符", trigger: "blur" },
],
manager: [
{ required: true, message: "部门负责人不能为空", trigger: "blur" },
],
departmentDescription: [
{ required: true, message: "部门介绍不能为空", trigger: "blur" },
{
trigger: "blur",
min: 1,
max: 300,
message: "部门介绍要求1-50个字符",
},
],
});

在上面的代码中定义了formData这个表单数据对象。

同时定义了formRules这个校验规则对象。

并且将两项内容返回。

1
2
3
4
5
6
return {
visible,
closeDialog,
formData, // 返回formData
formRules // 返回formRules
}

下面查看模版中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<el-form label-width="120px" :model="formData" :rules="formRules">
<el-form-item label="部门名称" prop="departmentName">
<el-input style="width: 80%" placeholder="1-50个字符" v-model="formData.departmentName" />
</el-form-item>
<el-form-item label="部门编码" prop = "departmentCode">
<el-input style="width: 80%" placeholder="1-50个字符" v-model="formData.departmentCode" />
</el-form-item>
<el-form-item label="部门负责人" prop="manager">
<el-select style="width: 80%" placeholder="请选择" v-model="formData.manager" />
</el-form-item>
<el-form-item label="部门介绍" prop="departmentDescription">
<el-input
style="width: 80%"
placeholder="1-300个字符"
type="textarea"
:rows="3"
v-model="formData.departmentDescription"
/>
</el-form-item>
</el-form>

在上面的模板代码中,给el-form组件添加了model属性绑定了表单数据对象formData,同时添加了rules属性绑定了formRules这个表单校验规则对象。

为了完成表单的校验,下面给每个el-form-item添加了prop属性。

同时给每个表单元素通过v-model双向绑定了表单数据对象中的相应属性。

返回到浏览器中进行测试。

16、新增部门–自定义校验

部门名称(departmentName):必填 1-50个字符 / 同级部门中禁止出现重复部门

部门编码(departmentCode):必填 1-50个字符 / 部门编码在整个模块中都不允许重复

关于部门名称的基本校验(必填 1-50个字符 )和部门编码基本校验(必填 1-50个字符 )已经完成了,下面再来看一下关于部门名称和部门编码中其它的校验要求。

这些要求需要通过自定义校验来实现。

这里我们先来看一下关于部门名称的校验,校验规则的要求:同级部门中禁止出现重复部门。

例如:在上海实业部,这个部门下面已经有一个A部门了,现在就不能在在上海实业部下面添加A部门了。

下面修改add-dept.vue这个组件中的代码。

在该组件中,首先应该接受传递过来的当前所单击的部门的信息。

1
2
3
4
5
6
7
8
9
10
11
12
export default {
name: "AddDept",
props:{
showDialog:{
type:Boolean,
default:false
},
treeNode:{
type:Object,
default:null
}
},

这里在props中定义了treeNode属性接收父组件中传递过来的当前所单击的部门的信息,在当前所单击的部门下面查找对应的子部门,然后看看与现在新添加的部门是否有名称上的冲突。

问题:在哪传递treeNode?

在父组件中,返回到index.vue组件中传递所单击的部门的信息。

1
2
</el-card>
<AddDept :showDialog="showDialog" @closeAddDialog="closeAddDialog" :treeNode="node"></AddDept>

这里的treeNode的数据来自node

node中已经保存了所单击的部门的信息,如下所示:

就在当前父组件index.vue中的addDepts方法中,已经将所单击的部门信息保存到了node中。

当然,这里需要将node返回,这样在模板中才可以使用。

1
2
3
4
5
6
7
8
9
10
11
return {
departs,
defaultProps,
company,
loading,
loadDepartments,
showDialog,
addDepts,
closeAddDialog,
node // 返回node
};

现在在返回到add-dept.vue组件中,在该组件中接收了传递过来的所单击的部门的信息,下面就是要查询一下在该单击的部门下的所有的子部门。看一下是否与新添加的部门名称有重复的。

1
2
3
4
5
6
7
8
9
const formRules = reactive({
departmentName: [
{ required: true, message: "部门名称不能为空", trigger: "blur" },
{ min: 1, max: 50, message: "部门名称要求1-50个字符", trigger: "blur" },
{
trigger:"blur",
validator:checkNameRepeat // 添加自定义检测
}
],

在上面的代码中,首先指定了自定义校验,添加了validator属性,该属性的取值是checkNameRepeat

下面实现checkNameRepeat方法,该方法的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

//---------------------------------------- 检测所添加的部门名称是否有重名的.
const checkNameRepeat=async(rule,value,callback)=>{
// value参数中存储的是在文本框中用户输入的部门名称
const result = await getDepartments();
// result中存储的是所有的部门
// 先通过filter方法找所单击的部门下的所有的子部门
// 然后再通过some方法查找所有的子部门名称与添加的部门名称是否有重名的,如果名称相同,some方法返回的是true
var isRepeat = result.filter((item)=>item.parentId===props.treeNode.id).some((item)=>item.departmentName===value);
isRepeat?callback(`同级部门下已经有${value}的部门了`):callback();
}

const formRules = reactive({
departmentName: [
{ required: true, message: "部门名称不能为空", trigger: "blur" },
{ min: 1, max: 50, message: "部门名称要求1-50个字符", trigger: "blur" },
{
trigger:"blur",
validator:checkNameRepeat // 添加自定义检测
}
],

在所定义的formRules校验规则对象上面实现了checkNameRepeat方法。

在上面的代码中,需要调用getDepartments方法发送请求获取最新的架构数据数据,所以这里需要将该方法进行导入

1
2
import { ref,watch,reactive } from "vue";
import {getDepartments}from '@/api/departments'

返回到浏览器中进行测试。

下面实现关于部门编码规则的校验

校验的规则要求是:部门编码在整个模块中都不允许重复

思路上是一样的。

1
2
3
4
5
6
7
8
// 检查编码是否重复
const checkCodeRepeat=async(rule,value,callback)=>{
// value参数中存储的是在文本框中用户输入的部门编码
const result = await getDepartments();
const isRepeat = result.some((item)=>item.departmentCode==value&&value)// 这里加一个 value不为空 因为我们的部门有可能没有code
isRepeat?callback(new Error(`组织架构中已经有部门使用${value}编码了`)):callback()
}

这里是从所有的组织架构数据中进行查找,看一下是否有部门编码相同。

1
2
3
4
5
6
7
8
departmentCode: [
{ required: true, message: "部门编码不能为空", trigger: "blur" },
{ min: 1, max: 50, message: "部门编码要求1-50个字符", trigger: "blur" },
{
trigger:"blur",
validator:checkCodeRepeat
}
],

departmentCode中添加了自定义的校验,完成了对checkCodeRepeat方法的调用。

下面返回到浏览器中进行测试。

17、新增部门–自定义校验问题

在上一小节中已经实现了自定义的校验,

但是这里有一个问题。

当单击顶部的下拉框,添加子部门的时候,输入了重名的部门名称并没有给出提示。

返回到views/departments/index.vue组件中

在头部布局中也使用了TreeTools组件

1
2
3
<!-- 组织架构--头部布局 -->
<el-card>
<TreeTools :treeNode="company" :isRoot="true" @addDepts="addDepts" ></TreeTools>

这里的treeNode属性的值是company,company的定义如下所示:

1
2
3
4
const company=ref({
departmentName:"xxx教育集团",
manager:"负责人"
})

以上对象中没有添加id

对应的tree-tools.vue组件中接收的

1
2
3
4
5
6
props: {
// 定义一个props属性
treeNode: {
type: Object, // 对象类型
required: true, // 要求对方使用您的组件的时候 必须传treeNode属性 如果不传 就会报错
},

treeNode中没有id

1
2
3
4
const operateDepts = (type) => {
if (type === "add") {
// 添加子部门
emit('addDepts',props.treeNode);

当单击添加子部门的时候,触发以上的addDepts自定义事件,同时传递了treeNode这个数据。

当父组件中,在父组件index.vue中,会调用addDepts方法,该方法的实现如下所示:

1
2
3
4
5
const addDepts = (nodeValue) => {
showDialog.value = true; // 将showDialog的值修改为true,表示展示窗口
node.value = nodeValue; // 存储当前单击的部门的信息。
console.log('nodeValue = ',node.value );
};

把传递过来的值有给了node

而最终node传递给了AddDept组件

1
<AddDept :showDialog="showDialog" @closeAddDialog="closeAddDialog" :treeNode="node"></AddDept>

在该组件中,接收treeNode中也就没有id

怎样修改呢?

views/departments/index.vue中给company对象添加一个id属性,并且取值是空字符串就可以了,如下所示:

1
2
3
4
5
6
const company=ref({
departmentName:"xxx教育集团",
manager:"负责人",
id:0

})

为什么传递是0?

原因是:当传递到了add-dept.vue这个组件以后

1
2
3
4
5
6
7
8
9
10
props:{
showDialog:{
type:Boolean,
default:false
},
treeNode:{
type:Object,
default:null
}
},

treeNode中有id,并且是0

1
2
var isRepeat =  result.filter((item)=>item.parentId===props.treeNode.id).some((item)=>item.departmentName===value); //这里查找的id是0,表示的是所有的根部门,也就是说单击顶部下拉框添加子部门实际上就是添加的是该公司下的根部门,而根部门也不能出现同名的。
isRepeat?callback(`同级部门下已经有${value}的部门了`):callback();

18、新增部门–填充下拉框

服务端接口设计

1
2
3
4
5
6
7
[HttpGet("simple")]
[Authorize]
public IActionResult GetSimpleUser()
{
var userSimpleList = _userInfoService.LoadEntities(u=>true);
return Ok(new ApiResult<List<UserInfo>>() { Success=true,Message="获取用户信息成功",Data=userSimpleList.Select(u=> new UserInfo() {Id=u.Id,UserName=u.UserName }).ToList() });
}

这里只查询了Id,UserName两个属性的值,因为这里我们填充下拉框,只需要使用到这两组数据。

前端实现:

这里首先构建一个api接口来获取员工的信息,

由于是获取员工的信息,所以我们在src/api目录下面创建employees.js

文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
import request from "@/utils/request";

/**
* 获取员工的简单列表(返回的数据中,只包含了员工姓名与编号)
* **/
export function getEmployeeSimple() {
return request({
url: "/users/simple",
});
}

返回到views/departments/components/add-dept.vue文件中进行代码的修改

1
2
3
import { ref,watch,reactive } from "vue";
import {getDepartments}from '@/api/departments'
import { getEmployeeSimple } from "@/api/employees";

在上面的代码中,导入了refgetEmployeeSimple这个方法

1
2
  const options =ref([]);
// 检测所添加的部门名称是否有重名的.

定义了一个options数组,存储获取到的员工的基本信息

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取员工的基本信息
const getEmployeeSimplInfo = async()=>{
const result = await getEmployeeSimple()
options.value = result;
}
return {
visible,
closeDialog,
formData,
formRules,
getEmployeeSimplInfo,
options
}

这里定义了getEmployeeSimpleInfo方法,在该方法中调用了getEmployeeSimple方法发送请求获取员工的基本信息。

将获取到的员工的基本信息存储到了options数组中。

最后,将getEmployeeSimpleInfo方法和options数组返回到模板中。

在模板中调用getEmployeeSimpleInfo方法。

下面修改模板中的代码,如下所示:

1
2
3
4
5
6
<el-form-item label="部门负责人" prop="manager">
<el-select style="width: 80%" placeholder="请选择" v-model="formData.manager" @focus="getEmployeeSimplInfo" >
<el-option v-for="item in options" :key="item.id" :label="item.userName" :value="item.userName+':'+item.id"><!----------注意:这里是把负责人的名称与编号拼接在一起,发送给服务端。--------------->
</el-option>
</el-select> <!---注意:el-option要在el-select里面--->
</el-form-item>

在上面的代码中,给el-select组件添加了@focs事件,当用鼠标点击下拉框的时候,下拉框会获取到焦点,从而会触发focus事件。该事件触发以后会调用getEmployeeSimpleInfo方法,获取基本的员工数据。

获取到的员工数据会在options数组中,所以下面开始对该数组进行遍历。

取出来的用户信息放到了el-option中。label表示展示的内容,value表示选中的对应的值。

返回到浏览器中进行测试。

19、新增部门–完成部门添加

前端代码实现

下面要实现的功能就是当单击确定按钮额时候完成部门的添加。

关于新增部门的api方法定义在src/api/departments.js文件中对应的addDepartments方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
/**
* 新增部门接口
*
* ****/
export function addDepartments(data) {
return request({
url: "/departments",
method: "post",
data,
});
}

下面修改src/views/departments/components/add-dept.vue文件中的代码

在添加部门前,也要判断表单是否通过校验

1
<el-form label-width="120px" :model="formData" :rules="formRules" ref="formRef">

所以这里给el-form添加了ref属性,关联的是formRef

1
<el-button type="primary" size="small"  @click="addOk(formRef)">确定</el-button>

确定按钮注册了单击事件,事件触发以后会执行addOk方法,将表单传递到该方法中

setup入口函数中定义formRef

1
2
const options =ref([]);
const formRef = ref(null); // 创建formRef对象

下面实现addOk方法。

1
2
3
4
5
6
7
8
9
10
11
 // 新增部门
// 完成部门添加
const addOk = (formEl) => {
formEl.validate(async (valid, fields) => {
if (valid) {
// console.log("pid=",props.treeNode.id );
await addDepartments({ ...formData, parentId: props.treeNode.id }); // 注意这里是将前面定义的formData对象(该对象已经与表单绑定在了一起,同时这里还需要parentId属性,指名当前添加的部门是哪个部门的子部门)
emit("addDepts");
}
});
};

在上面的代码中实现了addOK方法完成部门的添加,该方法的参数就是传递过来的表单的实例

接收传递过来的表单的实例,调用validate方法进行校验,如果valid参数的值为true表示校验通过。然后调用addDepartments方法发送请求。

注意:这里有一个问题:

添加部门,除了要获取表单中用户输入的部门的信息以外,还需要指定所添加的部门是属于哪个部门的子部门。

所以这里我们需要给所添加的部门指定parentId属性,该属性的取值就是所单击的当前部门的id(这里将formData对象中存储的部门信息解构与parentId进行了合并)

1
import { getDepartments, addDepartments } from "@/api/departments";

这里需要导入addDepartments方法。

当添加成功以后,触发自定义的事件addDepts,告诉父组件重新加载树形组件中部门的信息。

下面将formRefaddOk返回

1
2
3
4
5
6
7
8
9
10
return {
visible,
closeDialog,
formData,
formRules,
getEmployeeSimplInfo,
options,
formRef, // 这里返回了formRef和addOk
addOk
}

最后,返回到父组件中,指定自定义事件

views/departments/index.vue组件中

1
<AddDept :showDialog="showDialog" @closeAddDialog="closeAddDialog" :treeNode="node"      @addDepts="loadDepartments"></AddDept>

这里指定了addDepts这个自定义事件,当该事件触发以后会重新调用loadDepartments方法,发送请求重新加载部门信息数据。

服务端接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[HttpPost]
[Authorize]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> AddDepartment([FromBody]Department department)
{
string[] strs = department.Manager!.Split(":"); //注意前端提交过来的Manager中包含的内容
department.Manager = strs[0];
department.ManagerId = Convert.ToInt32(strs[1]);
department.City = "北京";
department.CreateTime = DateTime.Now;
department.UpdateTime = DateTime.Now;
department.DelFlag =Convert.ToBoolean(DelFlagEnum.Normal);
await _departmentsService.InsertEntityAsync(department);
return Ok(new ApiResult<Department>() { Success = true, Message = "添加成功", Data = department });
}

启动项目进行测试。

20、新增部门–关闭新增弹层

当新增完部门的信息以后,我们要将当前弹出的窗口关闭掉。

关闭窗口其实我们前面也已经实现了。

add-dept.vue这个组件对应的addOk方法中,我们也要触发closeAddDialog事件就可以了。

1
2
3
4
5
6
7
8
9
10
11
// 新增部门
// 完成部门添加
const addOk = (formEl) => {
formEl.validate(async (valid, fields) => {
if (valid) {
// console.log("pid=",props.treeNode.id );
await addDepartments({ ...formData, parentId: props.treeNode.id });
emit("addDepts");
emit("closeAddDialog",false); //-------------------触发closeAddDialog事件
}
});

21、新增部门–重置状态

这里有一个问题,在弹出的添加部门窗口中,我们没有在表单中输入任何数据,直接点击添加在,这时候会出现错误的提示。

这时候,单击取消按钮,将窗口关闭,然后再次单击添加子部门,把窗口弹出来,发现错误提示信息还存在,所以这里需要在单击取消`按钮关闭窗口前,先将这些错误的提示状态清除掉。

1
<el-button size="small" @click="closeDialog(formRef)">取消</el-button>

在调用btnCancel方法的时候,将formRef这个表单的实例传递到该方法中。

1
2
3
4
5
6
7
8
9

// 关闭窗口
const closeDialog=(formEl)=>{
//console.log('hello');
emit('closeAddDialog',false);
if(!formEl) return;
formEl.resetFields();
}

这里,调用了resetFields方法,重置了对应的状态。

返回到浏览器中进行测试。

当单击窗口右上角的叉号图标的时候,也需要将窗口关闭掉。

1
2
3
4
5
6
<el-dialog
title="新增部门"
v-model="visible"
:close-on-click-modal="false"
@close="closeDialog"
>

22、编辑部门–弹出编辑窗口

编辑部门功能实际上和新增窗体采用的是一个组件,只不过我们需要将新增场景变成编辑场景

当单击操作--编辑部门的时候,记录所单击的节点,然后弹出窗口,把数据填充到表单中。

下面修改views/departments/components/tree-tools.vue组件中的代码

1
2
3
4
5
6
7
8
setup(props,{emit}){
const operateDepts = (type) => {
if (type === "add") {
// 添加子部门
emit('addDepts',props.treeNode);
} else if (type === "edit") {
// 编辑子部门
emit("editDepts", props.treeNode);

这里触发了editDepts 事件,传递的是所点击的部门的数据。

返回到父组件departments/index.vue组件中

1
2
<TreeTools :treeNode="data" @delDepts="loadDepartments"     @addDepts="addDepts"   @editDepts="editDepts"></TreeTools>
</template>

这里是给第二个,也就是el-tree中的TreeTools中注册自定义事件editDepts,当该事件触发以后,会调用editDepts方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 编辑部门节点
const editDepts = (node) => {
// 首先打开弹层
showDialog.value = true;
node.value = node; // 赋值操作的节点
};
return {
departs,
defaultProps,
company,
loading,
loadDepartments,
showDialog,
addDepts,
closeAddDialog,
node,
editDepts // 返回
};

实现了editDepts方法,将editDepts方法返回,返回到浏览器中进行测试

23、编辑部门–展示编辑部门信息

前端会将要编辑的部门编号传递到服务端,服务端接收到该编号后,查询出要编辑的部门信息,然后返回到前端。

这里我们先来实现服务端的接口。

服务端接口实现

1
2
3
4
5
6
7
8
9
10
11
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> GetDepartmentById([FromRoute]int id)
{
var depart = await _departmentsService.LoadEntities(d => d.Id == id).FirstOrDefaultAsync();
if(depart == null)
{
return BadRequest("没有找到部门信息");
}
return Ok(new ApiResult<Department>() { Success=true, Message ="获取到部门信息", Data = depart });
}

前端实现

现在已经展示出来了编辑部门的窗口

接来下把要编辑的部门信息填充到表单中。

这里先构建一个api接口,请求服务端,获取要编辑的部门信息。

修改src/api/departments.js文件中的代码,增加如下的方法s

1
2
3
4
5
6
7
8
9
/** *
* 获取部门详情
* ***/
export function getDepartDetail(id) {
return request({
url: `/departments/${id}`,
method:"get"
});
}

下面返回到views/departments/components/add-dept.vue组件中进行代码的修改

我们知道在该组件中定义了formData这个响应式对象,而该对象已经与表单元素进行了双向数据绑定,这里只要将服务端返回的要修改的部门数据交给formData,对应的表单中就会展示要编辑的部门信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 将编辑的部门信息填充到表单中。
const getDepartDetailInfo = async (id) => {
const result = await getDepartDetail(id);
formData.departmentCode = result.departmentCode;
formData.departmentDescription = result.departmentDescription;
// formData.manager = result.manager;
formData.manager = result.manager;
formData.departmentName = result.departmentName;
};
return {
visible,
closeDialog,
formData,
formRules,
getEmployeeSimplInfo,
options,
formRef,
addOk,
getDepartDetailInfo
}

这里单独的定义了一个getDepartDetailInfo方法,在该方法中调用了getDepartDetail方法,发送请求获取部门信息,交给formData对象中的各个属性。

并且返回

1
import {getDepartments,addDepartments,getDepartDetail}from '@/api/departments'

这里导入了 getDepartDetail方法。

问题:getDepartDetailInfo方法在哪被调用?

在父组件中进行调用(弹出编辑窗口的时候就应该调用该方法,获取要编辑的部门信息,然后填充到表单中)

views/departments/index.vue

1
<AddDept :showDialog="showDialog" @closeAddDialog="closeAddDialog" :treeNode="node"      @addDepts="loadDepartments"  ref="addDept"></AddDept>

AddDept添加了一个ref属性,取值为addDept,也就是关联了一个AddDept对象

setup入口函数中,定义addDept

1
2
3
const showDialog = ref(false); // 控制窗口的弹出与隐藏
const node = ref(null);
const addDept = ref(null); // 定义addDept
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 编辑部门节点
const editDepts = (node) => {

// 首先打开弹层
showDialog.value = true;
node.value = node; // 赋值操作的节点
addDept.value.getDepartDetailInfo(node.id);//----------------- 这里调用了子组件中的getDepartDetailInfo方法
};
return {
departs,
defaultProps,
company,
loading,
loadDepartments,
showDialog,
addDepts,
closeAddDialog,
node,
editDepts,
addDept //----------------------------将addDept返回。
};

editDepts中通过addDept这个ref对象找到AddDept这个实例对象,然后调用内部的getDepartDetailInfo方法,传递的是所点击的要编辑的部门的编号。

最后补充一个知识点细节问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// vue3中直接对reactive整个对象赋值检测不到
let obj = reactive({
name: 'zhangsan',
age: '18'
})
obj = {
name: 'lisi'
age: ''
}
// 上面这样赋值检测不到,因为响应式的是它的属性,而不是它自身

// 如需要对 reactive 赋值
// 方法1: 单个赋值
obj['name'] = 'lisi';
obj['age'] = '';
// 方法2:多包一层
let obj = reactive({
data: {
name: 'zhangsan',
age: '18'
}
})
obj.data = {
name: 'lisi'
age: ''
}

24、编辑部门–切换标题

现在编辑与新增使用一个窗口,对应的标题也需要进行切换。

怎样进行切换呢?

下面修改add-dept.vue这个组件中的代码:

1
2
3
4
5
6
7
   const formData = reactive({
departmentName: "", // 部门名称
departmentCode: "", // 部门编码
manager: "", // 部门管理者
departmentDescription: "", // 部门介绍
id:""// ----部门编号
});

formData对象中又添加了一个id属性,表示部门的编号

1
2
3
4
5
6
7
8
9
// 将编辑的部门信息填充到表单中。
const getDepartDetailInfo = async (id) => {
const result = await getDepartDetail(id);
formData.departmentCode = result.departmentCode;
formData.departmentDescription = result.departmentDescription;
formData.manager = result.manager;
formData.departmentName = result.departmentName;
formData.id = result.id;// 将服务端返回的部门编号赋值给formData对象中的id属性
};

getDepartDetailInfo方法中获取了部门详情数据,这里肯定有部门的编号,将其赋值给formData对象中的id属性。

这就说明了,在编辑的时候该id属性是有值的,而新增的时候是没有值的。

所以当该id属性的 值发生了变化以后,就可以进行判断是编辑还是添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const showTitle = computed(() => {
return formData.id ? "编辑部门" : "新增子部门";
});
return {
visible,
closeDialog,
formData,
formRules,
getEmployeeSimplInfo,
options,
formRef,
addOk,
getDepartDetailInfo,
showTitle
}

这里我们添加了一个计算属性,根据id属性的值进行判断。

当然也需要将showTitle进行返回。

1
import { ref,watch,reactive,computed } from "vue";

这里也需要从vue中导入computed.

1
2
3
4
5
6
7
<!-- 新增部门的弹层 -->
<el-dialog
:title="showTitle"
v-model="visible"
:close-on-click-modal="false"
@close="closeDialog"
>

修改了el-dialog中的title属性的值,现在取值是showTitle.

返回到浏览器中进行测试。

在测试的时候,发现了一个问题,

首先单击添加子部门,展示的窗口中显示的标题是添加子部门,然后关闭窗口,再点击编辑部门,在弹出的窗口中展示的标题是编辑部门

关闭窗口,再单击添加子部门,弹出的窗口的标题还是编辑部门

原因是:当单击编辑部门弹出窗口后,进行了关闭,会执行如下代码

1
2
3
4
5
6
7
// 关闭窗口
const closeDialog=(formEl)=>{
//console.log('hello');
emit('closeAddDialog',false);
if(!formEl) return;
formEl.resetFields();
}

这里调用了formEl.resetFields方法,只是将与表单绑定的formData对象中的属性清除了,没有将id进行清除。

1
2
3
4
5
6
7
8
// 关闭窗口
const closeDialog=(formEl)=>{
//console.log('hello');
emit('closeAddDialog',false);
if(!formEl) return;
formEl.resetFields();
formData.id =""; // 将id的值清空。
}

这里将formData中的id值清空。

返回到浏览器中进行测试。

25、编辑部门–完成编辑

前端实现

当单击确定按钮的时候,要区分是添加部门信息还是编辑部门的信息

这里先增加一个更新部门的api接口在api/departments.js文件中添加如下方法:

1
2
3
4
5
6
7
8
9
10
11
/**
* 编辑部门
*
* ***/
export function updateDepartments(data) {
return request({
url: `/departments/${data.id}`,
method: "put",
data,
});
}

在调用以上方法的时候,会传递过来要更新的部门数据,数据中会有编号。

下面返回到add-dept.vue组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const addOk = (formEl) => {
formEl.validate(async (valid, fields) => {
if (valid) {
if(formData.id){
// id有值,表示更新的操作
await updateDepartments(formData);
}else{
await addDepartments({ ...formData, parentId: props.treeNode.id });
}
emit("addDepts");
emit("closeAddDialog",false);
}
});
};

我们知道,在单击确定按钮的时候,会调用addOk方法。在该方法中判断formData.id是否有值,如果有值调用updateDepartments方法进行数据的更新。

当然更新成功也需要重新加载数据,关闭窗口。

1
import {getDepartments,addDepartments,getDepartDetail,updateDepartments}from '@/api/departments'

这里导入了updateDepartments方法。返回到浏览器中进行测试。

在测试的时候发现了问题,进行编辑的时候,直接单击确定按钮,发现给出了部门名称与部门编码的错误。

为什么会给出这个错误的提示呢?因为这里是更新,而且是直接单击了确定按钮,没有更改部门名称和部门编码所以服务端已经存储了相同名称的部门名称和部门编码。所以才会出现这样的错误。

所以说,在编辑的时候关于业务规则的校验是不能与添加的时候一样的。

继续修改add-dept.vue组件中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 检测所添加的部门名称是否有重名的.
const checkNameRepeat=async(rule,value,callback)=>{
// value参数中存储的是在文本框中用户输入的部门名称
const result = await getDepartments();
let isRepeat =false;//---------------------重新定义了isRepeat变量
if(formData.id){ //-------------------------------------------添加了编辑状态下的判断

// 表示在编辑模式下
// 要求:同级部门下,不能出现和其它同级部门的名字重复
// 先查找当前所单击的要编辑的部门的所有同级部门。并且不是自己,然后在看一下新输入的部门名称是否有重名的。
isRepeat = result
.filter(
(item) =>
item.parentId === formData.parentId && item.id !== formData.id
)
.some((item) => item.departmentName === value);
}else{
// result中存储的是所有的部门
// 先通过filter方法找所单击的部门下的所有的子部门
// 然后再通过some方法查找所有的子部门名称与添加的部门名称是否有重名的,如果名称相同,some方法返回的是true
isRepeat = result.filter((item)=>item.parentId===props.treeNode.id).some((item)=>item.departmentName===value);
}

isRepeat?callback(`同级部门下已经有${value}的部门了`):callback();

};
// 检查编码是否重复
const checkCodeRepeat=async(rule,value,callback)=>{
// value参数中存储的是在文本框中用户输入的部门编码
const result = await getDepartments();
let isRepeat =false;
if(formData.id){
//------------------------- 编辑模式 因为编辑模式下 不能算自己(注意:编号在整个组织架构中是不允许出现重复的)
isRepeat = result.some(
(item) => item.id !== formData.id && item.departmentCode === value && value
);
}else{
isRepeat = result.some((item)=>item.departmentCode==value&&value)// 这里加一个 value不为空 因为我们的部门有可能没有code
}

isRepeat?callback(new Error(`组织架构中已经有部门使用${value}编码了`)):callback()
}

在上面的判断中使用了parentId,所以需要在formData中去定义parentId这个属性。

1
2
3
4
5
6
7
8
const formData = reactive({
departmentName: "", // 部门名称
departmentCode: "", // 部门编码
manager: "", // 部门管理者
departmentDescription: "", // 部门介绍
id:"",// 部门编号
parentId:"" // 父级编号
});

同时在getDepartDetailInfo方法中给parentId属性赋值。

1
2
3
4
5
6
7
8
9
10
// 将编辑的部门信息填充到表单中。
const getDepartDetailInfo = async (id) => {
const result = await getDepartDetail(id);
formData.departmentCode = result.departmentCode;
formData.departmentDescription = result.departmentDescription;
formData.manager = result.manager;
formData.departmentName = result.departmentName;
formData.parentId =result.parentId;
formData.id = id;
};

在编辑的时候会调用getDepartDetailInfo方法,在该方法中给parentId赋值

返回到浏览器中进行测试。

这里进行部门编辑的时候,可以先不输入任何的数据,然后单击确定按钮看一下

然后在输入新的内容,进行测试,看一下效果。

服务端实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[HttpPut("{id}")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> UpdateDepartment([FromRoute]int id, [FromBody]Department department)
{
var dept = await _departmentsService.LoadEntities(d=>d.Id==id).FirstOrDefaultAsync();
if(dept == null)
{
return BadRequest("没有找到要更新的部门");
}
string[] strs = department.Manager!.Split(":");
dept.DepartmentDescription = department.DepartmentDescription;
dept.DepartmentName = department.DepartmentName;
dept.DepartmentCode = department.DepartmentCode;
dept.Manager = strs[0];
dept.ManagerId = Convert.ToInt32(strs[1]);
dept.UpdateTime = DateTime.Now;
return Ok(new ApiResult<Department>() { Success = true, Message = "获取到部门信息", Data = dept });

}

由于在UpdateDepartment这个方法中,没有使用automapper自动映射,只能自己手动进行映射。

六、员工管理模块

1、创建通用工具栏组件

在后续的业务开发中,经常会用到一个类似下图的工具栏,作为公共组件,进行一下封装

src/components目录下面创建PageTools文件夹,在该文件夹下面创建index.vue组件,该组件中的基本布局代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<template>
<el-card class="page-tools">
<el-row type="flex" justify="space-between" align="middle">
<el-col>
<div v-if="showBefore" class="before"> <!--如果showBefore为true才展示该div中的内容-->
<el-icon><InfoFilled /></el-icon>
<!-- 定义前面得插槽 -->
<slot name="before" />
</div>
</el-col>
<el-col>
<el-row type="flex" justify="end">
<!-- 定义后面的插槽 -->
<slot name="after" />
</el-row>
</el-col>
</el-row>
</el-card>
</template>

<script>
export default {
name: "PageTools",
props: {
showBefore: {
type: Boolean,
default: false,
},
},
};
</script>

<style lang="scss">
.page-tools {
margin: 10px 0;
.before {
line-height: 34px;
i {
margin-right: 5px;
color: #409eff;
}
display: inline-block;
padding: 0px 10px;
border-radius: 3px;
border: 1px solid rgba(145, 213, 255, 1);
background: rgba(230, 247, 255, 1);
}
}
</style>

下面进行测试。

views/dashboard/index.vue这个组件中进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
后台布局
<PageTools :showBefore="true"> <!--指定showBefore属性为true,表示展示左侧插槽内容-->
<template v-slot:before> <!--指定before这个插槽展示的内容-->
<span>Hello</span>
</template>
<template v-slot:after><!--指定after这个插槽展示的内容-->
<el-button type="primary">添加</el-button>
</template>
</PageTools>
</template>
<script>
import PageTools from "@/components/PageTools"; // 导入`PageTools`组件
export default{
name:'dashboard',
components: {
PageTools, // 完成`PageTools`组件的注册
},
setup(){


}
}
</script>

返回到浏览器中进行测试。

访问首页查看效果

2、完成公共组件全局注册

在上一小节中创建的PageTools这个组件是公共组件,在其他的页面中使用该组件的时候,都需要导入与注册。

比较麻烦,这里可以完成全局的注册。

修改src/components/index.js文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
import SvgIcon from "@/components/SvgIcon";
import PageTools from "@/components/PageTools";
export default {
install(app) {
app.component(SvgIcon.name, SvgIcon);
app.component(PageTools.name, PageTools);
},
};

这里完成了PageTools组件的全局注册。

返回到views/dashboard/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
后台布局
<PageTools :showBefore="true"> <!--指定showBefore属性为true,表示展示左侧插槽内容-->
<template v-slot:before> <!--指定before这个插槽展示的内容-->
<span>Hello</span>
</template>
<template v-slot:after><!--指定after这个插槽展示的内容-->
<el-button type="primary">添加</el-button>
</template>
</PageTools>
</template>
<script>

export default{
name:'dashboard',

setup(){


}
}
</script>

这里不需要在导入PageTools组件和进行注册,直接使用

返回到浏览器中查看效果。

3、员工列表页面基本布局

在这一小节中,先将员工列表的基本布局设计出来。

views/employees/index.vue这个组件中添加如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<PageTools :showBefore="true"> <!--使用了PageTools这个公共组件-->
<template #before >共166条记录</template> <!--注意插槽在vue3中的应用--->
<template #after >
<el-button size="small" type="primary">新增员工</el-button>
</template>
</PageTools>
<!-- 放置表格和分页 -->
<el-card>
<el-table border>
<el-table-column label="序号" sortable="" /><!--sortable表示可以进行排序--->
<el-table-column label="姓名" sortable="" />
<el-table-column label="手机号" sortable="" />
<el-table-column label="部门" sortable="" />
<el-table-column label="入职时间" sortable="" />
<el-table-column label="操作" sortable="" fixed="right" width="280">
<template #default>
<el-button type="text" size="small">查看</el-button>
<el-button type="text" size="small">转正</el-button>
<el-button type="text" size="small">调岗</el-button>
<el-button type="text" size="small">离职</el-button>
<el-button type="text" size="small">编辑</el-button>
<el-button type="text" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<el-row
type="flex"
justify="center"
align="middle"
style="height: 60px"
>
<el-pagination
layout="prev, pager, next"
:total="10"
:page-size="2"
/>
</el-row>
</el-card>

app-container这个div内部添加如上内容。

返回到浏览器中查看效果

4、员工列表数据请求和分页加载

4.1 服务端接口设计

第一:首先构建搜索条件。

Cms.Entity项目中创建一个Search文件夹,在该文件夹中创建UserInfoSearch.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
namespace Cms.Entity.Search
{
public class UserInfoSearch:BaseSearch
{
public string? UserName { get; set; }
public string? UserPhone { get; set; }
}
}

同时在Search文件夹中创建BaseSearch.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
namespace Cms.Entity.Search
{
public class BaseSearch
{
public int PageIndex { get;set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public bool Order { get; set; }
}
}

第二:定义获取员工信息的服务接口与具体实现,如下所示:

1
2
3
4
5
6
7
8
namespace Cms.IService
{
public interface IUserInfoService:IBaseService<UserInfo>
{
IQueryable<UserInfo> LoadSearchEntities(UserInfoSearch userInfoSearch, bool delFlag);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public IQueryable<UserInfo> LoadSearchEntities(UserInfoSearch userInfoSearch, bool delFlag)
{
var temp = _userInfoRepository.LoadEntities(u=>u.DelFlag==delFlag);
if (!string.IsNullOrWhiteSpace(userInfoSearch.UserName))
{
temp = temp.Where<UserInfo>(u => u.UserName!.Contains(userInfoSearch.UserName));
}
if (!string.IsNullOrWhiteSpace(userInfoSearch.UserPhone))
{
temp = temp.Where<UserInfo>(u => u.UserPhone!.Contains(userInfoSearch.UserPhone));
}
userInfoSearch.TotalCount = temp.Count();
if (userInfoSearch.Order)
{
return temp.OrderBy<UserInfo, long>(u => u.Id).Skip<UserInfo>((userInfoSearch.PageIndex - 1) * userInfoSearch.PageSize).Take<UserInfo>(userInfoSearch.PageSize);
}
else
{
return temp.OrderByDescending<UserInfo, long>(u => u.Id).Skip<UserInfo>((userInfoSearch.PageIndex - 1) * userInfoSearch.PageSize).Take<UserInfo>(userInfoSearch.PageSize);
}
}

第三:控制器(UsersController.cs)中代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[HttpGet("pages")]
[Authorize]
public IActionResult GetUsers([FromQuery] UserParams userParams)
{
int totalCount = 0;
UserInfoSearch userInfoSearch = new UserInfoSearch()
{

UserName = userParams.UserName,
UserPhone = userParams.UserPhone,
Order = userParams.Order,
PageIndex = userParams.PageIndex,
PageSize = userParams.PageSize,
TotalCount =totalCount
};
// 获取所有的员工信息
var users = _userInfoService.LoadSearchEntities(userInfoSearch, Convert.ToBoolean(DelFlagEnum.Normal));
if (userInfoSearch.TotalCount <= 0)
{
return NotFound("没有用户信息");
}
//关联查询部门信息,获取员工所在的部门。这里只返回部分内容
var usersDepments = users.Include(u => u.Department).Select(u => new {Id=u.Id,UserName=u.UserName, UserPhone=u.UserPhone, CreateTime=u.CreateTime, departmentName=u.Department!.DepartmentName });
//Data中包含了员工信息以及总的记录数,是一个匿名类.
// 所以ApiResult的泛型类型是object

return Ok(new ApiResult<object>() {Success=true,Message="获取用户信息成功",Data =new { rows= usersDepments,total=userInfoSearch.TotalCount } });
}

上面GetUsers方法接收到的参数类型是UserParams类型,在WebApi项目中创建ResourceParams文件夹,在该文件夹中创建UserParams.cs,代码如下所示:

1
2
3
4
5
6
7
8
namespace Cms.WebApi.ResourceParams
{
public class UserParams:BaseParams
{
public string? UserName { get; set; }
public string? UserPhone { get; set; }
}
}

BaseParams.cs中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
namespace Cms.WebApi.ResourceParams
{
public class BaseParams
{
public int PageIndex { get; set; }
public int PageSize { get; set; }

public bool Order { get; set; }

}
}

注意:这里不包含totalCount

4.2前端实现

这里先定义获取员工列表数据的接口

src/api/employees.js文件中进行定义

1
2
3
4
5
6
7
8
9
/**
* 获取员工的综合列表数据
* ***/
export function getEmployeeList(params) {
return request({
url: "/users/pages",
params,
});
}

下面修改views/employees/index.vue这个组件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<script>
import { ref,onMounted} from 'vue'
import { getEmployeeList } from "@/api/employees";
export default {
name: "Employees",
setup(){
const loading = ref(false);
const list = ref([]);
const page = ref({
PageIndex: 1, // 当前页码
PageSize: 2, // 每页显示的记录数,这里服务端要求是PageSize
total:0
});
onMounted(() => {
loadEmployeeList();
});
const loadEmployeeList = async () => {
loading.value = true;
const { rows, total } = await getEmployeeList(page.value);
page.value.total = total;
list.value = rows;
loading.value = false;
};

return {
loading,
list,
page,

};
}
}
</script>

下面修改模板中的代码

1
2
3
4
5
6
7
<div class="app-container">
<PageTools :showBefore="true"> <!--使用了PageTools这个公共组件-->
<template #before>共{{ page.total }}条记录</template> <!--注意插槽在vue3中的应用--->
<template #after >
<el-button size="small" type="primary">新增员工</el-button>
</template>
</PageTools>

这里展示了page.total属性中存储的总的记录数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<el-card v-loading="loading">
<el-table border :data="list">
<el-table-column label="序号" type="index" /><!--sortable表示可以进行排序--->
<el-table-column label="姓名" prop="userName" />
<el-table-column label="手机号" prop="userPhone" />
<el-table-column label="部门" prop="departmentName"/>
<el-table-column label="入职时间" prop="createTime" />
<el-table-column label="操作" fixed="right" width="280">
<template #default>
<el-button size="small">查看</el-button>
<el-button size="small">转正</el-button>
<el-button size="small">调岗</el-button>
<el-button size="small">离职</el-button>
<el-button size="small">编辑</el-button>
<el-button size="small">删除</el-button>
</template>
</el-table-column>
</el-table>

el-card组件添加了v-loading属性。

同时给el-table添加了data属性关联了list,也就是指定了表格要展示的数据源。

下面给序号列指定了type=index

给其它的列指定了prop属性,指定要展示的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 分页组件 -->
<el-row
type="flex"
justify="center"
align="middle"
style="height: 60px"
>
<el-pagination
layout="prev, pager, next"
:total="page.total"
:page-size="page.PageSize"
v-model:currentPage="page.PageIndex"
@current-change="handleCurrentChange"
/>
</el-row>

这里给el-pagination这个分页组件指定了totalpage-size属性的值

还要实现分页的功能,也就是实现页码切换的功能。

这里给分页组件绑定了currentPage属性,表示当前页码值。取值是page对象的page属性。

同时指定了current-change事件,当进行页码切换的时候会触发该事件,执行handleCurrentChange这个方法,下面实现该方法:

1
2
3
4
5
6
7
8
9
10
11
const handleCurrentChange=(value)=>{
page.value.PageIndex = value;
loadEmployeeList(); // 重新加载数据
}
return {
loading,
list,
page,
handleCurrentChange
};
}

这里将切换后得到的页码值赋值给了page对象中的page属性,然后调用loadEmployeeList方法重新加载员工数据。

最后将handleCurrentChange方法返回。

下面返回到浏览器中进行测试。

5、数据格式处理

这里是对展示的日期格式进行处理。

1
<el-table-column label="入职时间" prop="createTime"  :formatter="formatTimeOfEntry" />

这里也是给入职时间这一列中添加了formatter属性,该属性的值是formatTimeOfEntry方法。

该方法的定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 入职时间格式处理
const formatTimeOfEntry = (row, column, cellValue, index) => {
// console.log("cellvalue=", cellValue);
const date = new Date(cellValue);
return (
date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate() // 注意在计算`date.getMonth() + 1`的时候需要用括号括起来,否则就是字符串的拼接了。
);
};
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry // 将该方法返回
};

这里对日期时间做了简单的处理

返回到浏览器中查看效果。

6、新建员工弹出层组件

views/employees目录下面创建components目录,在该目录下面创建add-employee.vue组件,该组件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<template>
<el-dialog title="新增员工" v-model="visible">
<!-- 表单 -->
<el-form label-width="120px">
<el-form-item label="姓名">
<el-input style="width: 50%" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="手机">
<el-input style="width: 50%" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="密码">
<el-input style="width: 50%" placeholder="请输入密码" />
</el-form-item>
<el-form-item label="入职时间">
<el-date-picker style="width: 50%" placeholder="请选择入职时间" />
</el-form-item>
<el-form-item label="邮箱">
<el-input style="width: 50%" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="部门">
<el-input style="width: 50%" placeholder="请选择部门" />
</el-form-item>
<el-form-item label="转正时间">
<el-date-picker style="width: 50%" placeholder="请选择转正时间" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group class="ml-4">
<el-radio label="1" size="large">男</el-radio>
<el-radio label="0" size="large">女</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<!-- footer插槽 -->
<template #footer>
<el-row type="flex" justify="center">
<el-col :span="6">
<el-button size="small">取消</el-button>
<el-button type="primary" size="small">确定</el-button>
</el-col>
</el-row>
</template>
</el-dialog>
</template>

<script>
import {ref} from 'vue'
export default {
name: "AddEmployee",
props: {
showDialog: {
type: Boolean,
default: true, // 这里先将默认值修改为`true`,查看效果,后面在将其修改为false
},
},
setup(props){
const visible =ref(false);
visible.value = props.showDialog;
return {
visible
}
}
};
</script>

以上通过showDialog属性接收传递过来的值,决定弹出层的展示与隐藏。

下面返回到employees/index.vue组件中使用上面创建出来的添加员工组件。

1
2
3
4
5
6
import AddEmployee from "./components/add-employee.vue";
export default {
name: "Employees",
components:{
AddEmployee
},

导入AddEmployee组件,并且完成注册。

在模板中使用

1
2
3
4
5
</el-row>
</el-card>
<AddEmployee></AddEmployee>
</div>
</div>

返回到浏览器中进行测试。

7、控制弹出层显示

当单击页面右上角的新增员工 按钮的时候,将添加员工的弹出层展示出来。

employees/index.vue组件中修改代码:

1
2
3
4
onMounted(() => {
loadEmployeeList();
});
const showAddDialog = ref(false);

定义了showAddDialog这个状态

1
2
3
4
5
6
7
8
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog// 返回
};

showAddDialog返回

1
<el-button size="small" type="primary" @click="showAddDialog=true">新增员工</el-button>

这里给新增员工的按钮注册点击事件,事件触发以后将showAddDialog设置为true

同时传递到添加员工的子组件中。

1
<AddEmployee :showDialog="showAddDialog"></AddEmployee>

当然,AddEmployee这个组件中的代码还需要修改一下

1
2
3
4
5
6
7
8
9
10
11
setup(props){
const visible =ref(false);
// 通过watch 监听props.showDialog数据的变化。
watch(()=>props.showDialog,(newValue,oldValue)=>{
visible.value = newValue;
})

return {
visible
}
}

当然,这里需要导入watch

1
import {ref,watch} from 'vue'

返回到浏览器中进行测试。

8、新增员工表单校验

在这一小节中,将要完成的是新增员工的表单校验。

修改views/employees/components/add-employee.vue组件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
setup(props){
const visible =ref(false);
const formData = ref({
UserName:"",
UserPhone:"",
UserPassword:"",
UserEmail:'',
Gender:"",
DepartmentName:'',
CreateTime:'',
UpdateTime:''
})
// 表单校验
const rules = reactive({
UserName: [
{ required: true, message: "用户姓名不能为空", trigger: "blur" },
{
min: 1,
max: 16,
message: "用户姓名为1-16位",
},
],
UserPhone: [
{ required: true, message: "手机号不能为空", trigger: "blur" },
{
pattern: /^1[3-9]\d{9}$/,
message: "手机号格式不正确",
trigger: "blur",
},
],
UserPassword: [
{ required: true, message: "密码不能为空", trigger: "blur" },
],
UserEmail: [
{ required: true, message: "邮箱不能为空", trigger: "blur" },
],
DepartmentName: [
{ required: true, message: "部门不能为空", trigger: "change" },
],
CreateTime: [{ required: true, message: "入职时间", trigger: "blur" }],
UpdateTime: [{ required: true, message: "转正时间", trigger: "blur" }],
});

return {
visible,
formData, // 返回数据对象
rules // 返回校验的规则对象
}

在上面的代码中定义了formData对象,该对象中的属性都是与表单进行了双向的绑定。

这些属性都是通过查看接口文档获取到的。

模板中表单中添加相应的属性,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- 表单 -->
<el-form label-width="120px" :model="formData" :rules="rules">
<el-form-item label="姓名" prop="UserName">
<el-input style="width: 50%" placeholder="请输入姓名" v-model="formData.UserName" />
</el-form-item>
<el-form-item label="手机" prop="UserPhone">
<el-input style="width: 50%" placeholder="请输入手机号" v-model="formData.UserPhone" />
</el-form-item>
<el-form-item label="密码" prop="UserPassword">
<el-input style="width: 50%" placeholder="请输入密码" v-model="formData.UserPassword" />
</el-form-item>
<el-form-item label="入职时间" prop="CreateTime">
<el-date-picker style="width: 50%" placeholder="请选择入职时间" v-model="formData.CreateTime" />
</el-form-item>
<el-form-item label="邮箱" prop="UserEmail">
<el-input style="width: 50%" placeholder="请输入邮箱" v-model="formData.UserEmail"/>
</el-form-item>
<el-form-item label="部门" prop="DepartmentName">
<el-input style="width: 50%" placeholder="请选择部门" v-model="formData.DepartmentName" />
</el-form-item>
<el-form-item label="转正时间" prop="UpdateTime">
<el-date-picker style="width: 50%" placeholder="请选择转正时间" v-model="formData.UpdateTime" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group class="ml-4" v-model="formData.Gender">
<el-radio label="0" size="large">男</el-radio>
<el-radio label="1" size="large">女</el-radio>
</el-radio-group>
</el-form-item>
</el-form>

这里先给el-form组件添加了model,rules

同时给el-form-item组件添加prop属性完成校验。

表单项添加v-model属性完成双向绑定。

返回到浏览器中查看效果。

9、加载部门数据

在新增员工的表单中有一项是部门

员工的部门是从树形部门中选择一个部门

下面看一下怎样获取部门数据,将其转换成一个树形结构,在下拉框中进行展示。

下面修改views/employees/components/add-employee.vue组件中的代码,如下所示:

1
2
3
  import {ref,watch,reactive} from 'vue'
import { getDepartments } from "@/api/departments"; // 导入获取部门信息的方法
import { tranListToTreeData } from "@/utils"; // 导入构建树形结构数据的方法
1
2
3
4
const treeData = ref([]); // 存储部门的树形数据
const showTree = ref(false); // 控制树形结构的显示与隐藏
const loading = ref(false); // 控制树形结构中数据加载时的进度条
const visible =ref(false);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 // 获取部门信息
const getDepartmentsInfo = async () => {
showTree.value = true; // 默认情况下是不展示树形组件的,只有当获取数据后展示
loading.value = true;
const depts = await getDepartments(); // depts是数组但不是树形结构
treeData.value = tranListToTreeData(depts,0); // 将其转换成树形结构
loading.value = false;
};
return {
visible,
formData,
rules,
getDepartmentsInfo,
treeData,
showTree,
loading,
}

定义getDepartmentsInfo 方法,获取部门数据,并且组织成树形结构。

并且将相关的内容返回。

下面修改模板中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
<el-form-item label="部门" prop="DepartmentName">
<el-input style="width: 50%" placeholder="请选择部门" v-model="formData.DepartmentName" @focus="getDepartmentsInfo" /><!--当文本框失去焦点后调用getDepartmentsInfo方法。--->
</el-form-item>
<!-- 放置一个el-tree组件 -->
<el-tree
v-if="showTree"
v-loading="loading"
:data="treeData"
:default-expand-all="true"
:props="{ label: 'departmentName' }"
/>

在部门的el-form-item这个组件下面添加一个树形组件。当showTree属性为true的时候进行展示,通过data指定数据源,default-expand-all属性的值为true表示展示整个树形结构。同时通过props中的label属性指定树形结构中展示的内容是来自name属性。

问题:什么时候调用getDepartmentsInfo方法来获取部门数据?

el-input这个选择部门的文本框获取到了焦点后来获取部门数据,并且将其转换成树形结构,填充到树形组件中。

所以要给该el-input这个组件添加@focus事件。

返回到浏览器中进行测试,查看效果。

10、选择部门

接下来要实现的功能是当单击了部门名称以后,将其填充到文本框中,

修改views/employees/components/add-employee.vue组件中的代码:

1
2
3
4
5
6
7
8
9
<!-- 放置一个el-tree组件 -->
<el-tree
v-if="showTree"
v-loading="loading"
:data="treeData"
:default-expand-all="true"
:props="{ label: 'departmentName' }"
@node-click="selectNode"
/>

el-tree这个组件中添加了node-click事件,当单击了树形结构中的节点的时候,会触发该事件。

调用selectNode这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取部门树形结构中所单击的部门
const selectNode = (node) => {
// node 参数中存储的是所单击的部门信息
// console.log("node=", node);
formData.value.DepartmentName = node.departmentName;

showTree.value = false;
};
return {
visible,
formData,
rules,
getDepartmentsInfo,
treeData,
showTree,
loading,
selectNode
}

这里传递给selectNode方法的参数node中存储了所单击的部门的信息。

将其赋值给formData对象中的DepartmentName属性,而该属性已经与部门的文本框进行了绑定,所以所单击的部门的名称会展示到文本框中。

并将树形结构隐藏隐藏掉。

最后将selectNode方法返回。

返回到浏览器中进行测试。

11、完成新增员工

11.1 前端实现

src/api/employees.js文件中添加请求的方法,如下所示:

1
2
3
4
5
6
7
8
9
10
/** **
* 新增员工的接口
* **/
export function addEmployee(data) {
return request({
method: 'post',
url: '/users',
data
})
}

修改views/employees/components/add-employee.vue组件中的代码

1
2
import { tranListToTreeData } from "@/utils"; // 导入构建树形结构数据的方法
import { addEmployee } from "@/api/employees";

这里将addEmployee导入进来。

1
2
const visible =ref(false);
const formRef = ref(null); // 关联表单组件

创建formRef对象与表单进行关联。

1
2
3
4
5
6
7
8
9
// 增加员工
const btnOk = (formEl) => {
formEl.validate(async (valid, fields) => {
if (valid) {
await addEmployee(formData.value); // 注意:这里一定要添加value
emit("addEmployee");
emit("changedialog", false);
}
});

创建btnOk方法,当单击确定按钮的时候,会调用该方法,同时获取表单中的数据,传递到addEmployee方法中。

1
2
3
4
5
6
7
8
9
10
11
12
return {
visible,
formData,
rules,
getDepartmentsInfo,
treeData,
showTree,
loading,
selectNode,
btnOk, // 返回
formRef // 返回
}

这里将btnOkformRef返回。

1
2
<!-- 表单 -->
<el-form label-width="120px" :model="formData" :rules="rules" ref="formRef">

这里将formRef对象与el-form表单关联在一起。

1
2
3
4
5
6
7
8
9
<!-- footer插槽 -->
<template #footer>
<el-row type="flex" justify="center">
<el-col :span="6">
<el-button size="small">取消</el-button>
<el-button type="primary" size="small" @click="btnOk(formRef)">确定</el-button>
</el-col>
</el-row>
</template>

这里给确定按钮添加了click事件,事件触发以后调用btnOk方法,传递了formRef对象。

通过校验以后,在btnOk方法的内部调用了addEmployee方法,发送请求,完成员工信息的添加。

然后触发了以下两个事件

1
2
emit("addEmployee"); //  //触发该事件的目的是重新加载父组件中的数据,填充表格
emit("changedialog", false); // 关闭添加窗口
1
setup(props, { emit }) {

这里通过emits声明了两个事件,同时在setup入口函数中,在第二个参数中解构出了emit

下面返回到父组件index.vue中。

1
<AddEmployee :showDialog="showAddDialog"    @addEmployee="loadEmployeeList"  @changedialog="updatedialog"></AddEmployee>

AddEmployee组件添加事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 关闭添加员工的窗口
const updatedialog = (value) => {
showAddDialog.value = value;
};
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog,
updatedialog, // 返回
loadEmployeeList // 返回
};

updatedialog方法中关闭了窗口。同时将该方法和loadEmployeeList方法返回。

下面是关于取消按钮

返回到add-employee.vue组件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 取消
const btnCanel = (formEl) => {
formData.value = {};
formEl.resetFields();
emit("changedialog", false);
};

return {
visible,
formData,
rules,
getDepartmentsInfo,
treeData,
showTree,
loading,
selectNode,
btnOk,
formRef,
btnCanel // 返回
}
}

这里,创建了取消的方法btnCanel,将该方法返回

1
2
3
<template>
<el-dialog title="新增员工" v-model="visible" @close="btnCanel(formRef)">
<!-- 表单 -->

el-dialog组件添加了@close事件,该事件触发说明了单击了窗口右上角的叉号图标。调用btnCanel方法。

1
<el-button size="small"  @click="btnCanel(formRef)">取消</el-button>

这里给取消按钮也添加了单击事件,事件触发以后调用btnCanel方法。

以上是前端代码的实现。

11.2 服务端接口实现

UsersController.cs控制器中,实现新增员工的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[HttpPost]
[Authorize]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task <IActionResult> AddUser([FromBody]UserInfoDto userInfoDto)
{
// 根据接收到的部门名称,查询部门
var departMent = await this._departmentService.LoadEntities(d => d.DepartmentName == userInfoDto.DepartmentName).FirstOrDefaultAsync();

UserInfo userInfo = new UserInfo() {

PhotoUrl= "/images/f.jpg", // 上传大家可以自己实现
UserName = userInfoDto.UserName,
UserPhone = userInfoDto.UserPhone,
CreateTime = userInfoDto.CreateTime,
DelFlag = Convert.ToBoolean(DelFlagEnum.Normal),
Gender = userInfoDto.Gender,
UpdateTime = userInfoDto.UpdateTime,
UserPassword = userInfoDto.UserPassword,
UserEmail = userInfoDto.UserEmail,
Department = departMent, // 将部门信息赋值给UserInfo对象中的Department属性

};
await _userInfoService.InsertEntityAsync(userInfo);
return Ok(new ApiResult<UserInfoDto>() {Success=true,Message="添加用户成功",Data= userInfoDto }); // 这里返回的是userInfoDto这个数据传输对象
}

接收数据,需要定义UserInfoDto来实现,原因是:原有的UserInfo这个实体类中没有DepartmentName这个部门名称的属性,而我们自己定义UserInfoDto这个数据传输对象,可以添加该属性。

在上面的代码中,我们没有使用自动映射AutoMapper,是我们自己实现了映射,代码稍微有点麻烦,但是大家要理解原理。

WebApi项目中创建的models目录下面创建UserInfoDto.cs这个数据传输对象,该对象中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
namespace Cms.WebApi.models
{
public class UserInfoDto
{
public string? UserName { get; set; }
/// <summary>
/// 密码
/// </summary>
public string? UserPassword { get; set; }
/// <summary>
/// 邮箱
/// </summary>
public string? UserEmail { get; set; }
/// <summary>
/// 电话
/// </summary>
public string? UserPhone { get; set; }

/// <summary>
/// 性别:0表示男,1表示女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 用户头像,存储头像路径
/// </summary>
public string? PhotoUrl { get; set; }
/// <summary>
/// 主键id
/// </summary>
public long Id { get; set; }

/// <summary>
/// 添加数据时间
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新数据时间
/// </summary>
public DateTime UpdateTime { get; set; }
/// <summary>
/// 软删除
/// </summary>
public bool DelFlag { get; set; }
public string? DepartmentName { get; set; } // 注意:这里定义了DepartMentName属性,表示部门的名称,接收前端传递过来的部门名称。
}

启动项目进行测试。

1
2
3
4
5
6
7
8

private IUserInfoService _userInfoService;
private IDepartmentService _departmentService;
public UsersController(IUserInfoService userInfoService,IDepartmentService departmentService)
{
this._userInfoService = userInfoService;
this._departmentService = departmentService; // 完成部门服务层的注入
}

这里还需要完成departmentService注入的操作

七、角色管理模块

什么是RBAC

RBAC(全称是Role-Based Access Control)基于角色的权限访问控制,在RBAC中,权限与角色相关联,用户通过成为合适的角色的成员从而得到这些角色的权限,这样就极大的简化了权限的管理。

RBAC是目前公认的解决大型企业的统一资源访问控制的有效方法。

1、创建角色模型

Cms.Entity类库项目中创建RoleInfo.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
namespace Cms.Entity
{
public class RoleInfo:BaseEntity<long>
{
public string? RoleName { get; set; }
public string? Remark { get; set;}
// 一个角色属于多个用户
public List<UserInfo> UserInfos { get; set; }=new List<UserInfo>();
}
}

用户与角色是多对多的关系。

修改UserInfo.cs实体类中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
/// </summary>
public string? PhotoUrl { get; set;}
/// <summary>
/// 一个员工只能属于一个部门
/// </summary>
public Department? Department { get; set; }
/// <summary>
/// -----------------------------一个员工有多个角色
/// </summary>
public List<RoleInfo> RoleInfos { get; set; } = new List<RoleInfo>();

在上面的代码中,指定了一个用户有多个角色。

创建RoleInfoConfig.cs,对应的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace Cms.Entity
{
public class RoleInfoConfig : IEntityTypeConfiguration<RoleInfo>
{
public void Configure(EntityTypeBuilder<RoleInfo> builder)
{
builder.ToTable("T_RoleInfos");
builder.Property(x => x.RoleName).HasMaxLength(20).IsRequired();
builder.Property(x => x.Remark).IsRequired();
// 配置了多对多的关系
//中间表是T_UserInfos_RoleInfos
builder.HasMany<UserInfo>(s => s.UserInfos).WithMany(s => s.RoleInfos).UsingEntity(a => a.ToTable("T_UserInfos_RoleInfos"));
}
}
}

下面修改MyDbContext.cs中的代码,如下所示:

1
2
3
4
5
6
public class MyDbContext:DbContext
{
public DbSet<UserInfo>UserInfos { get; set; }
public DbSet<Department> DepartmentInfos { get; set; }
// 创建了RoleInfos这个DbSet
public DbSet<RoleInfo> RoleInfos { get; set; }

在上面的代码中,创建了RoleInfos这个DbSet.

下面进行数据迁移的操作

[程序包管理器控制台]中,选择Cms.EntityFrameWorkCore

执行如下命令,完成数据的迁移操作。

1
2
Add-Migration CreateRoleInfo
Update-database

2、前端角色列表布局实现

Views目录下面创建roles文件夹,在该文件夹中创建index.vue组件,该组件中的基本代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<div class="dashboard-container">
<div class="app-container">
<el-card>
<!-- 新增角色按钮 -->
<el-row style="height: 60px">
<el-button size="small" type="primary">新增角色</el-button>
</el-row>
<!-- 表格 -->
<el-table :border="true">
<el-table-column label="序号" width="120" />
<el-table-column label="角色名称" width="240" />
<el-table-column label="描述" />
<el-table-column label="操作">
<el-button size="small" type="success">分配权限</el-button>
<el-button size="small" type="primary">编辑</el-button>
<el-button size="small" type="danger">删除</el-button>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<el-row type="flex" justify="center" style="height: 60px">
<!-- 分页组件 -->
<el-pagination layout="prev,pager,next" :total="100" />
</el-row>
</el-card>
</div>
</div>
</template>


以上是角色列表的基本布局。

下面定义对应的前端角色列表组件对应的路由规则。

router/modules目录下面创建roleInfo.js,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 导出角色的路由规则
import Layout from "@/layout";
export default {
path: "/roles", // 路由地址
name: "roles", // 给模块的一级路由设置一个name属性,后面设置权限的时候会使用到(注意这里如果只是给父级添加name属性在浏览器的控制台中会给出警告信息,为了解决这个问题,也需要在下面的子路由中添加name,当然这里可以先暂时的将name属性去掉,后面讲解到权限的时候在添加)
// 都采用了Layout组件中的布局
component: Layout,
children: [
{
path: "", // 这里写成一个空字符串,当访问的地址是`/permission`的时候,不但会展示layout布局页面,而会展示下面component中指定的页面
name:'rolesChild',
component: () => import("@/views/roles"),
// 路由元信息,可以存储任何的数据
meta: {
title: "角色管理", // 这里保存的是title,目的:会读取作该属性作为左侧的菜单项。
icon:"setting"
},
},
],
};

对应的router目录下的index.js文件中的代码,如下所示:

1
2
3
import employeesRouter from './modules/employees'
import departmentsRouter from './modules/departments'
import roleRouter from './modules/roleInfo' // ---------------导入roleInfo路由规则

指定动态路由规则:

1
2
3
4
5
6
// 动态路由
export const asyncRoutes =[
employeesRouter,
departmentsRouter,
roleRouter //---------------指定角色的路由规则
];

返回浏览器,查看前端展示效果。

3、角色列表接口定义

下面先创建数据仓库接口

定义IRoleInfoService.cs,代码如下所示:

1
2
3
4
5
6
7
8

namespace Cms.IRepository
{
public interface IRoleInfoRepository:IBaseRepository<RoleInfo>
{
}
}

数据仓储的具体实现,如下所示:

1
2
3
4
5
6
7
namespace Cms.Repository
{
public class RoleInfoRepository:BaseRepository<RoleInfo>,IRoleInfoRepository
{
public RoleInfoRepository(MyDbContext context) : base(context) { }
}
}

定义服务的接口

1
2
3
4
5
6
namespace Cms.IService
{
public interface IRoleInfoService:IBaseService<RoleInfo>
{
}
}

服务的具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace Cms.Service
{
public class RoleInfoService:BaseService<RoleInfo>,IRoleInfoService
{
private readonly IRoleInfoRepository _roleInfoRepository;
public RoleInfoService(IRoleInfoRepository roleInfoRepository)
{
base.repository = roleInfoRepository;
_roleInfoRepository = roleInfoRepository;
}

}
}

构建搜索条件,在Cms.Entity项目中的Search文件夹中创建RoleSearch.cs,代码如下所示:

1
2
3
4
5
6
7
8
namespace Cms.Entity.Search
{
public class RoleSearch:BaseSearch
{
public string? RoleName { get; set; }
public string? Remark { get; set; }
}
}

定义服务层中,关于获取角色列表的接口

1
2
3
4
5
6
7
8
namespace Cms.IService
{
public interface IRoleInfoService:IBaseService<RoleInfo>
{
IQueryable<RoleInfo> LoadSearchEntities(RoleSearch roleInfoSearch, bool delFlag);
}
}

具体实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
namespace Cms.Service
{
public class RoleInfoService:BaseService<RoleInfo>,IRoleInfoService
{
private readonly IRoleInfoRepository _roleInfoRepository;
public RoleInfoService(IRoleInfoRepository roleInfoRepository)
{
base.repository = roleInfoRepository;
_roleInfoRepository = roleInfoRepository;
}
// 实现搜索
public IQueryable<RoleInfo> LoadSearchEntities(RoleSearch roleInfoSearch, bool delFlag)
{
var temp = _roleInfoRepository.LoadEntities(r => r.DelFlag == delFlag);
if (!string.IsNullOrWhiteSpace(roleInfoSearch.RoleName))
{
temp = temp.Where<RoleInfo>(r=>r.RoleName!.Contains(roleInfoSearch.RoleName));
}
if (!string.IsNullOrWhiteSpace(roleInfoSearch.Remark))
{
temp = temp.Where<RoleInfo>(r=>r.Remark!.Contains(roleInfoSearch.Remark));
}
roleInfoSearch.TotalCount = temp.Count();

if (roleInfoSearch.Order)
{
return temp.OrderBy<RoleInfo, long>(u => u.Id).Skip<RoleInfo>((roleInfoSearch.PageIndex - 1) * roleInfoSearch.PageSize).Take<RoleInfo>(roleInfoSearch.PageSize);
}
else
{
return temp.OrderByDescending<RoleInfo, long>(u => u.Id).Skip<RoleInfo>((roleInfoSearch.PageIndex - 1) * roleInfoSearch.PageSize).Take<RoleInfo>(roleInfoSearch.PageSize);
}


}
}
}

构建参数

WebApi项目中的ResourceParams文件夹中创建RoleParams.cs,代码如下所示:

1
2
3
4
5
6
7
8
namespace Cms.WebApi.ResourceParams
{
public class RoleParams:BaseParams
{
public string? RoleName { get;set; }
public string? Remark { get; set; }
}
}

创建RoleInfoController.cs控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
namespace Cms.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class RolesController : ControllerBase
{
private readonly IRoleInfoService _roleInfoService;
// 完成注入操作
public RolesController(IRoleInfoService roleInfoService) {
this._roleInfoService = roleInfoService;
}
// 定义具体的方法
[HttpGet]
[Authorize]
public IActionResult GetRoles([FromQuery]RoleParams roleParams)
{
int totalCount = 0;
RoleSearch roleSearch = new RoleSearch()
{
Order = roleParams.Order,
PageIndex = roleParams.PageIndex,
PageSize = roleParams.PageSize,
Remark = roleParams.Remark,
RoleName = roleParams.RoleName,
TotalCount = totalCount
};
var roles = _roleInfoService.LoadSearchEntities(roleSearch, Convert.ToBoolean(DelFlagEnum.Normal));
if (roles.Count() <= 0)
{
return BadRequest("没有角色信息");
}
return Ok(new ApiResult<object>() { Success = true, Message = "获取角色成功", Data = new { rows = roles, total = roleSearch.TotalCount } });
}
}
}

以上是接口的具体实现。

4、前端角色信息展示

在这一小节中,我们要实现的是展示角色信息

src/api/目录下面创建role.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
import request from "@/utils/request";
// 获取角色列表
export function getRoleList(params){
return request({
method:'get',
url:'/roles',
params
})
}

params是查询参数,里面需要携带分页信息

下面修改view/roles/index.vue组件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<script>
import {reactive, ref,onMounted, toRefs} from 'vue'
import { getRoleList } from '@/api/role'; // ---导入getRoleList
export default {
name:'Role',
setup(){
const list = ref([]);
const page =reactive({
params:{
PageIndex:1,
PageSize:2,
total:0
}
})
onMounted(()=>{
getRoleListInfo();
})
const getRoleListInfo = async()=>{
const {total,rows} = await getRoleList(page.params);
// total:记录了总的记录数
// rows: 表示获取到的角色信息
page.params.total = total;
list.value = rows;
}
return {
list,
...toRefs(page)
}
}
}
</script>

在上面的代码中,导入getRoleList.

同时定义list集合存储服务端返回的角色数据。

定义page对象,对象中的params也是一个对象,其中的PageIndexPageSize分别表示当前页码和每页显示的记录数。

total:表示总的记录数。

onMounted这个钩子函数中调用了getRoleListInfo方法,在该方法调用了getRoleList方法,发送请求。

服务端返回的数据中包含了totalrows.

total:表示总的记录数

rows: 表示获取到的角色数据。

下面把数据展示到表格中。

1
2
3
4
return {
list,
...toRefs(page)
}

在返回的数据中,将page进行了解构,为了保证解构出的内容还是响应式的,这里通过toRefs函数进行了转换。

下面在表格中展示上面所返回的list中的数据,如下所示:

1
2
3
4
5
6
7
8
9
10
11
<!-- 表格 -->
<el-table :border="true" :data="list">
<el-table-column label="序号" width="120" type ="index" />
<el-table-column label="角色名称" width="240" prop ="roleName" />
<el-table-column label="描述" prop="remark" />
<el-table-column label="操作">
<el-button size="small" type="success">分配权限</el-button>
<el-button size="small" type="primary">编辑</el-button>
<el-button size="small" type="danger">删除</el-button>
</el-table-column>
</el-table>

这里给el-table组件添加data属性,绑定list,也就是指定了数据源

同时给第一列添加了type='index'表示第一列展示序号

然后给其它列通过prop属性指定要展示的数据的属性.

返回到浏览器中查看效果

下面处理分页的问题:

1
2
3
4
5
6
7
8
9
10
11
<!-- 分页组件 -->
<el-row type="flex" justify="center" style="height: 60px">
<!-- 分页组件 -->
<el-pagination
layout="prev,pager,next"
:total="params.total"
:page-size="params.PageSize"
v-model:currentPage="params.PageIndex"
@current-change="handleCurrentChange"
/>
</el-row>

这里给el-pagination组件指定了total,page-size,同时指定了currentPage表示当前页。这里需要注意指定该属性的时候是通过v-model进行了绑定。

并且指定了current-change事件,当进行页码切换的时候,会触发该事件。

从而会执行handleCurrentChange这个处理函数。该函数的实现如下所示:

1
2
3
4
5
6
7
8
9
const handleCurrentChange = (value) => {
page.params.page = value;
getRoleListInfo();
};
return {
list,
...toRefs(page),
handleCurrentChange // 返回
}

接收到的value参数中存储的就是当前切换的页码。

然后再次调用了getRoleListInfo方法,根据新的页码值重新发送请求,获取最新的数据。

handleCurrentChange函数也返回了。

1
import { ref, reactive, onMounted, toRefs } from "vue";

这里也需要导入toRefs函数

返回到浏览器中进行测试。

5、为用户分配角色

关于角色的添加,编辑与删除,大家可以自己实现。

5.1 获取用户已经具有角色接口实现

UsersController.cs控制器中添加如下方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/// <summary>
/// 获取所有角色以及用户具有的角色。
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}/roles")]
[Authorize]
public async Task<IActionResult> GetUserRoles([FromRoute]int id)
{
var userInfo =await _userInfoService.LoadEntities(u=>u.Id == id).FirstOrDefaultAsync();
if (userInfo == null)
{
return BadRequest("没有查询到对应的用户");
}
// 获取用户已经具有的角色
var userAllRoles = _userInfoService.LoadEntities(u => u.Id == id).Select(r=>r.RoleInfos);

// 要分配角色的用户以前具有哪些角色(这里只要角色编号)
var userRoleIdList = userAllRoles.Select(u=>u.Select(r=>r.Id)

// var userRoles = userInfoService.LoadEntities(u => u.Id == id).Include(u => u.RoleInfos);
// var userRoleIds = userRoles.Select(u => u.RoleInfos.Select(r => r.Id));

// 获取所有的角色
var roleInfoList = _roleInfoService.LoadEntities(r=>r.DelFlag==Convert.ToBoolean(DelFlagEnum.Normal)).ToList();
return Ok(new ApiResult<object> {Success=true,Message="获取成功", Data=new { userRoles=userRoleIdList,allRoles=roleInfoList } });
}

5.2 展示用户具有的角色-前端实现

views/employees/index.vue这个组件中,添加窗口,如下所示:

1
2
3
4
5
6
7
 <AddEmployee :showDialog="showAddDialog"    @addEmployee="loadEmployeeList"  @changedialog="updatedialog"></AddEmployee>
</div>
<!--弹出分配角色的窗口-->
<el-dialog v-model="showRoleDialog" title='为用户分配角色'>
hello

</el-dialog>

这里我们添加了一个el-dialog组件,用来展示为用户分配角色的窗口,当然,这里也可以单独的封装成一个组件也是可以的。

通过v-model的值showRoleDialog,来控制窗口的弹出与因此。

1
2
3
setup(){
const loading = ref(false);
const showRoleDialog =ref(false); // 声明showRoleDialog

在上面的代码中声明了showRoleDialog,同时也需要将其返回

1
2
3
4
5
6
7
8
9
10
11
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog,
updatedialog,
loadEmployeeList,
showRoleDialog, // 返回
}

下面在表格中添加一个分配角色的按钮,如下所示:

1
2
3
<template #default="scope">
<el-button size="small">查看</el-button>
<el-button type="primary" size="small" @click="btnRole(scope.row)">分配角色</el-button>

这里给分配角色的按钮注册了click事件,事件触发以后,调用btnRole这个函数,同时将scope.row也就是要分配角色的用户信息传递该函数中,btnRole函数的定义如下所示;

1
2
3
4
5
6
7
// 为用户分配角色
const btnRole = async(row)=>{
showRoleDialog.value = true; // 展示窗口
const {userRoles,allRoles } = await getUserRoleList(row.id);
console.log("userRoles=",userRoles);
console.log("allRoles=",allRoles);
}

通过上面的代码,我们可以看到,这里调用了getUserRoleList这个方法,并且传递了用户的编号。

api/employees.js文件中定义该方法,如下所示:

1
2
3
4
5
// 获取用户具有的角色
export function getUserRoleList(id){

return request(`/users/${id}/roles`);
}

views/employees/index.vue这个组件中,导入上面定义的getUserRoleList这个方法

1
import { getEmployeeList,getUserRoleList } from "@/api/employees";

最后将btnRole这个方法返回

1
2
3
4
5
6
7
8
9
10
11
12
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog,
updatedialog,
loadEmployeeList,
showRoleDialog,
btnRole // 返回btnRole
};

返回到浏览器中进行测试,在控制台中查看服务端返回的结果。

现在已经,获取到了所有的角色,以及用户已经具有的角色,下面要进行展示。

这里我们会给每个角色名称前面添加一个复选框,如果当前分配角色的用户以前已经具有了该角色,则让该复选框选中。

这里我们使用的是复选框CheckBox的多选框组来进行构建:

1
https://element-plus.gitee.io/zh-CN/component/checkbox.html#%E5%A4%9A%E9%80%89%E6%A1%86%E7%BB%84

下面我们先将获取到的角色数据,赋值给一个响应对象

1
2
3
4
5
6
   const showRoleDialog =ref(false);
// 定义roles响应对象
const roles=reactive({
userRoles:[], // 存储用户已经具有的角色编号
allRoles:[] // 存储所有的角色
})
1
2
3
4
5
6
7
8
// 为用户分配角色
const btnRole = async(row)=>{
showRoleDialog.value = true;
const {userRoles,allRoles } = await getUserRoleList(row.id);
roles.allRoles = allRoles;
roles.userRoles = userRoles;

}

btnRole方法中,我们将数据分别赋值给了roles这个响应式对象中的allRoles属性和userRoles属性。

最后,需要将roles这个对象返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog,
updatedialog,
loadEmployeeList,
showRoleDialog,
btnRole,
...toRefs(roles), // 这里将roles解构,然后在通过toRefs转换成响应式对象

};

下面改造dialog窗口中的内容,如下所示:

1
2
3
4
5
6
<el-dialog v-model="showRoleDialog" :title="`为用户分配角色`">
<el-checkbox-group v-model="roleIdList">
<el-checkbox v-for="item in allRoles" :label="item.id" :key="item.id">{{ item.roleName }}</el-checkbox>
</el-checkbox-group>

</el-dialog>

el-dialog窗口中使用了el-checkbox-group ,并且为其添加了v-model属性进行双向绑定,这里绑定了roleIdList

作用是:当我们选中了某些复选框后,选中的复选框的内容,会保存到roleIdList中。

下面在对el-checkbox进行循环,这里展示的是角色的名称,同时label属性的值是角色的编号,也就是说,我们选中了复选框后,要获取选中的复选框的值,而这些值就是角色的编号,当前选中的角色编号会自动的保存到roleIdList中。

1
2
3
const loading = ref(false);
const roleIdList = ref([]); // 定义roleIdList,默认值是一个数组,这里可以通过文档来查看
const showRoleDialog =ref(false);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog,
updatedialog,
loadEmployeeList,
showRoleDialog,
btnRole,
...toRefs(roles),
username,
roleIdList // 返回roleIdList
};

上面将roleIdList这个数组返回了。

返回到浏览器中查看效果。

下面要实现的就是将用户已经具有的角色选中

这个需求实现起来比较简单

1
<el-checkbox-group v-model="roleIdList">

前面,我们给el-checkbox-group 添加了v-model这个属性,实现了双向数据绑定,也就是说我们给roleIdList赋值,对应的复选框也会选中。

1
2
3
4
5
6
7
8
9
10
// 为用户分配角色
const btnRole = async(row)=>{
username.value=row.userName;
const {userRoles,allRoles } = await getUserRoleList(row.id);
roles.allRoles = allRoles;
roles.userRoles = userRoles;
// 给roleIdList赋值,注意:服务端返回的userRoles是一个二维数组,第一个元素也是一个数组,存储用户具有的角色编号
roleIdList.value= roles.userRoles[0];
showRoleDialog.value = true; // 注意:这里将这行代码放在了btnRole方法的最后,原因:先获取数据然后在弹出窗口,否则如果出现了网络比较慢的情况,就会把窗口弹出来了,但是没有数据的情况
}

返回到浏览器中进行查看。

5.3 完成角色分配

5.3.1 前端实现

首先在窗口中增加“确定与取消”按钮。

并且给“确定”按钮注册单击事件,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
<el-dialog v-model="showRoleDialog" :title="`为用户分配角色`">
<el-checkbox-group v-model="roleIdList">
<el-checkbox v-for="item in allRoles" :label="item.id" :key="item.id">{{ item.roleName }}</el-checkbox>
</el-checkbox-group>
<!-- ----------------确定取消按钮 -->
<el-row type="flex" justify="center">
<el-col :span="6">
<el-button type="primary" size="small" @click="btnRoleOk">确定</el-button>
<el-button size="small" @click="showRoleDialog=false">取消</el-button>
</el-col>
</el-row>
</el-dialog>

当单击确定按钮的时候,会执行btnRoleOk这个函数,在该函数中会发送请求。

所以这里我们还需要创建一个发送请求的方法。

src/api/employees.js文件中,添加如下方法

1
2
3
4
5
6
7
8
9
// 为用户分配角色
export function setUserRole(id,data){

return request({
method:'post',
url: `/users/${id}/roles/${data}`,
})

}

参数id,表示的是用户的编号,data就是要分配的角色的编号。

这里发送的是post请求,并且是通过url地址的方式传递参数。

返回到views/employee/index.vue这个组件中继续修改代码

1
2
3
4
5
6
7
8
9
10
11
12
13
// 完成用户角色的分配
const btnRoleOk=async()=>{
// console.log('userId=',userId.value);
// console.log("roleIdList=",roleIdList.value.join(','));
// 如果为0,表示取消了某个用户所有的角色
const ids=roleIdList.value.length>0?roleIdList.value.join(','):'0'
await setUserRole(userId.value,ids);
ElMessage({
message:"分配角色成功",
type:'success'
})

}

上面的代码实现了btnRoleOk函数,在这函数中首先从roleIdList中获取所选中的角色的编号,roleIdList前面已经绑定了复选框

1
<el-checkbox-group v-model="roleIdList">

roleIdList是一个数组,当选中了复选框后,所选中的复选框的角色编号会存储在该数组中,

在向服务端发送的时候,将该数组中存储的角色编号都转换成了字符串,然后使用逗号进行分割。

如果,在分配角色的时候,没有选择一个角色,也就是没有选择一个复选框,但是单击了确定按钮,这里会向服务端发送一个0,表示取消用户的角色(以前用户是有角色的,但是这里取消了选择)

问题是:在调用setUserRole方法的时候,需要传递用户的编号,用户的编号怎样获取呢?

1
2
const showRoleDialog =ref(false);
const userId=ref(0); // 存储要分配角色的用户编号

这里定义了userId,用来存储要分配角色的用户的编号。

1
2
3
4
5
// 为用户分配角色
const btnRole = async(row)=>{
showRoleDialog.value = true;
userId.value= row.id; // -----------------------存储用户的编号
const {userRoles,allRoles } = await getUserRoleList(row.id);

我们知道,当单击用户列表中的分配角色按钮的时候,会首先调用btnRole方法,在这方法中,传递过来的参数row中存储了要分配角色的用户信息,将其编号存储到了userId中。

所以,在btnRoleOk方法中,就可以通过userId来获取用户的编号。最后通过ElMessage组件给出相应的提示。

当然,这里需要将setUserRole方法导入进来。

1
import { getEmployeeList,getUserRoleList,setUserRole } from "@/api/employees";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog,
updatedialog,
loadEmployeeList,
showRoleDialog,
btnRole,
...toRefs(roles),
roleIdList,
btnRoleOk // btnRoleOk
};

最后,返回btnRoleOk函数

5.3.2服务端接口实现

第一步:在给某个用户分配角色的时候,我们需要获取该用户以前具有的角色,所以这里我们需要定义一个数据访问的仓储接口。

修改Cms.IRepository项目中的IRoleInfoRepository接口

1
2
3
4
5
6
7
namespace Cms.IRepository
{
public interface IRoleInfoRepository:IBaseRepository<RoleInfo>
{
Task<List<RoleInfo>> GetUserRoles(long userId);
}
}

IRoleInfoRepository接口中添加了GetUserRoles方法,该方法的作用就是根据用户的编号,查询用户具有的角色信息。

第二:实现GetUserRoles方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace Cms.Repository
{
public class RoleInfoRepository:BaseRepository<RoleInfo>,IRoleInfoRepository
{
public RoleInfoRepository(MyDbContext context) : base(context) { }

public async Task<List<RoleInfo>> GetUserRoles(long userId)
{
var userInfo = await ctx.UserInfos.Include(r => r.RoleInfos).Where(u => u.Id == userId).FirstOrDefaultAsync();

return userInfo!.RoleInfos.ToList();

}
}
}

在上面的代码中,我们通过父类 中注册的DbContext对象ctx来查询用户具有的角色信息,这里使用了include来进行关联的查询。

最后返回的就是用户以前具有的角色信息,这里将其存储到了list集合中返回。

第三:定义实现分配角色的服务接口

1
2
3
4
5
6
7
8
9
namespace Cms.IService
{
public interface IUserInfoService:IBaseService<UserInfo>
{
IQueryable<UserInfo> LoadSearchEntities(UserInfoSearch userInfoSearch, bool delFlag);
// 实现分配用户角色
Task<bool> SetUserRoles(UserInfo userInfo, List<long> roleIdList);
}
}

在上面的代码中,声明了SetUserRoles方法,用来实现给某个用户分配角色。roleIdList中存储的就是要分配的角色的编号。

第四:实现角色的分配业务

UserInfoService.cs服务类中实现了SetUserRoles方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public async Task< bool> SetUserRoles(UserInfo userInfo, List<long> strIds)
{


await _roleInfoRepository.GetUserRoles(userInfo.Id);// 获取用户以前具有的角色
// _userInfoRepository.LoadEntities(u=>u.Id== userInfo.Id).Include(u=>u.RoleInfos).ToList(); // 获取用户以前具有的角色
// 删除用户以前具有的角色信息
userInfo.RoleInfos.Clear();
// 如果strIds集合中只有一个元素,并且值是0,表示要删除当前用户所有的角色
if (strIds.Count == 1 && strIds[0] == 0)
{
return true;
}

// 完成更新操作。(所以这里最好是发送put请求)
foreach (var roleId in strIds)
{
var roleInfo = await _roleInfoRepository.LoadEntities(r => r.Id == roleId).FirstOrDefaultAsync();
userInfo!.RoleInfos.Add(roleInfo!);

}
return true;
}

在上面的代码中,根据传递过来的用户编号,调用roleInfoRepository数据仓储中的GetUserRoles 方法,来获取该用户以前具有的角色信息。

在对用户进行分配角色的时候,我们是先将用户以前具有的角色删除。这里通过Clear方法做了删除的标记。

然后,判断一下传递过来的角色编号数量是否是1,并且值是0,如果该条件成立,有两种可能

第一:什么角色也没有选,直接单击了确定按钮(所有的复选框都没有选中)

第二:把用户以前具有的角色都取消了,然后单击了确定按钮。

不管以上那种情况,这里我们都做了删除标记,即使第一种情况,进行删除操作也不会出错。

下面根据传递过来的角色编号,获取到每个角色信息,添加到userInfo.RoleInfos这个集合中。

第五:控制器实现

UsersController控制器中添加SetUserRoles方法,注意指定的路由规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 [HttpPost("{id}/roles/{roleIds}")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
[Authorize]
// 通过路由接收传递过来的用户编号与角色编号
public async Task<IActionResult> SetUserRoles([FromRoute]int id, [FromRoute] string roleIds)
{
var userInfo = await _userInfoService.LoadEntities(u => u.Id == id).FirstOrDefaultAsync();
if (userInfo == null)
{
return BadRequest("没有查询到对应的用户");
}
List<long> list = new List<long>(); //
// roleIds是一个字符串,这里按照字符串进行分割,返回的是一个数组,然后遍历该数组,将其存储的每个角色编号转成成整型。
var strIds = roleIds.Split(',');
foreach (var rId in strIds)
{
list.Add(Convert.ToInt32(rId));
}
// 调用服务层中的SetUserRoles方法,完成用户角色的分配。
await _userInfoService.SetUserRoles(userInfo,list);


return Ok(new ApiResult<object> { Success = true, Message = "获取成功"});

}

启动项目进行测试。

八、权限管理模块

现在已经为用户分配了角色,接下来需要做的就是给角色分配权限,这样用户就有相应的权限了。

在应用系统中,权限是以什么样的形式展现出来呢?对菜单的访问,页面上按钮的可见性,后端接口的控制,都要进行充分考虑.

前端菜单:根据是否有请求菜单权限进行动态加载

前端按钮(功能权限):根据是否具有次权限进行按钮的显示与隐藏的控制

后端:前端发送请求到后端的接口,有必要对接口的访问进行权限的验证。

针对以上的需求,在设计中可以将菜单,按钮,后端Api请求作为资源,这样就构成了基于RBAC的另外一种授权模型(用户-角色-权限-资源)

针对以上权限模型,其中权限究竟是属于菜单,按钮,还是API的权限呢?那就需要在设计权限实体模型(数据库权限表)的时候添加类型加以区分,例如权限类型1是菜单,2是功能(按钮),3是api

1、创建权限模型

1.1 基础模型创建

权限模型与角色模型是多对多的关系

权限模型与菜单权限,功能权限,Api权限都是一对一的关心,这里将不同的权限拆分到不同的模型(数据表)中的目的是为了方便扩展。

Cms.Entity类库项目中先创建权限模型PermissionInfo.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
namespace Cms.Entity
{
/// <summary>
/// 权限模型
/// </summary>
public class PermissionInfo:BaseEntity<long>
{
/// <summary>
/// 权限名称,如果是菜单权限,表示的就是菜单名称, 如果是按钮,表示的就是按钮的名称,如果是API,则表示API的名称
/// </summary>
public string? PermissionName { get; set; }
/// <summary>
/// 权限类型:1为菜单,2为功能(按钮),3为API
/// </summary>
public int PermissionType { get; set; }

/// <summary>
/// 权限编码,有响应的编码,就表示拥有该权限,这里主要用于前端路由权限的判断
/// </summary>
public string? PermissionCode { get; set; }

/// <summary>
/// 权限描述
/// </summary>
public string? PermissionDescription { get; set;}
/// <summary>
/// 权限是有父子关系的,例如当单击某个菜单,呈现一个页面,页面中有按钮,单击按钮的时候会访问某个API
/// 这样按钮,API,这些权限都是菜单的子权限。
/// </summary>

public int ParentId { get; set; }


}
}

Api权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace Cms.Entity
{
/// <summary>
/// API权限
/// </summary>
public class PermissionApi:BaseEntity<long>
{
/// <summary>
/// API的地址
/// </summary>
public string? ApiUrl { get; set; }
/// <summary>
/// 请求类型
/// </summary>
public string? ApiMethod { get; set; }


}
}

菜单权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace Cms.Entity
{
/// <summary>
/// 菜单权限,菜单名称在PermissionInfo模型中已经声明了
/// </summary>
public class PermissionMenu:BaseEntity<long>
{
// 菜单图标
public string? MenuIcon { get; set; }

//排序
public string? MenunOrder { get; set; }

}
}

功能权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace Cms.Entity
{
/// <summary>
/// 功能权限,就是对页面中的按钮进行控制
/// </summary>
public class PermissionPoint:BaseEntity<long>
{
/// <summary>
/// 按钮的样式
/// </summary>
public string? PointClass { get; set; }
/// <summary>
/// 按钮的图标
/// </summary>
public string? PointIcon { get; set;}

/// <summary>
/// 按钮的状态
/// </summary>
public int PointStatus { get; set;}
}
}

1.2 配置权限模型关系

先配置权限模型与角色模型之间的关系

PermissionInfo.cs这个模型中添加了RoleInfos,表示一个权限有多个角色

1
2
3
4
5
6
7

public string? ParentId { get; set; }

/// <summary>
/// 一个权限有多个角色
/// </summary>
public List<RoleInfo> RoleInfos { get; set; } = new List<RoleInfo>();

RoleInfo.cs中添加了PermissionInfos,表示一个角色有多个权限

1
2
3
4
5
6
7
8
public class RoleInfo:BaseEntity<long>
{
public string? RoleName { get; set; }
public string? Remark { get; set;}
public List<UserInfo> UserInfos { get; set; }=new List<UserInfo>();
//一个角色有多个权限
public List<PermissionInfo>PermissionInfos { get; set; } =new List<PermissionInfo>();
}

下面创建PermissionInfoConfig.cs配置类

代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
public class PermissionInfoConfig : IEntityTypeConfiguration<PermissionInfo>
{
public void Configure(EntityTypeBuilder<PermissionInfo> builder)
{
builder.ToTable("T_PermissionInfos");
builder.Property(x => x.PermissionName).HasMaxLength(20).IsRequired();
builder.Property(x => x.PermissionCode).HasMaxLength(20).IsRequired();
builder.Property(x => x.PermissionDescription).HasMaxLength(500).IsRequired();
// 中间表是T_RoleInfos_Permissions
builder.HasMany<RoleInfo>(s => s.RoleInfos).WithMany(s => s.PermissionInfos).UsingEntity(a => a.ToTable("T_RoleInfos_Permissions"));
}
}

MyDbContext中添加PermissionInfos这个DbSet

1
2
3
4
5
6
7
public class MyDbContext:DbContext
{
public DbSet<UserInfo>UserInfos { get; set; }
public DbSet<Department> DepartmentInfos { get; set; }
public DbSet<RoleInfo> RoleInfos { get; set; }
// 添加了PermissionInfos
public DbSet<PermissionInfo> PermissionInfos { get; set; }

以上是角色模型与权限模型关系的配置。

下面就是权限模型与菜单权限模型,功能权限模型,Api权限模型的配置。

继续修改PermissionInfo.cs模型中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int ParentId { get; set; }
/// <summary>
///----------------------- Api权限
/// </summary>
public PermissionApi? PermissionApi { get; set; }

/// <summary>
///------------------ 菜单权限
/// </summary>
public PermissionMenu? PermissionMenu { get; set; }

/// <summary>
/// ---------------------功能权限
/// </summary>
public PermissionPoint? PermissionPoint { get; set; }


/// <summary>
/// 一个权限有多个角色
/// </summary>
public List<RoleInfo> RoleInfos { get; set; } = new List<RoleInfo>();

在上面的代码中添加了 Api权限,菜单权限,功能权限

修改PermissionApi.cs模型中的代码

1
2
3
4
5
6
7
8
 /// <summary>
/// 请求类型
/// </summary>
public string? ApiMethod { get; set; }
//-----------------添加了PermissionInfo
public PermissionInfo? PermissionInfo { get; set; }
//---------------显式的指定外键
public long PermissionId { get; set; }

PermissionPoint.cs模型中的代码

1
2
3
4
5
6
7
8
/// <summary>
/// 按钮的状态
/// </summary>
public string? PointStatus { get; set;}
//-----------------添加了PermissionInfo
public PermissionInfo? PermissionInfo { get; set; }
//---------------显式的指定外键
public long PermissionId { get; set; }

PermissionMenu.cs模型中的代码

1
2
3
4
5
6
7

//排序
private string? MenunOrder { get; set; }
//-----------------添加了PermissionInfo
public PermissionInfo? PermissionInfo { get; set; }
//---------------显式的指定外键
public long PermissionId { get; set; }

下面还需要配置PermissionInfoConfig.cs模型,与上面三个模型一对一的关心(当然,在以上三个模型中进行配置也可以)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void Configure(EntityTypeBuilder<PermissionInfo> builder)
{
builder.ToTable("T_PermissionInfos");
builder.Property(x => x.PermissionName).HasMaxLength(20).IsRequired();
builder.Property(x => x.PermissionCode).HasMaxLength(20).IsRequired();
builder.Property(x => x.PermissionDescription).HasMaxLength(500).IsRequired();
// 中间表是T_RoleInfos_Permissions
builder.HasMany<RoleInfo>(s => s.RoleInfos).WithMany(s => s.PermissionInfos).UsingEntity(a => a.ToTable("T_RoleInfos_Permissions"));


// 配置一对一的关系
builder.HasOne<PermissionApi>(p => p.PermissionApi).WithOne(p => p.PermissionInfo).HasForeignKey<PermissionApi>(d => d.PermissionId);

builder.HasOne<PermissionMenu>(p => p.PermissionMenu).WithOne(p => p.PermissionInfo).HasForeignKey<PermissionMenu>(d => d.PermissionId);

builder.HasOne<PermissionPoint>(p => p.PermissionPoint).WithOne(p => p.PermissionInfo).HasForeignKey<PermissionPoint>(d => d.PermissionId);

}

下面创建PermissionApiConfig.cs

1
2
3
4
5
6
7
8
9
public class PermissionApiConfig : IEntityTypeConfiguration<PermissionApi>
{
public void Configure(EntityTypeBuilder<PermissionApi> builder)
{
builder.ToTable("T_PermissionApis");
builder.Property(x => x.ApiUrl).HasMaxLength(200).IsRequired();
builder.Property(x => x.ApiMethod).HasMaxLength(20).IsRequired();
}
}

下面创建PermissionMenuConfig.cs

1
2
3
4
5
6
7
8
9
public class PermissionMenuConfig : IEntityTypeConfiguration<PermissionMenu>
{
public void Configure(EntityTypeBuilder<PermissionMenu> builder)
{
builder.ToTable("T_PermissionMenus");
builder.Property(x => x.MenuIcon).HasMaxLength(200);
builder.Property(x => x.MenunOrder).HasMaxLength(20);
}
}

下面创建PermissionPointConfig.cs

1
2
3
4
5
6
7
8
9
public class PermissionPointConfig : IEntityTypeConfiguration<PermissionPoint>
{
public void Configure(EntityTypeBuilder<PermissionPoint> builder)
{
builder.ToTable("T_PermissionPoints");
builder.Property(x => x.PointClass).HasMaxLength(200);
builder.Property(x => x.PointIcon).HasMaxLength(20);
}
}

下面还需要修改MyDbContext.cs文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
public class MyDbContext:DbContext
{
public DbSet<UserInfo>UserInfos { get; set; }
public DbSet<Department> DepartmentInfos { get; set; }
public DbSet<RoleInfo> RoleInfos { get; set; }
public DbSet<PermissionInfo> PermissionInfos { get; set; }
// 添加如下三个对应的Dbset
public DbSet<PermissionApi> PermissionApiInfos { get; set; }

public DbSet<PermissionMenu> PermissionMenuInfos { get; set; }

public DbSet<PermissionPoint> PermissionPointInfos { get; set; }

最后进行数据的迁移

[程序包管理器控制台]中一定要选择[Cms.EntityFrameworkCore]这个项目

1
2
Add-Migration AddPermission
Update-database

2、添加权限

2.1 添加权限接口创建

第一:先实现所有的数据仓储

定义权限数据仓储接口,如下所示:

1
2
3
4
5
6
namespace Cms.IRepository
{
public interface IPermissionInfoRepository:IBaseRepository<PermissionInfo>
{
}
}

定义权限数据仓储实现类,如下所示:

1
2
3
4
5
6
7
8
namespace Cms.Repository
{
public class PermissionInfoRepository:BaseRepository<PermissionInfo>,IPermissionInfoRepository
{
public PermissionInfoRepository(MyDbContext context) : base(context) { }
}
}

定义菜单权限数据仓储接口,如下所示:

1
2
3
4
5
6
namespace Cms.IRepository
{
public interface IPermissionMenuRepository:IBaseRepository<PermissionMenu>
{
}
}

菜单权限数据仓储具体实现,如下所示:

1
2
3
4
5
6
7
8
namespace Cms.Repository
{
public class PermissionMenuRepository:BaseRepository<PermissionMenu>,IPermissionMenuRepository
{
public PermissionMenuRepository(MyDbContext context) : base(context) { }
}
}

Api权限数据仓储接口

1
2
3
4
5
6
7
8

namespace Cms.IRepository
{
public interface IPermissionApiRepository:IBaseRepository<PermissionApi>
{
}
}

Api权限数据仓储具体实现,如下所示:

1
2
3
4
5
6
7
8
namespace Cms.Repository
{
public class PermissionApiRepository:BaseRepository<PermissionApi>, IPermissionApiRepository
{
public PermissionApiRepository(MyDbContext context) : base(context) { }
}
}

功能权限数据仓储接口实现,如下所示:

1
2
3
4
5
6
7
namespace Cms.IRepository
{
public interface IPermissionPointRepository:IBaseRepository<PermissionPoint>
{
}
}

功能权限数据仓储具体实现

1
2
3
4
5
6
7
8
namespace Cms.Repository
{
public class PermissionPointRepository:BaseRepository<PermissionPoint>, IPermissionPointRepository
{
public PermissionPointRepository(MyDbContext context) : base(context) { }
}
}

第二:权限服务创建

实现权限服务的接口

1
2
3
4
5
6
namespace Cms.IService
{
public interface IPermissionInfoService:IBaseService<PermissionInfo>
{
}
}

下面是权限服务的具体实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace Cms.Service
{
public class PermissionInfoService:BaseService<PermissionInfo>, IPermissionInfoService
{
private readonly IPermissionInfoRepository _permissionInfoRepository;
public PermissionInfoService(IPermissionInfoRepository permissionInfoRepository)
{
base.repository = permissionInfoRepository;
_permissionInfoRepository = permissionInfoRepository;
}
}
}

针对其他权限的服务,如果需要再进行创建。

下面来实现具体的业务

在保存权限信息的时候,我们需要对权限的类别进行判断,所以这里需要再创建一个枚举类型。

Cms.Entity项目的Enum目录下面创建PermissionTypeEnum.cs文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace Cms.Entity.Enum
{
public enum PermissionTypeEnum
{
/// <summary>
/// 菜单权限
/// </summary>
PermissionMenu =1,
/// <summary>
/// 功能权限
/// </summary>
PermissionPoint =2,
/// <summary>
/// Api权限
/// </summary>
PermissionApi =3
}
}

下面还需要定义一个保存权限信息的接口,所以需要修改IPermissionInfoService.cs这个业务接口中的代码,需要添加如下方法:

1
2
3
4
5
6
7
8
namespace Cms.IService
{
public interface IPermissionInfoService:IBaseService<PermissionInfo>
{
Task<bool> AddPermission(PermissionDto permissionDto);
}
}

这里我们需要一个PermissionDto类来接收前端传递过来的权限信息,在Cms.Entity这个类库项目中创建Permission文件夹,在该文件夹中创建PermissionDto.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
namespace Cms.Entity.Permission
{
public class PermissionDto:BaseEntity<long>
{
/// <summary>
/// 权限名称,如果是菜单权限,表示的就是菜单名称, 如果是按钮,表示的就是按钮的名称,如果是API,则表示API的名称
/// </summary>
public string? PermissionName { get; set; }
/// <summary>
/// 权限类型:1为菜单,2为功能(按钮),3为API
/// </summary>
public int PermissionType { get; set; }

/// <summary>
/// 权限编码,有响应的编码,就表示拥有该权限,当然,这里也可以使用主键id作为权限编码
/// </summary>
public string? PermissionCode { get; set; }

/// <summary>
/// 权限描述
/// </summary>
public string? PermissionDescription { get; set; }
/// <summary>
/// 权限是有父子关系的,例如当单击某个菜单,呈现一个页面,页面中有按钮,单击按钮的时候会访问某个API
/// 这样按钮,API,这些权限都是菜单的子权限。
/// </summary>

public int ParentId { get; set; }


/// <summary>
/// API的地址
/// </summary>
public string? ApiUrl { get; set; }
/// <summary>
/// 请求类型
/// </summary>
public string? ApiMethod { get; set; }


// 菜单图标
public string? MenuIcon { get; set; }

//排序
public string? MenunOrder { get; set; }


/// <summary>
/// 按钮的样式
/// </summary>
public string? PointClass { get; set; }
/// <summary>
/// 按钮的图标
/// </summary>
public string? PointIcon { get; set; }

/// <summary>
/// 按钮的状态
/// </summary>
public int? PointStatus { get; set; }
}

这个类中定义的属性都是需要保存的权限信息。

下面我们要在具体的业务类PermissionInfoService.cs中实现前面接口所以定义的AddPermission这个方法,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

public class PermissionInfoService:BaseService<PermissionInfo>, IPermissionInfoService
{
// 下面需要注入多个数据仓储来完成不同类别的权限信息的保存
private readonly IPermissionInfoRepository _permissionInfoRepository;
private readonly IPermissionMenuRepository _permissionMenuRepository;
private readonly IPermissionApiRepository _permissionApiRepository;
private readonly IPermissionPointRepository _permissionPointRepository;
public PermissionInfoService(IPermissionInfoRepository permissionInfoRepository, IPermissionMenuRepository permissionMenuRepository, IPermissionApiRepository permissionApiRepository, IPermissionPointRepository permissionPointRepository)
{
base.repository = permissionInfoRepository;
_permissionInfoRepository = permissionInfoRepository;
_permissionMenuRepository = permissionMenuRepository;
_permissionApiRepository = permissionApiRepository;
_permissionPointRepository = permissionPointRepository;
}
/// <summary>
/// 添加权限
/// </summary>
/// <param name="permissionDto"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public Task<bool> AddPermission(PermissionDto permissionDto)
{
// 创建基础权限模型对象
PermissionInfo permissionInfo = new PermissionInfo();
permissionInfo.ParentId = permissionDto.ParentId;
permissionInfo.PermissionCode = permissionDto.PermissionCode;
permissionInfo.PermissionName = permissionDto.PermissionName;
permissionInfo.PermissionType=permissionDto.PermissionType;
permissionInfo.PermissionDescription=permissionDto.PermissionDescription;
permissionInfo.CreateTime=DateTime.Now;
permissionInfo.UpdateTime=DateTime.Now;
permissionInfo.DelFlag=Convert.ToBoolean(DelFlagEnum.Normal);
_permissionInfoRepository.InsertEntityAsync(permissionInfo); // 注意:这里需要对PermissionInfo这个基础权限模型的信息进行保存
bool result = false;
switch (permissionInfo.PermissionType)
{
// 判断是否是菜单权限
case (int)PermissionTypeEnum.PermissionMenu:
// 创建菜单权限模型对象
PermissionMenu permissionMenu = new PermissionMenu();
permissionMenu.MenuIcon = permissionDto.MenuIcon;
permissionMenu.MenunOrder = permissionDto.MenunOrder;
permissionMenu.CreateTime = DateTime.Now;
permissionMenu.UpdateTime = DateTime.Now;
permissionMenu.DelFlag = Convert.ToBoolean(DelFlagEnum.Normal);
// 这里需要把基础permissionInfo模型对象赋值给permissionMenu中的PermissionInfo,才能完成一对一的操作
permissionMenu.PermissionInfo = permissionInfo;
// 把菜单权限模型对象添加到DbContext中
this._permissionMenuRepository.InsertEntityAsync(permissionMenu);
result = true;
break;
// 功能权限的判断
case (int)PermissionTypeEnum.PermissionPoint:
// 创建功能权限模型对象
PermissionPoint point = new PermissionPoint();
point.PointClass=permissionDto.PointClass;
point.PointIcon=permissionDto.PointIcon;
point.PointStatus=permissionDto.PointStatus;
point.CreateTime = DateTime.Now;
point.UpdateTime = DateTime.Now;
point.DelFlag = Convert.ToBoolean(DelFlagEnum.Normal);
point.PermissionInfo = permissionInfo; // 注意
this._permissionPointRepository.InsertEntityAsync(point); // 注意
result = true;
break;
// 判断是否是Api权限
case (int)PermissionTypeEnum.PermissionApi:
// 创建Api权限模型对象
PermissionApi permissionApi = new PermissionApi();
permissionApi.ApiMethod = permissionDto.ApiMethod;
permissionApi.ApiUrl = permissionDto.ApiUrl;
permissionApi.CreateTime = DateTime.Now;
permissionApi.UpdateTime = DateTime.Now;
permissionApi.DelFlag = Convert.ToBoolean(DelFlagEnum.Normal);
permissionApi.PermissionInfo = permissionInfo; // 注意
this._permissionApiRepository.InsertEntityAsync(permissionApi); // 注意
result = true;
break;
default:

break;


}
return Task.FromResult(result);

}
}

在以上的操作中,我们没有使用AutoMapper进行自动映射,这里是进行了手动的映射,相对来讲比较麻烦一下,但是需要大家理解整个原理。

下面实现具体的控制。

创建PermissionsController.cs控制器,该控制器中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

[Route("api/[controller]")]
[ApiController]
public class PermissionsController : ControllerBase
{
//注入权限的服务对象
private readonly IPermissionInfoService _permissionInfoService;
public PermissionsController(IPermissionInfoService permissionInfoService)
{
this._permissionInfoService = permissionInfoService;
}
[HttpPost]
[Authorize] // 这里可以先不添加该特性,在swagger/index.html页面中测试该接口
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> AddPermissions([FromBody]PermissionDto permissionDto)
{
// 调用服务层中的AddPermission方法完成权限信息的保存
await this._permissionInfoService.AddPermission(permissionDto);
return Ok(new ApiResult<object>() {Message="添加权限成功",Success=true });
}
}

下面返回到浏览器中,在swagger/index.html页面中测试该添加权限的接口。

3、权限列表

3.1 权限列表接口创建

这里我们先来构建权限列表展示的接口,后面再来考虑前端的设计。

这里也需要对权限的信息进行搜索,所以在Cms.Entity类库项目中的Search文件夹中创建PermissionSearch.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
namespace Cms.Entity.Search
{
public class PermissionSearch:BaseSearch
{
public string? PermissionName { get; set; }
public string? PermissionDescription { get;set; }
}
}

下面在权限业务的接口中定义获取权限列表的方法,如下所示:

1
2
3
4
5
6
7
8
9
10
namespace Cms.IService
{
public interface IPermissionInfoService:IBaseService<PermissionInfo>
{
Task<bool> AddPermission(PermissionDto permissionDto);
// 获取权限列表
IQueryable<PermissionInfo> LoadSearchEntities(PermissionSearch permissionSearch, bool delFlag);
}
}

下面要实现上面定义的LoadSearchEntities这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// <summary>
/// 获取权限列表信息
/// </summary>
/// <param name="permissionSearch"></param>
/// <param name="delFlag"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public IQueryable<PermissionInfo> LoadSearchEntities(PermissionSearch permissionSearch, bool delFlag)
{
// 这里我们要查询的表是T_PermissionPoints,因为该表中保存了权限的基础信息,其他资源权限表这里就不用查询
var temp = _permissionInfoRepository.LoadEntities(u => u.DelFlag == delFlag);
if (!string.IsNullOrWhiteSpace(permissionSearch.PermissionName))
{
temp = temp.Where<PermissionInfo>(u => u.PermissionName!.Contains(permissionSearch.PermissionName));
}
if (!string.IsNullOrWhiteSpace(permissionSearch.PermissionDescription))
{
temp = temp.Where<PermissionInfo>(u => u.PermissionDescription!.Contains(permissionSearch.PermissionDescription));
}
permissionSearch.TotalCount = temp.Count();
if (permissionSearch.Order)
{
return temp.OrderBy<PermissionInfo, long>(u => u.Id).Skip<PermissionInfo>((permissionSearch.PageIndex - 1) * permissionSearch.PageSize).Take<PermissionInfo>(permissionSearch.PageSize);
}
else
{
return temp.OrderByDescending<PermissionInfo, long>(u => u.Id).Skip<PermissionInfo>((permissionSearch.PageIndex - 1) * permissionSearch.PageSize).Take<PermissionInfo>(permissionSearch.PageSize);
}
}

这里,需要进行搜索,所以在Cms.Entity 这个类库项目中的Search文件夹中,创建PermissionSearch.cs类,该类的代码如下所示:

1
2
3
4
5
6
7
8
9
namespace Cms.Entity.Search
{
public class PermissionSearch:BaseSearch
{
public string? PermissionName { get; set; }
public string? PermissionDescription { get;set; }
}
}

下面需要控制器PermissionsController.cs中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
   [HttpGet]
// 这里需要构建搜索参数
public IActionResult GetPermissions([FromQuery] PermissionParams permissionParams)
{

int totalCount = 0;
PermissionSearch permissionSearch = new PermissionSearch()
{
PermissionName = permissionParams.PermissionName,
PermissionDescription = permissionParams.PermissionDescription,

Order = permissionParams.Order,
PageIndex = permissionParams.PageIndex,
PageSize = permissionParams.PageSize,
TotalCount = totalCount
};
var permissionsList = _permissionInfoService.LoadSearchEntities(permissionSearch, Convert.ToBoolean(DelFlagEnum.Normal));
if (permissionSearch.TotalCount <= 0)
{
return NotFound("没有权限信息");
}
// 返回前端需要展示的权限信息
var permissions = permissionsList.Select(u => new { Id = u.Id, PermissionName = u.PermissionName, PermissionCode = u.PermissionCode, CreateTime = u.CreateTime, PermissionDescription= u.PermissionDescription,ParentId=u.ParentId, PermissionType=u.PermissionType });

return Ok(new ApiResult<object>() { Success = true, Message = "获取权限信息成功", Data = new { rows = permissions, total = permissionSearch.TotalCount } });


}

这里需要在WebApi项目中ResourceParams文件夹中创建PermissionParams.cs类来来接收搜索和分页的参数

1
2
3
4
5
6
7
8
namespace Cms.WebApi.ResourceParams
{
public class PermissionParams:BaseParams
{
public string? PermissionName { get; set; }
public string? PermissionDescription { get; set; }
}
}

返回到浏览器中对该接口进行测试。

注意:前端展示的时候,只需要展示菜单权限和功能权限的信息就可以了,不需要展示Api的权限,所以在服务的LoadSearchEntities方法中,我们进行过滤的时候,可以在添加如下的过滤条件。

1
2
3
4
public IQueryable<PermissionInfo> LoadSearchEntities(PermissionSearch permissionSearch, bool delFlag)
{
//以下过滤权限的时候,有添加了过滤的条件,注意小括号的使用
var temp = _permissionInfoRepository.LoadEntities(u => u.DelFlag == delFlag&&(u.PermissionType==Convert.ToInt32(PermissionTypeEnum.PermissionMenu) ||u.PermissionType==Convert.ToInt32(PermissionTypeEnum.PermissionPoint)));

3.2 权限列表前端展示

定义路由规则

router/modules目录下面创建permission.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 导出权限的路由规则
import Layout from "@/layout";
export default {
path: "/permissions", // 路由地址
name: "permissions", // 给模块的一级路由设置一个name属性,后面设置权限的时候会使用到(注意这里如果只是给父级添加name属性在浏览器的控制台中会给出警告信息,为了解决这个问题,也需要在下面的子路由中添加name,当然这里可以先暂时的将name属性去掉,后面讲解到权限的时候在添加)
// 都采用了Layout组件中的布局
component: Layout,
children: [
{
path: "", // 这里写成一个空字符串,当访问的地址是`/permission`的时候,不但会展示layout布局页面,而会展示下面component中指定的页面
name:'permissionsChild',
component: () => import("@/views/permissions"),
// 路由元信息,可以存储任何的数据
meta: {
title: "权限管理", // 这里保存的是title,目的:会读取作该属性作为左侧的菜单项。
icon:"lock"
},
},
],
};

router/index.js文件中,添加以上的路由规则对象。

1
2
import roleRouter from './modules/roleInfo'
import permissionRouter from './modules/permission' // 导入permissionRouter
1
2
3
4
5
6
7
// 动态路由
export const asyncRoutes =[
employeesRouter,
departmentsRouter,
roleRouter,
permissionRouter // 添加permissionRouter
];

下面在views目录下面创建permissions文件夹,在该文件夹中创建index.vue组件,

该组件的初步代码如下:

1
2
3
<template>
权限列表
</template>

返回到浏览器中进行查看。

index.vue这个组件,做一个简单的布局,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div class="dashboard-container">
<div class="app-container">
<!-- 靠右的按钮 ,这里使用了前面封装的公共组件PageTools-->
<PageTools>
<template #after > <!--使用插槽-->
<el-button type="primary" size="small" >添加权限</el-button>
</template>
</PageTools>
<!-- 表格 -->
<el-table border>
<el-table-column align="center" label="权限名称"></el-table-column>
<el-table-column align="center" label="权限标识"></el-table-column>
<el-table-column align="center" label="权限描述"></el-table-column>
<el-table-column align="center" label="操作">
<template #default="scope">
<el-button>添加</el-button>
<el-button>编辑</el-button>
<el-button>删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>

创建请求的方法。

src/api目录下面创建permission.js文件,添加如下代码

1
2
3
4
5
6
7
import request from '@/utils/request'
export function getPermissionList(params){
return request({
url:'/permissions',
params
});
}

修改views/permissions/index.vue组件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<script>
import {onMounted, ref} from 'vue'
import {getPermissionList} from '@/api/permission' // 导入getPermissionList方法
export default{
name:'Permission',
setup(){
const list=ref([]);
const loading = ref(false);
const page = ref({
PageIndex: 1, // 当前页码
PageSize: 12, // 每页显示的记录数,这里服务端要求是PageSize
total:0
});
onMounted(()=>{
loadPermissionsList();
})
const loadPermissionsList=async()=>{
loading.value = true;
const { rows, total } = await getPermissionList(page.value);
list.value = rows;
page.value.total = total;
loading.value = false;
}
// 实现分页
const handleCurrentChange=(value)=>{
page.value.PageIndex = value;
loadPermissionsList();
}
return {
list,
page,
handleCurrentChange
}
}
}
</script>

继续修改模版中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!-- 表格 -->
<el-table border :data="list"> <!--把返回的list赋值给data属性,作为表格的数据源-->
<!--通过prop属性指定要渲染的属性名-->
<el-table-column align="center" label="权限名称" prop="permissionName"></el-table-column>
<el-table-column align="center" label="权限标识" prop="permissionCode"></el-table-column>
<el-table-column align="center" label="权限描述" prop="permissionDescription"></el-table-column>
<el-table-column align="center" label="操作">
<template #default="scope">
<el-button size="small">添加</el-button>
<el-button size="small">编辑</el-button>
<el-button size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<el-row
type="flex"
justify="center"
align="middle"
style="height: 60px"
>
<el-pagination
layout="prev, pager, next"
:total="page.total"
:page-size="page.PageSize"
v-model:currentPage="page.PageIndex"
@current-change="handleCurrentChange"
/>
</el-row>

在上面的代码中,给表格指定了数据源,同时也添加了分页组件。

返回到浏览器中查看效果。

虽然,在表格中展示了权限的信息,但是,数据的展示没有呈现出树形结构。

我们知道,权限是有父子关系的,所以再进行展示的时候,最好是通过树形方式来进行展示。

在表格中怎样呈现出树形的结构呢?

参考文档:

1
https://element-plus.gitee.io/zh-CN/component/table.html#%E6%A0%91%E5%BD%A2%E6%95%B0%E6%8D%AE%E4%B8%8E%E6%87%92%E5%8A%A0%E8%BD%BD

通过文档,我们可以看到在使用树形表格的时候,需要注意两点

第一:服务端返回的数据如果包含了children属性,则会被当作树形数据,而我们这里服务端返回的数据中没有children,所以需要前端进行转换

第二:就是table表格中必须添加row-key属性,该属性的值是唯一的。

1
2
<!-- 表格 -->
<el-table border :data="list" row-key="id">

这里添加了row-key属性,取值是id

1
2
import {getPermissionList} from '@/api/permission'
import {tranListToTreeData} from '@/utils/index' // 导入tranListToTreeData方法
1
2
3
4
5
6
7
const loadPermissionsList=async()=>{
loading.value = true;
const { rows, total } = await getPermissionList(page.value);
list.value =tranListToTreeData(rows,0); // 使用tranListToTreeData方法将rows中存储的数据转换成树形结构,0表示的就是根节点的编号
page.value.total = total;
loading.value = false;
}

返回到浏览器中进行查看。

在这里,权限的展示,我们要求只展示两层,所以在第二层中,就不需要再出现添加按钮了。

修改index.vue组件,

1
2
<template  #default="scope">
<el-button size="small" v-if="scope.row.permissionType===1">添加</el-button>

这里,我们添加了一个判断,就是判断返回的permissionType的值是否是1,如果是1,表示的就是菜单权限,所以才会出现添加按钮.

也就是说,功能权限不需要再添加对应的子权限了。

而功能权限实际上菜单权限的子权限。

4、创建添加权限的窗口

4.1 基础表单创建

修改views/Permission/index.vue这个组件中修改代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 <!-- 新增窗口 -->
<el-dialog title="新增权限" v-model="showAddDialog">
<el-form label-width="120px">
<el-form-item label="名称">
<el-input v-model="formData.PermissionName" style="width: 90%;" />
</el-form-item>
<el-form-item label="标识">
<el-input v-model="formData.PermissionCode" style="width: 90%;" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="formData.PermissionDescription" style="width: 90%;" />
</el-form-item>
</el-form>
<template #footer>
<el-row type="flex" justify="center">
<el-col :span="6">
<el-button size="small">确定</el-button>
<el-button type="primary" size="small" @click="showAddDialog=false">取消</el-button>
</el-col>
</el-row>
</template>
</el-dialog>


</div>
</template>

这里创建一个dialog窗口,然后添加了v-model属性,该属性的值是showAddDialog,通过shoAddDialog控制窗口的显示与隐藏,

dialog窗口中添加了el-form表单,表单元素通过v-model双向绑定了formData响应式对象中的相关属性。

setup入口函数中定义formData,showAddDialog

1
2
3
4
5
6
7
8
9
const showAddDialog=ref(true); // 测试完,将该值修改为false,默认情况下窗口不会弹出来。

const formData=reactive({
PermissionName:"",
PermissionCode:"",
PermissionDescription:'',
PermissionType:1, // 权限的类型
ParentId:0, // 因为是树形结构,所以添加的权限属于哪个节点下
})

同时将其返回

1
2
3
4
5
6
7
return {
list,
page,
handleCurrentChange,
formData, // 返回
showAddDialog // 返回
}

返回到浏览器中进行查看。

4.2 完成权限的添加

第一步:给添加权限的按钮注册单击事件。

我们知道,权限列表页面的顶部有一个添加权限的按钮,单击该按钮,添加的是菜单权限

而单击每一行中的添加权限的按钮,添加的权限是功能权限(按钮权限)

所以,单击不同按钮的时候,在所调用的方法中要进行判断。

1
2
3
4
5
<PageTools>
<template #after >
<el-button type="primary" size="small" @click="addPermission(1,0)" >添加权限</el-button>
</template>
</PageTools>

以上是顶部按钮,在调用addPermission方法的时候,传递的参数是1,表示将要添加菜单权限。(在服务端我们规定了菜单权限对应的类别就是1),同时这里添加的是根权限,所以第二个参数是0。

1
2
<template  #default="scope">
<el-button size="small" @click="addPermission(2,scope.row.id)" v-if="scope.row.permissionType===1">添加</el-button>

每行中的添加按钮触发后,调用的addPermission方法,传递的参数是2,表示添加的是功能权限。同时第二个参数,表示的是父id,

也就是当前所添加的权限是哪个权限的子权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 添加权限
const addPermission=(type,pid)=>{
// 弹出窗口
showAddDialog.value = true;
// 记录当前添加的权限的类型与父编号
formData.PermissionType = type;
formData.ParentId = pid;
}

return {
list,
page,
handleCurrentChange,
formData,
showAddDialog,
addPermission // 将addPermission返回
}

以上是addPermission方法的初步定义。(该方法的作用:将窗口弹出,同时记录下权限的类型与父编号)

同时这里还需要注意的一点就是 :给el-form标签,添加model属性,绑定formData

1
2
3
<!-- 新增窗口 -->
<el-dialog title="新增权限" v-model="showAddDialog">
<el-form label-width="120px" :model="formData">

关于表单校验,大家可以自己完善。

当单击了新增权限这个窗口中的确定按钮的时候,才会真正的发送请求,完成权限的添加。

给【确定】按钮注册单击事件

1
2
<el-col :span="6">
<el-button size="small" @click="btnOk">确定</el-button>

下面看一下btnOk方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {getPermissionList,addPermissionInfo} from '@/api/permission' // 导入addPermissionInfo方法  
// 完成权限的添加
const btnOk=async()=>{
await addPermissionInfo(formData);// 调用addPermissionInfo方法,发送请求.formData对象中存储的就是权限数据
ElMessage.success("权限添加成功");
showAddDialog.value = false;
loadPermissionsList();// 重新加载数据
}
return {
list,
page,
handleCurrentChange,
formData,
showAddDialog,
addPermission,
btnOk// 返回btnOk方法
}

src/api/permission.js文件中添加addPermissionInfo方法,如下所示:

1
2
3
4
5
6
7
export function addPermissionInfo(data){
return request({
method:'post',
url:'/permissions',
data
})
}

返回到浏览器端进行测试。

当然,这里大家可以把新增权限前面做的稍微详细点,我们知道,当添加菜单权限的时候 ,可以添加MenuIcon等值,而添加功能权限的时候,可以添加PointClass等值。

所以在添加的时候,可以根据不同的类型进行判断,展示成不同的输入框。

5、为角色分配权限

5.1 展示分配权限的窗口

在角色列表组件中,添加如下布局代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</el-card>
<!-- 给角色分配权限的窗口 -->
<el-dialog title="分配权限" v-model="showPermDialog"> <!--showPermDialog控制窗口的显示与隐藏-->
<!--permData就是要展示的数据-->
<!--defaultProps:定义显示的字段的名称以及子属性显示的字段的名称-->
<el-tree :data="permData" :props="defaultProps" default-expand-all></el-tree>
<template #footer>
<el-row type="flex" justify="center">
<el-col :span="6">
<el-button type="primary" size="small">确定</el-button>
<el-button size="small" @click="showPermDialog=false">取消</el-button>
</el-col>
</el-row>
</template>
</el-dialog>




</div>
1
2
3
4
5
  const list = ref([]);
// 定义以下属性
const showPermDialog=ref(false);
const permData=ref([]);
const defaultProps={};
1
2
<el-table-column label="操作">
<el-button size="small" type="success" @click="setPermission">分配权限</el-button>

当单击分配权限按钮的时候,调用setPermission方法,在该方法中需要将以上的窗口弹出

1
2
3
4
5
6
7
8
9
10
11
12
const setPermission=()=>{
showPermDialog.value=true; // 展示窗口
}
return {
list,
...toRefs(page),
handleCurrentChange,
showPermDialog, // 返回
permData, // 返回
setPermission, // 返回
defaultProps // 返回
}

下面要做的就是发送请求,获取所有的权限数据,将其展示到树形结构中。

ap/permission.js文件中定义如下方法:

1
2
3
4
5
export function getAllPermissions(){
return request({
url:'/permissions/per'
})
}

以上代码是发送请求,获取所有的权限。

返回到views/roles/index.vue这个组件中,继续修改代码:

1
2
import {getAllPermissions} from '@/api/permission'
import {tranListToTreeData} from'@/utils/index'

在上面的代码中,导入了getAllPermissions这个方法和tranListToTreeData方法,这个方法的作用就是将数据转换成树形结构。

1
2
3
4
5
6
// 获取所有的权限
const setPermission=async()=>{
showPermDialog.value=true;
const {rows}= await getAllPermissions();
permData.value =tranListToTreeData(rows,0);
}

在上面的代码中,我们调用getAllPermissions方法,获取所有的权限数据,然后传递tranListToTreeData方法进行转换,第二个参数,表示的是根节点的编号是0.

转换好的数据赋值给permData

1
<el-tree :data="permData" :props="defaultProps" default-expand-all></el-tree>

我们知道permData已经与el-tree组件的data属性关联,data属性表示的就是要展示的数据,

但是还需要注意props属性,该属性关联的是defaultProps,

1
2
3
4
5
6
 const permData=ref([]);
// 完善了defaultProps的内容
const defaultProps={
label:"permissionName",
children:'children'
};

以上完善了defaultProps对象,label属性的取值是permissionName,表示在树形结构中展示的就是权限的名称。

children属性的取值是children,表示构建树形结构,就是通过children来完成的。

当然,这里我们调用getAllPermissions发送的获取所有权限请求对应的接口,在服务端还没有创建,服务端以前创建的是分页展示。

而这里我们需要获取到所有的权限数据,而不需要分页的内容。

PermissionsController.cs控制器中添加如下方法

1
2
3
4
5
6
7
8
9
10
11
12
[HttpGet("per")]
public IActionResult GetAllPermissions()
{
var permissionsList= _permissionInfoService.LoadEntities(p => true);
if (permissionsList.Count() <= 0)
{
return NotFound("没有权限信息");
}
var permissions = permissionsList.Select(u => new { Id = u.Id, PermissionName = u.PermissionName, PermissionCode = u.PermissionCode, CreateTime = u.CreateTime, PermissionDescription = u.PermissionDescription, ParentId = u.ParentId, PermissionType = u.PermissionType });

return Ok(new ApiResult<object>() { Success = true, Message = "获取权限信息成功", Data = new { rows = permissions} });
}

返回到浏览器中进行测试。

5.2 为角色分配权限前端处理1

当单击分配角色按钮,在弹出的窗口中可以以树形结构展示权限信息了。

这里,我们还需要在每个权限名称前面添加复选框,方便进行选择。

参考文档:

1
https://element-plus.gitee.io/zh-CN/component/tree.html#%E5%8F%AF%E9%80%89%E6%8B%A9
1
2
3
<!-- 给角色分配权限的窗口 -->
<el-dialog title="分配权限" v-model="showPermDialog">
<el-tree :data="permData" show-checkbox check-strictly :props="defaultProps" default-expand-all></el-tree>

这里修改了views/roles/index.vue这个组件中添加的el-tree组件。

给改组件添加了show-checkbox表示在每个角色名称前面添加复选框

check-strictly :如果表示选择了某个根权限,不会选择它下面的子权限。

下面我们要处理的就是,当弹出以上窗口的时候,应该将要分配权限的角色,以前具有的权限选中。

1
2
3
4
<el-table-column label="描述" prop="remark" />
<el-table-column label="操作">
<template #default="scope"><!--添加了默认插槽--->
<el-button size="small" type="success" @click="setPermission(scope.row.id)">分配权限</el-button>

当单击分配权限按钮的时候,会调用前面我们所创建的setPermission这个方法,同时会将要分配权限的角色的编号传递到该方法中。

1
2
3
4
5
6
const setPermission=async(id)=>{
showPermDialog.value=true;
const {rows}= await getAllPermissions();
permData.value =tranListToTreeData(rows,0);
roleId.value=id;//记录要分配权限的角色的编号。
}

setPermission方法中,我们将传递过来的角色的编号赋值给了一个响应对象roleId进行了保存。

1
2
3
const permData=ref([]);
// 记录要分配权限的角色编号
const roleId = ref(0);

所以在上面的代码中,我们创建了roleId这个响应对象。

下面我们要做的就是把角色编号发送到服务端,服务端根据该角色编号,查询一下该角色以前具有哪些权限。

5.3 实现获取角色已经具有权限接口实现

PermissionsController.cs控制器中添加如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 获取角色已经具有的权限
[HttpGet("{id}/roles")]
public async Task<IActionResult> GetPermissionsByRoleId([FromRoute]int id)
{
var roleInfo = await _roleInfoService.LoadEntities(r => r.Id == id).FirstOrDefaultAsync();
if (roleInfo == null)
{
return NotFound("没有找到对应的角色");
}
// 获取当前角色已经拥有的权限信息
var rolesAllPermissions = _roleInfoService.LoadEntities(r => r.Id == id).Select(r => r.PermissionInfos);
var permIdList = rolesAllPermissions.Select(u => u.Select(r => r.Id)).ToList();
// var permissions = roleInfoService.LoadEntities(r => r.Id == id).Include(r => r.PermissionInfos);
// var permissionIds = permissions.Select(u => u.PermissionInfos.Select(u => u.Id));
return Ok(new ApiResult<object>() { Success = true, Data = permIdList });

}

5.4 角色分配权限前端处理2

在上一小节中,我们已经创建好了获取角色已经具有的权限对应的接口。

在前端的api/permission.js文件中应该创建一个方法请求该接口。

1
2
3
4
5
6
7
// 获取角色已经具有的权限编号
export function getRolePermissionIdList(id){
return request({
url:`/permissions/${id}/roles`
})
}

返回到views/roles/index.vue这个组件中

1
import {getAllPermissions,getRolePermissionIdList} from '@/api/permission' // 导入getRolePermissionIdList方法
1
2
3
4
5
6
7
8
9
10
// 获取所有的权限
const setPermission=async(id)=>{
showPermDialog.value=true;
const {rows}= await getAllPermissions();
permData.value =tranListToTreeData(rows,0);
roleId.value=id;//记录要分配权限的角色的编号。
const result = await getRolePermissionIdList(id) // 调用getRolePermissionIdList方法
selectCheck.value=result[0];

}

setPermission方法中,调用了我们刚刚封装好的getRolePermissionIdList方法,传递了角色的编号,发送请求,获取了当前这个角色已经具有的权限信息。当然这里服务端返回的都是权限的编号。

而且,返回的是一个二维数组,二维数组中的第一个元素也是一个数组,里面存储的都是当前角色已经具有的权限编号。

然后赋值给了响应对象selectCheck

1
2
const roleId = ref(0);
const selectCheck =ref([]);

这里我们定义了selectCheck这个响应对象,并且将其返回

1
2
3
4
5
6
7
8
9
10
return {
list,
...toRefs(page),
handleCurrentChange,
showPermDialog,
permData,
setPermission,
defaultProps,
selectCheck // 返回
}

下面需要给树形组件添加属性。

1
2
3
4
5
6
7
8
 <!-- 给角色分配权限的窗口 -->
<el-dialog title="分配权限" v-model="showPermDialog">
<el-tree :data="permData"
show-checkbox check-strictly
:props="defaultProps"
// 添加了default-checked-keys 和node-key
default-expand-all :default-checked-keys="selectCheck" node-key="id">
</el-tree>

在上面的el-tree这个组件中,添加了default-checked-keys属性,该属性关联的值就是selectCheck, 也就是,树形中的复选框要想选中,需要根据selectCheck这个数组来确定。问题是,需要根据selectCheck这个数组中存储的id值来选中对应的复选框,所以这里又添加了node-key这个属性,取值是id,来作为唯一的标识。

返回到浏览器中进行测试(可以现在数据库中输入数据,方便进行测试)

5.5 完成角色权限的分配

下面我们首先要获取的就是所选中的权限的编号。

1
2
3
4
5
6
<el-tree :data="permData" 
show-checkbox check-strictly
:props="defaultProps"
ref="permTree"
default-expand-all :default-checked-keys="selectCheck" node-key="id">
</el-tree>

这里,给el-tree这个组件,添加了ref属性,关联了permTree.

1
2
// 关联el-tree组件
const permTree =ref(null);

当单击分配权限窗口中的确定按钮以后,会触发click事件。

1
<el-button type="primary" size="small" @click="setPermissionOk">确定</el-button>

调用setPermissionOk方法。在该方法中,会通过permTree获取到el-tree组件。

然后获取到所选中的权限的编号。

1
2
3
4
// 给角色分配权限
const setPermissionOk=()=>{
console.log("keys=",permTree.value.getCheckedKeys());//通过getCheckedKeys方法获取选中的权限编号
}

最终是调用了el-tree组件中的getCheckedKeys方法获取了所选中的权限编号。

最后返回

1
2
3
4
5
6
7
8
9
10
11
12
return {
list,
...toRefs(page),
handleCurrentChange,
showPermDialog,
permData,
setPermission,
defaultProps,
selectCheck,
permTree, // 返回
setPermissionOk // 返回
}

可以返回到浏览器中,看一下控制台中的打印。

5.5.2 服务端接口实现

第一:在Cms.IRepository类库项目中的IPermissionInfoRepository接口中声明一个用来获取角色已有权限的方法,如下所示:

1
2
3
4
5
6
7
8
namespace Cms.IRepository
{
public interface IPermissionInfoRepository:IBaseRepository<PermissionInfo>
{
Task<List<PermissionInfo>> GetRolePermissions(long roleId);
}
}

第二:在Cms.Repository类库项目中的PermissionInfoRepository.cs数据仓储中实现上面的方法,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
public class PermissionInfoRepository:BaseRepository<PermissionInfo>,IPermissionInfoRepository
{
public PermissionInfoRepository(MyDbContext context) : base(context) { }

public async Task<List<PermissionInfo>> GetRolePermissions(long roleId)
{
var roleInfo = await ctx.RoleInfos.Include(r => r.PermissionInfos).Where(u => u.Id == roleId).FirstOrDefaultAsync();
// 获取指定角色对应的权限信息
return roleInfo!.PermissionInfos.ToList();
}
}

第三:在IPermissionInfoService.cs这个服务接口中声明一个用来完成给角色分配权限的方法,如下所示:

1
2
3
4
5
6
7
public interface IPermissionInfoService:IBaseService<PermissionInfo>
{
Task<bool> AddPermission(PermissionDto permissionDto);
IQueryable<PermissionInfo> LoadSearchEntities(PermissionSearch permissionSearch, bool delFlag);
// 声明一个给指定角色分配权限的方法
Task<bool> SetRolePermission(RoleInfo roleInfo, List<long> permissionIdList);
}

第四:在PermissionInfoService.cs中实现上面所声明的SetRolePermission方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 完成给角色分配权限
public async Task<bool> SetRolePermission(RoleInfo roleInfo, List<long> permissionIdList)
{
// 获取角色已经具有的权限
await _permissionInfoRepository.GetRolePermissions(roleInfo.Id);
// _roleInfoRepository.LoadEntities(p=>p.Id == roleInfo.Id).Include(r=>r.PermissionInfos).ToList();
// 删除角色已经具有的权限
roleInfo.PermissionInfos.Clear();
if (permissionIdList.Count == 1 && permissionIdList[0] == 0)
{
return true;
}
foreach(var pId in permissionIdList)
{
var permissionInfo =await _permissionInfoRepository.LoadEntities(p => p.Id == pId).FirstOrDefaultAsync();
roleInfo.PermissionInfos.Add(permissionInfo!);
}
return true;
}

第五:PermissionsController.cs控制器中的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 给角色分配权限
[HttpPost("{id}/roles/{permissionId}")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
[Authorize]
public async Task<IActionResult> SetRolePermissions([FromRoute]int id, [FromRoute]string permissionId)
{
var roleInfo = await _roleInfoService.LoadEntities(r=>r.Id==id).FirstOrDefaultAsync();
if(roleInfo == null)
{
return NotFound("没有找到对应的角色信息");
}
List<long> list = new List<long>(); // 注意这里的list一定是long类型因为下面要进行并集运算,类型必须要统一
var strIds = permissionId.Split(',');
foreach (var pId in strIds)
{
list.Add(Convert.ToInt32(pId));
}
await _permissionInfoService.SetRolePermission(roleInfo,list);

return Ok(new ApiResult<object> { Success = true, Message = "获取成功"});
}
5.5.3 前端实现

src/api/permission.js文件中,添加如下方法:

1
2
3
4
5
6
7
// 给角色分配权限
export function setRolePermissions(id,data){
return request({
method:'post',
url:`/permissions/${id}/roles/${data}`
})
}

返回到Views/roles/index.vue组件,继续修改代码,如下所示:

1
import {getAllPermissions,getRolePermissionIdList,setRolePermissions} from '@/api/permission'

在上面的代码中,导入setRolePermissions方法

修改setPermissionOk方法中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 给角色分配权限
const setPermissionOk=async()=>{
// console.log("keys=",permTree.value.getCheckedKeys());
// 获取到选中的权限的编号,然后转换成字符串
const ids = permTree.value.getCheckedKeys().length>0? permTree.value.getCheckedKeys().join(','):"0";
await setRolePermissions(roleId.value,ids); // 把角色编号以及权限编号发送到服务端
ElMessage({
message:"分配权限成功",
type:'success'
})
showPermDialog.value= false;// 关闭窗口
selectCheck.value =[]// ------------注意:这里一定要清空数据

}

返回到浏览器中进行测试。

九、权限过滤

1、前端权限实现思路

在权限管理页面中,我们设置了一个标识,这个标识可以和我们的路由模块进行关联,也就是说,如果用户拥有了这个标识,那么用户就可以拥有这个路由模块,如果没有这个标识,就不能访问路由模块。

怎样动态的添加路由呢?

vue-router中提供了一个叫做addRoutes的方法,这个方法的含义是:动态添加路由规则

思路如下:

用户登录—-获取标识—标识和路由进行关联,筛选出具有权限的路由—通过addRoutes添加。

2、通过Vuex管理权限模块

src/store/modules目录下面创建一个permission.js文件,该文件的初步代码

1
2
3
4
5
6
7
8
9
10
// 处理权限路由模块
const state = {}
const mutations = {}
const actions = {};
export default {
namespaced:true,
state,
mutations,
actions,
}

这个Vuex模块,专门就是用来权限路由的。

当然,需要把该模块添加到Vuex的容器中。修改src/store/index.js文件中,对代码进行修改,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createStore } from 'vuex'
import user from './modules/user'
import getters from "./getters";
import permission from './modules/permission'; // 导入permission模块
export default createStore({

modules: {
user,
permission // 将permission模块添加到Store容器中
},
getters
})

返回到permisson模块中,继续修改代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 处理权限路由模块
import {constantRoutes} from '@/router'
const state = {
// 最开始,用户一定会拥有静态路由的权限(例如登录的路由,首页的路由)
routes:constantRoutes // routes:表示的是路由表,当前用户所拥有的所有路由的数组。

}
const mutations = {}
const actions = {};
export default {
namespaced:true,
state,
mutations,
actions,
}

state这个状态对象中添加了一个属性routes,表示的是路由表,当前用户所拥有的所有路由都会存储在该数组中。

而我们也知道,用户默认是拥有静态路由的,例如:登录,首页等。所以这里导入进来,然后赋值给routes属性。

当然,这里我们把静态路由的名字修改成了constantRoutes,所以在router/index.js文件中,修改一下静态路由的名字,如下:

1
2
3
4
5
6
export  const constantRoutes = [
{
path: '/',
redirect:'/dashboard',
component: Layout,
children:[

上面把名字修改成了constantRoutes,同时,在创建路由对象router的时候也需要修改一下

1
2
3
4
const router = createRouter({
history: createWebHashHistory(),
routes:[...constantRoutes,...asyncRoutes] // 这把静态路由的名字修改成了constantRoutes
})

下面继续修改store/modules/permission.js模块中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 处理权限路由模块
import {constantRoutes} from '@/router'
const state = {
// 最开始,用户一定会拥有静态路由的权限(例如登录的路由,首页的路由)
routes:constantRoutes // routes:表示的是路由表,当前用户所拥有的所有路由的数组。

}
const mutations = {
// newRoutes:用户登录成功后需要添加的路由
setRoutes(state,newRoutes){
// 当用户登录成功后,state对象中的routes属性中存储的就是登录用户具有的静态路由,以及其他的动态路由。
state.routes=[...constantRoutes,...newRoutes]
// 注意以下写法,从业务的角度来讲是不正确的.
//state.routes=[...state.routes,...newRoutes]
}
}
const actions = {};
export default {
namespaced:true,
state,
mutations,
actions,
}

如果我们想修改state中的routes这个状态,需要再mutations中创建一个方法来完成修改。

这里我们创建了setRoutes方法:该方法的作用就是修改routes这个状态值。

每次用户登录以后,我们都是将静态路由和该用户拥有的动态路由合并以后,重新赋值给rotues这个状态。

这里需要注意的一点,就是如下写法,在当前的场景中不符合业务的要求:

1
state.routes=[...state.routes,...newRoutes]

在以上的写法中,是将新的路由规则,与state.routes中存储的原有的路由规则进行合并。

这样会导致出现一种情况:一个用户登录以后,含有了静态路由和动态路由,这时候他退出了,另外一个用户登录以后,就有可能获取到第一个用户的动态路由(静态路由大家都是一样的,而且是在一台电脑中进行登录的,并且浏览器没有关闭。)。

3、Vuex筛选权限路由

3.1 获取用户具有的权限编码–接口实现

在前面我们讲解过,当用户登录以后,我们要获取权限的标识,然后与路由进行关联。

怎样获取标识呢?

src/permission.js这个文件中,我们前面实现了前置的路由守卫,当用户登录成功以后,我们又执行了如下代码:

1
2
3
if(!store.state.user.userInfo.Id){
await store.dispatch("user/getUserInfo");
}

这里执行了store/modules/user.js中的actions中的getUserInfo方法来获取用户的信息。而这时候,服务端是可以将该用户具有的权限标识返回过来的。

当然,目前服务端接口中,还没有返回该用户具有的权限标识,所以这里我们需要完善一下该接口。

修改IUserInfoService.cs服务端接口中的代码,如下所示:

1
2
3
4
5
6
7
public interface IUserInfoService:IBaseService<UserInfo>
{
IQueryable<UserInfo> LoadSearchEntities(UserInfoSearch userInfoSearch, bool delFlag);
Task<bool> SetUserRoles(UserInfo userInfo, List<long> roleIdList);
// 获取用户具有的权限编码
Task<Dictionary<string, List<string>>> GetUserPermissionCodes(long userId);
}

在上面的代码中,声明了GetUserPermissionCodes方法,用来获取用户具有的权限编码,该方法返回的数据类型是Dictionary字典类型、key是字符串,值是一个list集合。

定义字典集合的原因是,最终返回的用户具有的权限编码的格式是:

1
2
3
4
5
6
7
8
9
10
11
"menus": [
"UserManager",
"DepartmentManager",
"RoleManager"
],
"points": [
"UserAdd",
"DepartmentAdd",
"RoleAdd"
],
"apis": []

下面返回到UserInfoService.cs这个服务类中,实现上面接口中所声明的GetUserPermissionCodes方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/// <summary>
/// 获取登录用户具有的权限标识
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<Dictionary<string,List<string>>> GetUserPermissionCodes(long userId)// UserInfo userInfo
{
// _userInfoRepository.LoadEntities(u => u.Id == userInfo.Id).Include(u => u.RoleInfos).ToList();
// var userRoles = userInfo.RoleInfos;
var userRoles = await _roleInfoRepository.GetUserRoles(userId);// 获取用户具有的角色

List<string> menus = new List<string>(); // 存储菜单权限的编码
List<string> points = new List<string>(); // 存储功能权限的编码
List<string> apis = new List<string>(); // 存储api权限的编码
foreach (var role in userRoles)
{
// 获取每个角色具有的权限信息
var rolePermissions = await _permissionInfoRepository.GetRolePermissions(role.Id);
//也可以采用如下方法,更加的简单
// roleInfoRepository.LoadEntities(r => r.Id == role.Id).Include(r => r.PermissionInfos).ToList();
// var rolePermissions = role.PermissionInfos;

// 对获取到的权限的信息进行遍历,然后判断对应的类型,添加到相应的list集合中。
foreach (var permission in rolePermissions)
{
if (permission.PermissionType == (int)PermissionTypeEnum.PermissionMenu)
{
menus.Add(permission.PermissionCode!);
}else if (permission.PermissionType == (int)PermissionTypeEnum.PermissionPoint)
{
points.Add(permission.PermissionCode!);
}
else
{
apis.Add(permission.PermissionCode!);
}
}
}
Dictionary<string,List<string>>dict =new Dictionary<string,List<string>>();
dict.Add("menus", menus);
dict.Add("points", points);
dict.Add("apis",apis);

return dict; // dict
}
1
2
3
4
5
6
7
8
9
10
 private readonly IUserInfoRepository _userInfoRepository;
private readonly IRoleInfoRepository _roleInfoRepository;
//这里需要注入权限的数据仓储
private readonly IPermissionInfoRepository _permissionInfoRepository;
public UserInfoService(IUserInfoRepository userInfoRepository,IRoleInfoRepository roleInfoRepository,IPermissionInfoRepository permissionInfoRepository) {
base.repository = userInfoRepository;
_userInfoRepository = userInfoRepository;
_roleInfoRepository = roleInfoRepository;
_permissionInfoRepository = permissionInfoRepository;
}

下面看一下UsersController.cs控制器的具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[HttpGet]
/* [Authorize]*/ // 可以先注释该特性,方便进行接口的测试
public async Task<IActionResult> GetUserInfo([FromQuery]string userPhone)
{
if (string.IsNullOrWhiteSpace(userPhone))
{
return BadRequest("手机号不能为空");
}
var user = await _userInfoService.LoadEntities(u => u.UserPhone == userPhone).FirstOrDefaultAsync();
if(user == null)
{
return BadRequest("没有查找到对应用户");
}
// ---------------------这里还需要查询该用户具有的权限信息。
var dirct = await _userInfoService.GetUserPermissionCodes(user.Id);

//------------------返回的具体的data数据中包含了用户的信息,以及权限编号的信息.
// 当然用户信息,这里我们只是根据需要返回了用户的编号,用户名和手机号,头像地址

return Ok(new ApiResult<object>() { Success = true, Message = "获取用户成功", Data = new { user = new {Id=user.Id,UserName = user.UserName,UserPhone=user.UserPhone,PhotoUrl=user.PhotoUrl}, roles = dirct } });
}

下面可以进行接口的测试。

当然,前面我们讲过,是在前端项目的store/modules/user.js文件中发送的请求,请求以上接口的,现在该接口中返回的数据内容和格式发生了变化,所以我们也需要对前端做进一步的修改处理。

1
2
3
4
5
6
7
8
// 获取用户资料信息(该方法中代码不需要修改,修改的是mutations中的setUserInfo方法)
async getUserInfo(context){
const userPhone =localStorage.getItem("userPhone");
const result = await getUserInfo(userPhone);
// console.log('result =',result);
context.commit("setUserInfo",result);
return result;
},

我们知道,在以上的actions中所定义的getUserInfo这个方法中,向服务端发送了请求,并且传递了的数据是手机号,服务端返回的数据通过commit方法,提交到了mutations中,来根据服务端返回的数据修改state的状态,下面我们来看一下再mutations中定义的setUserInfo这个方法,

1
2
3
4
setUserInfo(state,payload){
const {user,roles } =payload // 解构出服务端返回的数据。
state.userInfo = user;
},

setUserInfo这个Mutations方法中,把服务端返回的数据进行解构,然后把用户数据重新赋值给了state状态中的userInfo属性。

通过浏览器查看前端页面,可以看到页面中可以照常展示用户名和用户的头像。

3.2 前端路由筛选——-

我们现在已经能够从服务端获取到权限的编码了,下面我们要做的就是把服务端返回的这些权限的编码与路由规则进行绑定。

怎样绑定呢?就是将动态路由中的name属性与权限编码一致就可以了。

例如,员工管理的权限编码是UserManager,而当前登录用户有该权限编码,说明当前登录用户具有员工管理的权限,同时就应该能够访问员工管理菜单,怎样能够访问呢?就是让员工管理对应的路由规则的name属性值也是UserManager就可以了。

这里,我们需要进行路由的筛选,筛选出与这些服务端返回的权限的标识一样的name属性值相同的动态路由。

在哪里筛选呢?—

这里,我们需要在store/modules/permission.js文件中进行筛选,这里定义一个actions方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const actions = {
// 筛选路由
// 第二个参数为当前用户所拥有的菜单权限(也是服务端返回的菜单权限的标识)
filterRoutes(context,menus){
const routes=[];
// 筛选出动态路由中能够和menus匹配上的路由
menus.forEach(key=>{
// key:就是菜单权限标识
// asyncRoutes就是定义了`router/index.js`文件中的动态路由的数组
// 这里就是查找在`asyncRoutes`数组中的动态路由对象的name属性值是否与key相同,如果相同,就筛选出来,也就是表示当前用户拥有该路由权限,
// filter返回的是一个数组,这里是将其解构后,把成员都添加到了routes数组中。routes数组中存储的就是满足权限要求的路由数组。也就是当前用户所拥有的动态路由的权限。
routes.push(...asyncRoutes.filter(item=>item.name===key));
})
// 将用户拥有的动态路由提到到mutations中,然后更新state状态
// 后面,我们就可以把state中的动态路由信息取出来,然后来控制左侧菜单的显示
context.commit('setRoutes',routes);
return routes;// 这里返回routes动态路由数组的原因是为`addRoutes`使用的,因为我们要将这些动态路由添加到路由对象中,这样单击菜单的时候才可以展示。

}

};

注意:这里需要导入asyncRoutes这个动态路由

1
2
// 处理权限路由模块
import {asyncRoutes, constantRoutes} from '@/router'

4、在权限拦截处调用筛选权限的Action

在上一小节中,我们已经在Vuex中定义了一个action的方法叫做filterRoutes,用来进行路由的筛选。

问题是:在哪调用filterRoutes这个方法呢?

我们知道filterRoutes这个方法的第二个参数,是当前登录用户具有的菜单权限,也就是对应的菜单权限的标识。

所以说,我们肯定是在获取了用户的信息以后调用filterRoutes这个方法的。

所以这里修改src/permission.js文件中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
router.beforeEach(async(to,from,next)=>{
NProgress.start(); // 开启进度条
// 通过keys获取对象中的属性,返回值是一个数组,如果length大于0,表示对象中有属性,而不是一个空对象。
if (store.getters.token!=null&& Object.keys(store.getters.token).length) {
// 如果有token
if (to.path === "/login") {
// 如果访问的是登录页面,跳转到主页
next("/");
} else {
if(!store.state.user.userInfo.Id){
//-------------------------这里会调用`vuex`中的user模块中的`getUserInfo`方法,该方法会返回获取到的用户信息,而返回的用户信息中的roles属性中保存了当前登录用户具有的权限的标识。并且getUserInfo方法将得到的用户信息返回了,所以这里我们可以进行接收,并且将返回的用户信息中的roles给解构出来
const {roles} =await store.dispatch("user/getUserInfo");
// console.log('roles===',roles.menus)
// ------------筛选用户的可用路由
//---------------------- 这里调用了vuex中的permission模块中的filterRoutes方法,来筛选用户的可用路由,需要将当前登录用户的菜单权限标识传递到该方法中。该方法最后也将筛选出的路由返回了,所以这里可以进行接收,然后打印到浏览器的控制台中。
const routes = await store.dispatch('permission/filterRoutes',roles.menus);
console.log('routes===',routes);
}
next(); // 放行

我们返回到浏览器中,刷新页面,可以看到当前打印的routes的值是空值。

原因是:

我们定义的动态路由规则的name属性的值没有修改。

所以下面我们修改一下

修改router/departments.js文件,该文件中定义的是部门管理的动态路由规则

1
2
3
4
5
// 导出部门的路由规则
import Layout from "@/layout";
export default {
path: "/departments", // 路由地址
name: "DepartmentManager", // --------------注意:这里的name属性的取值一定要与服务端返回的菜单权限的标识名称一致

router/employees.js

1
2
3
4
5
// 导出员工的路由规则
import Layout from "@/layout";
export default {
path: "/employees", // 路由地址
name: "UserManager", // --------------注意:这里的name属性的取值一定要与服务端返回的菜单权限的标识名称一致

router/permission.js

1
2
3
4
5
// 导出权限的路由规则
import Layout from "@/layout";
export default {
path: "/permissions", // 路由地址
name: "PermissionsManager", // --------------注意:这里的name属性的取值一定要与服务端返回的菜单权限的标识名称一致

router/roleInfo.js

1
2
3
4
5
// 导出角色的路由规则
import Layout from "@/layout";
export default {
path: "/roles", // 路由地址
name: "RoleManager", // --------------注意:这里的name属性的取值一定要与服务端返回的菜单权限的标识名称一致

当然,在系统中也要把这些菜单权限都添加到系统中,然后给某个角色分配好,在把该角色分配给某个用户。

当用户登录以后,可以看到浏览器的控制台中打印了routes的内容。

在上面的src/permission.js文件中,我们已经能够获取到筛选出来的动态路由了

下面要做的就是,把获取到的动态路由添加到路由表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
if(!store.state.user.userInfo.Id){
const {roles} =await store.dispatch("user/getUserInfo");
// console.log('roles===',roles.menus)
// 筛选用户的可用路由
const routes = await store.dispatch('permission/filterRoutes',roles.menus);
// console.log('routes===',routes);
// ----------------------------将动态路由添加到路由表中,路由表中默认情况下只有静态路由,没有动态路由。
//router.addRoutes(routes);
routes.forEach((route)=>{
router.addRoute(route);
})
// next(to.path)
}

5、静态路由与动态路由解除合并

在上一小节中,我们调用了addRoute这个方法,进行了动态路由的添加,这样我们原来临时添加的动态路由就需要删除掉。

这里我们需要修改src/router/index.js文件中的代码,

1
2
3
4
const router = createRouter({
history: createWebHashHistory(),
routes:[...constantRoutes,...asyncRoutes]
})

在上面的代码中,我们将静态路由与动态路由进行了临时的合并。现在要把动态路由删除掉。

如下所示:

1
2
3
4
5
const router = createRouter({
history: createWebHashHistory(),
routes:[...constantRoutes]
})

这里只有静态的路由,没有动态路由了。

返回到浏览器中进行刷新,发现左侧菜单只有首页了,即使我们当前登录的用户zhangsan有其他的菜单权限,但是在左侧也没有进行展示。

原因是什么呢?

我们首先来看一下左侧菜单的构建

左侧菜单的构建是在src/layout/index.vue这个组件中,

1
2
3
<template v-for="route in routes" >
<el-menu-item v-if="!route.hidden && route.children" :index="route.path" >
<el-icon><SvgIcon :iconClass="route.children[0].meta.icon"

我们可以看到,这里通过循环的routes,来构建菜单。

routes的定义如下

1
2
3
routes = computed(()=>{
return router.options.routes;
})

现在的问题就是,前面我们通过调用addRoute方法动态添加的路由,这里并不会进行响应式的变化。

所以说,在通过调用了addRoute方法动态添加了路由以后,左侧菜单没有展示相应的内容。

怎样进行处理呢?

我们前面也讲过,当我们执行src/store/modules/permission.js文件中的filterRoutes方法进行路由过滤的时候,我们将过滤出来的动态路由保存到了state中的routes这个状态中了。保存到该状态中的目的就是给左侧菜单使用的。

问题是在src/layout/index.vue中怎样获取这个状态数据呢?

我们可以在src/store/getters.js文件中建立对应的快捷访问的方式:

1
2
3
4
5
6
7
8
const getters = {
token: (state) => state.user.token,
name:(state)=>state.user.userInfo.userName,
photoUrl:(state)=>state.user.userInfo.photoUrl,
routes:state=>state.permission.routes// 访问permission模块下的routes这个state状态数据
};
export default getters;

上面代码建立了针对routes这个状态的快捷访问的方式。

下面返回到src/layout/index.vue组件中继续修改代码:

1
2
3
4
routes = computed(()=>{
//return router.options.routes;
return store.getters.routes;
})

这里是从store仓库的getters中的routes来获取动态的路由。

返回到浏览器中进行刷新。可以看到对应的效果

这样就完成了菜单权限的过滤。

这里可以登录具有不同角色的用户进行测试。

当然,在测试的时候,可以给不同不同的角色分配相同的权限,例如给经理和组长两个角色都分配员工管理的权限

然后给王五这个用户都分配经理和组长两个角色。

当以王五这个账号进行登录的时候,发现左侧菜单中显示了两个员工管理菜单。

造成这种情况的问题是:服务端在根据角色获取权限的标识的时候没有进行去重的操作。

所以在UserInfoService.cs这个服务类中的GetUserPermissionCodes方法中,

1
2
3
4
Dictionary<string,List<string>>dict =new Dictionary<string,List<string>>();
dict.Add("menus", menus.Distinct().ToList()); // 对menus这个list集合进行去重。以下也是一样的
dict.Add("points", points.Distinct().ToList());
dict.Add("apis",apis.Distinct().ToList());

对相关的数据进行去重的操作。

重新返回到浏览器中进行测试。

当然,现在遇到了另外一个问题,就是刷新浏览器的时候,会出现404的情况,关于这个问题我们后面再来解决。

6、退出登录重置路由权限与404问题

6.1 退出登录重置路由

在解决404问题之前,我们先来看一下关于退出登录后重置路由权限的问题。

我们知道用户登录的时候,会执行前端项目中的src/permission.js文件中的如下代码:

1
2
3
4
5
// 将动态路由添加到路由表中,路由表中默认情况下只有静态路由,没有动态路由。
//router.addRoutes(routes);
routes.forEach((route)=>{
router.addRoute(route);
})

也就是调用addRoute这个方法动态的添加路由,但是问题时,用户退出登录的时候,没有移除所添加的动态路由。

下面首先修改router/index.js文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
// 重置路由
export function resetRouter(){
const newRouter = createRouter({
history: createWebHashHistory(),
routes:[...constantRoutes]}
);
// matcher就是路由表的数据
// 这里是将新路由的数据赋值给了整个原有router,也就是把路由数据还原成最初的状态。
// 路由的最初状态只有静态路由。
router.matcher = newRouter.matcher;
}
export default router

这里我们创建了一个resetRouter方法,在该方法中通过createRouter方法又重新创建了一个路由对象,然后将新路由对象中的信息重新赋值给了原有router路由对象。完成了路由信息的重置操作。

问题:在哪调用该resetRouter方法呢?

在退出登录的时候调用

下面修改store/modules/user.js文件中的代码:

1
2
3
4
5
6
7
8
9
10
// 用户退出
logout(context){
// 删除token信息
context.commit("removeToken");
// 删除用户信息
context.commit("removeUserInfo");
// 删除本地存储的手机号
localStorage.removeItem("userPhone");
// 重置路由
resetRouter();

user.js这个模块中,我们实现了logout这个actions方法,该方法在单击退出按钮的时候,会被调用。

所以在这logout方法中完成对resetRouter方法的调用。

重置了路由的信息以后,还有一个问题:

这里还需要将store/permission.js模块中,用来保存路由信息的routes这个state状态还需要清空.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 用户退出
logout(context){
// 删除token信息
context.commit("removeToken");
// 删除用户信息
context.commit("removeUserInfo");
// 删除本地存储的手机号
localStorage.removeItem("userPhone");
// 重置路由
resetRouter();
// 这里还需要将`store/permission.js`模块中,用来保存路由信息的routes这个state状态还需要清空.
// 问题是:Vuex某个子模块怎样去调用其他子模块中的`action`或者是mutations方法呢?
// 如果模块都没有添加命名空间,所有的mutations和action都是挂在全局上的,所以可以直接调用
// 但是子模块添加了命名空间以后,怎样调用另外一个添加了命名空间的子模块的mutations或者是actions.
// 添加了命名空间以后,context对象指的不是全局的context对象,而是当前的模块的context对象。
// 这里我们添加了第三个参数,第三个参数是一个对象,表示返回到根模块,也就是返回到了`store/index`
// 在根模块中可以调用对应的`mutaionts`或者是action方法,但是在根下面没有setRoutes这个方法,所以前面要加上对应的模块名称,也就说,这里我们先返回到根模块,然后找到根模块中所引入的permission模块,调用permission这个子模块中的setRoutes方法,给该方法传递的参数是空数组,从而将permission这个子模块中的routes这个state状态的值设置为空数组,从而完成了`routes`的清空操作。
context.commit('permission/setRoutes',[],{root:true});

}

在调用完了resetRouter方法以后,需要调用permission这个模块中的setRoutes这个mutations方法,完成routes这个state状态数据的清空操作。

6.2 解决404问题

在前面我们也提到过,当登录成功以后,刷新浏览器会出现404的问题。在这一小节中,我们来看一下怎样解决404的问题。

我们知道关于404,是我们在路由中进行了如下的配置。

查看router/index.js文件

1
2
3
4
{
path: "/:pathMatch(.*)*", // 没有其他的路由规则相匹配
redirect: "/404",
},

如果没有其他的路由规则进行匹配,会跳转到/404.

但是,问题是,以上配置必须放在所有路由规则的最后。

我们看到,以上路由规则并没有放在其他路由规则的最后,后面还有一个about的路由规则,当然about这个路由规则这里我们使用不到,可以删除。

但是删除以后,单击了某个菜单项以后,重新刷新浏览器还是不行。

原因是:这里我们添加的以上404的路由规则是在整个路由规则的最后吗?

不是的,因为以上添加的404路由规则是在静态路由规则的最后,但是不是在动态路由规则的最后。

所以这里我们需要修改src/permission.js文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
routes.forEach((route)=>
{
router.addRoute(route);
})

router.addRoute( {
path: "/:pathMatch(.*)*", // 没有其他的路由规则相匹配
component: () => import("@/views/404.vue"),
});

在上面的代码中,我们是将其他的动态路由添加完毕以后,再去添加404对应的路由规则。

这时候,我们需要把router/index.js文件中定义的404路由规则注释掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
if(!store.state.user.userInfo.id){ // --------------------------注意,这里的id是小写的,前面我们写错了,这里需要改正

const {roles} =await store.dispatch("user/getUserInfo");


// 筛选用户的可用路由
const routes = await store.dispatch('permission/filterRoutes',roles.menus);

// 将动态路由添加到路由表中,路由表中默认情况下只有静态路由,没有动态路由。
//router.addRoutes(routes);
routes.forEach((route)=>
{
router.addRoute(route);
})

router.addRoute( {
path: "/:pathMatch(.*)*", // 没有其他的路由规则相匹配
component: () => import("@/views/404.vue"),
});
next(to.path); //----------------------------------------跳转到对应的地址
// next({
// path:`${to.path}`
// });

// next(to.path)
}else{ //--------------------------------这里添加了elese
next(); // 放行
}

在上面的代码中,我们修改了判断条件,原来是对Id进行判断的,但是这里应该是id,注意大小写。

同时,在添加了404的路由规则以后,直接通过next(to.path)跳转到对应的页面,否则(这里添加了else)直接放行,也就是说有用户数据了直接放行,不需要在重新请求服务端获取用户数据。

这里将其逻辑结构做了一定的调整。

7、功能权限判断

在服务端返回的roles数据中,有一个points属性,该属性中存储的标识,表示的就是登录用户具有的功能权限,也就是按钮权限。

下面我们看一下怎样使用points属性中的数据来进行功能权限的判断。

这里我们先来修改store/modules/user.js文件中的代码:

1
2
3
4
5
6
setUserInfo(state,payload){
const {user,roles } =payload

state.userInfo = user;
state.userPermission =roles;
},

setUserInfo这个mutations方法中,我们已经将服务端返回的userroles数据给解构出来了。

我们知道roles中存储了对应的points,这里将roles直接赋值给了userPermission这个状态属性。

1
2
3
4
5
6
state() {
return {
token: getTokenInfo(), // 获取token信息,初始化vuex
userInfo:{},
userPermission:{} // --------------这里定义了userPermission这个状态属性
};

然后在src目录下面创建checkPermission文件夹,在该文件夹中创建index.js文件,该文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
import store from '@/store'
// 参数key就是权限的标识
export function checkPermission(key){
const {userPermission} = store.state.user;
// console.log("userInfo=",userPermission);
if(userPermission.points&&userPermission.points.length>0){
const result = userPermission.points.some(item=>item === key);
// console.log('resultaaaa=',result);
return result;
}
return false; // 如果没有points数组,或者是该数组中是空,直接返回false.
}

这里创建了checkPermission方法,当调用该方法的时候,会传递一个参数key,表示的就是登录用户所具有的功能权限的标识。

然后看一下是否在userPermission状态中的points数组中存在,如果存在返回true,否则返回的是false.

下面我们就可以在组件中调用checkPermission方法来进行功能权限的校验。

例如,我们在views/employees/index.vue这个组件中进行测试

1
2
import AddEmployee from "./components/add-employee.vue";
import {checkPermission } from '@/checkPermission/index' // 导入

这里导入了checkPermission这个方法,

当然,也需要返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
return {
loading,
list,
page,
handleCurrentChange,
formatTimeOfEntry,
showAddDialog,
updatedialog,
loadEmployeeList,
showRoleDialog,
btnRole,
...toRefs(roles),
roleIdList,
btnRoleOk,
checkPermission // 返回checkPermission方法
};
1
2
3
<template  #after >
<el-button size="small" type="primary" @click="showAddDialog=true" :disabled="!checkPermission('UserAdd')">新增员工</el-button>
</template>

这里在模版中,找到新增员工这个按钮,给其添加了disabled属性(这里由于使用了elementplus组件,所以要查看一下文档不同组件禁用的属性是哪一个),该属性的取值如果是true会禁用按钮,所以这里该属性的值就是checkPermission方法返回的值,当然需要取反。

同时在调用checkPermission方法的时候,需要传递UserAdd这个标识,具有这个标识表示的就是登录的用户具有添加员工的权限。

UserAdd这个标识,可以从服务端返回的数据中查看到

首先以管理员登录,然后给某个用户分配角色,在给改角色分配指定的员工管理这个菜单权限,然后在分配添加员工这个功能权限,最后以该用户进行登录,查看(后面再禁用添加员工这个功能权限,在让该用户进行登录来查看一下效果)。

后面可以在其他所有的组件中,给对应的按钮指定checkPermission方法,当然,在调用该方法的时候,注意一定要传递正确的功能权限的标识。

8、api权限校验

前面我们已经实现了菜单权限,功能权限的过滤,在这一小节中,我们来看一下api权限的校验。

这里我们列举一个场景:

例如:用户添加,假如某个登录用户没有这个权限,对应的该按钮是不可用,或者是隐藏的。

但是该用户如果知道了,用户添加的访问接口地址,是不是就可以直接访问了,所以这里我们也需要对api接口进行权限的校验。

怎样对api接口的权限进行校验呢?

这里需要使用到Filter过滤器。

我们可以实现ActionFilter,这样就可以保证,在请求服务端的接口,执行具体的api方法之前,先执行该改过滤器,在该改过滤器中,判断用户是否具有访问api接口的权限。

Cms.WebApi这个项目中的Filters文件夹中,创建ApiPermissionCheckFilter.cs这个类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using Cms.IService;
using Microsoft.AspNetCore.Mvc.Filters;
using System.IdentityModel.Tokens.Jwt;

namespace Cms.WebApi.Filters
{
public class ApiPermissionCheckFilter: IAsyncActionFilter
{
// 通过`ApiPermissionCheckFilter`构造方法实现对`IUserInfoService`用户服务的注入
private readonly IUserInfoService _userInfoService;
public ApiPermissionCheckFilter(IUserInfoService userInfoService) {
this._userInfoService = userInfoService;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// 这里通过HttpContext接收前端发送过来的请求头中的Authorization信息。
// 我们知道Authorization中保存了token信息,而token信息中含有用户的编号,这里根据用户的编号查询用户,然后查询用户具有的角色,在通过角色查询用户具有的api权限,查询出用户具有的api权限以后,判断用户当前请求的api接口是否在用户所具有的api权限中,如果在则允许访问,否则给出相应的错误提示。
var authorization = context.HttpContext.Request.Headers["Authorization"];
if (authorization.Count>0)
{
// 这里需要注意的是,我们前面约定了token信息中包含Bearer (这里是空格)前缀,所以这里需要进行分割,获取真正的token数据。
var token = authorization[0]!.ToString().Split("Bearer ");
// 这里读取token信息(可以打上断点,查看具体的值)
var tokenInfo = new JwtSecurityTokenHandler().ReadJwtToken(token[1]);
// Claims中存储的第一个就是用户的编号
var info = tokenInfo.Claims.FirstOrDefault();
var userInfo = _userInfoService.LoadEntities(u=>u.Id.ToString()==info!.Value).include().toList();
userInfo.RoleInfos
// 根据用户查询角色,角色找权限,权限表找api权限。List

// 根据请求的地址和请求的方式,查询api权限表,可以找到具体的记录,然后看一下该记录是否在上面的list集合中存在,如果存在,放行。
var url = context.HttpContext.Request.Path; // 获取用户请求的接口地址
var method = context.HttpContext.Request.Method; // 获取接口请求的方式

}


await next();
}
}
}

在上面的代码中,我们没有实现完整的代码,但是实现思路已经理清楚了,大家可以自己实现代码。

其实代码我们前面也已经讲过。

当然,我们还需要注入该Filter.

修改Program.cs文件中的代码,如下所示:

1
2
3
4
5
6
// 完成Filter的注册
builder.Services.Configure<MvcOptions>(c =>
{
c.Filters.Add<ApiPermissionCheckFilter>(); // 这里注册了ApiPermissionCheckFilter
c.Filters.Add<UnitOfWorkFilter>();

关于api权限的存储问题,在数据库中我们单独的创建了一张表来存储api权限。

问题:怎样向该表中添加api权限呢?

我们在前端没有提供添加api权限的表单,当然,这里可以提供。

或者是,我们可以将api权限(api地址)写到一个Excel表格中,最后读取该Excel文件,批量进行导入。

.net core读取Excel 文件,参考:EPPlus开源组件,

同时还要注意:一些api接口的权限是所有用户都具有的,例如:登录,所以需要进行判断。