五、后台管理
5.1、商品服务
5.1.1、三级分类
5.1.1.1、查询所有菜单与子菜单
我们需要维护所有菜单以及各种子菜单,子菜单里面可能还有子菜单,所以我们采用递归的方式进行书写。
我们先在CategoryController中修改list方法,让他以组装树形结构进行返回。
/**
* 查询列表,并且以树形结构进行返回
*/
@RequestMapping("/list")
public R list(){
List<CategoryEntity> categoryEntityList = categoryService.listWithTree();
return R.ok().put("categoryEntityList", categoryEntityList);
}
由于数据库表中是没有子菜单这个属性,所以我们需要在实体类中添加这个属性,一般开发中可以重新写一个VO,更加解耦。在CategoryEntity中添加一个children属性,但是需要加一个注解,告诉Mybatis-plus,这个属性我的表中没有,不需要理他。
/**
* 子分类
*/
@TableField(exist = false)
private List<CategoryEntity> children;
CategoryService中写一个接口。
/**
* 查出所有分类,并且组装成父子结构
* @return
*/
List<CategoryEntity> listWithTree();
CategoryServiceImpl也顺带写一写。
@Override
public List<CategoryEntity> listWithTree() {
// 查询所有
List<CategoryEntity> categoryEntities = baseMapper.selectList(null);
// 找到所有一级分类
List<CategoryEntity> collect = categoryEntities.stream().filter((categoryEntity) -> {
// 返回父分类id为0的,父id等于0说明他是一级分类
return categoryEntity.getParentCid() == 0;
}).map((menu)->{
// 设置子菜单
menu.setChildren(getChildren(menu,categoryEntities));
return menu;
}).sorted(Comparator.comparingInt(menu -> (menu.getSort() == null ? 0 : menu.getSort())))
.collect(Collectors.toList());
// 设置每一个父分类的子分类
return collect;
}
由于需要递归遍历,所以我们把遍历方法抽取出来。
/**
* 获取子菜单
* @param currentMenu 当前菜单
* @param allMenu 所有菜单
* @return 所有子菜单
*/
private List<CategoryEntity> getChildren(CategoryEntity currentMenu,List<CategoryEntity> allMenu){
List<CategoryEntity> childrents = allMenu.stream().filter(categoryEntity -> categoryEntity.getParentCid() == currentMenu.getCatId())
// 每个子菜单可能还有子菜单
.map(categoryEntity -> {
categoryEntity.setChildren(getChildren(categoryEntity, allMenu));
return categoryEntity;
})
// 排序
.sorted(Comparator.comparingInt(menu -> (menu.getSort() == null ? 0 : menu.getSort())))
.collect(Collectors.toList());;
return childrents;
}
然后启动商品服务就可以开始测试。
5.1.1.2、配置网关和路由重写
我们需要维护后台管理系统,启动renren-fast-vue项目和mall-admin后端项目,直接npm run dev
开跑。我们首先写的功能是分类维护。
renren-fast-vue的页面规则是http://localhost:8001/#/product-category
,页面在src/views/modules/product下的目录里面。
我们新建一个prodect文件夹和category.vue,用于保存目录管理页面。为了便于开发,我们队vscdoe进行一些配置。
首先是配置vue模板,输入vue时可以直接跳出模板。
输入vue可以直接开始配置模板。
把原来的注释掉,直接换成下面的这段配置。
{
"Print to console": {
"prefix": "vue",
"body": [
"<!-- $1 -->",
"<template>",
"<div class='$2'>$5</div>",
"</template>",
"",
"<script>",
"//这里可以导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等)",
"//例如:import 《组件名称》 from '《组件路径》';",
"",
"export default {",
"//import引入的组件需要注入到对象中才能使用",
"components: {},",
"data() {",
"//这里存放数据",
"return {",
"",
"};",
"},",
"//监听属性 类似于data概念",
"computed: {},",
"//监控data中的数据变化",
"watch: {},",
"//方法集合",
"methods: {",
"",
"},",
"//生命周期 - 创建完成(可以访问当前this实例)",
"created() {",
"",
"},",
"//生命周期 - 挂载完成(可以访问DOM元素)",
"mounted() {",
"",
"},",
"beforeCreate() {}, //生命周期 - 创建之前",
"beforeMount() {}, //生命周期 - 挂载之前",
"beforeUpdate() {}, //生命周期 - 更新之前",
"updated() {}, //生命周期 - 更新之后",
"beforeDestroy() {}, //生命周期 - 销毁之前",
"destroyed() {}, //生命周期 - 销毁完成",
"activated() {}, //如果页面有keep-alive缓存功能,这个函数会触发",
"}",
"</script>",
"<style lang='scss' scoped>",
"//@import url($3); 引入公共css类",
"$4",
"</style>"
],
"description": "Log output to console"
}
}
模板设置好以后,我们需要配置格式化,首先先安装一个插件,Vetur。
进入配置界面。进入json界面编辑。
把原来的注释掉,直接换成下面这段配置。
{
// tab 大小为2个空格
"editor.tabSize": 2,
// 编辑器换行
"editor.wordWrap": "off",
// 保存时格式化
"editor.formatOnSave": true,
// 开启 vscode 文件路径导航
"breadcrumbs.enabled": true,
// prettier 设置语句末尾不加分号
"prettier.semi": false,
// prettier 设置强制单引号
"prettier.singleQuote": true,
// 选择 vue 文件中 template 的格式化工具
"vetur.format.defaultFormatter.html": "js-beautify-html",
// vetur 的自定义设置
"vetur.format.defaultFormatterOptions": {
"js-beautify-html": {
"wrap_line_length": 30,
"wrap_attributes": "auto",
"end_with_newline": false
},
"prettier": {
"singleQuote": true,
"semi": false,
"printWidth": 100,
"wrapAttributes": false,
"sortAttributes": false
}
},
"[vue]": {
"editor.defaultFormatter": "octref.vetur"
},
"vetur.completion.scaffoldSnippetSources": {
"workspace": "💼",
"user": "🗒️",
"vetur": "✌"
}
}
后面你ctrl+s可以直接保存加格式化了。
这个后台管理系统我们使用的是Element-UI的树形控件:https://element.eleme.cn/#/zh-CN/component/tree, 我们先把官方文档的代码copy进去看看效果。
<!-- -->
<template>
<el-tree :data="data"
:props="defaultProps"
@node-click="handleNodeClick">
</el-tree>
</template>
<script>
//这里可以导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等)
//例如:import 《组件名称》 from '《组件路径》';
export default {
//import引入的组件需要注入到对象中才能使用
components: {},
data() {
return {
data: [
{
label: '一级 1',
children: [
{
label: '二级 1-1',
children: [
{
label: '三级 1-1-1',
},
],
},
],
},
{
label: '一级 2',
children: [
{
label: '二级 2-1',
children: [
{
label: '三级 2-1-1',
},
],
},
{
label: '二级 2-2',
children: [
{
label: '三级 2-2-1',
},
],
},
],
},
{
label: '一级 3',
children: [
{
label: '二级 3-1',
children: [
{
label: '三级 3-1-1',
},
],
},
{
label: '二级 3-2',
children: [
{
label: '三级 3-2-1',
},
],
},
],
},
],
defaultProps: {
children: 'children',
label: 'label',
},
}
},
methods: {
handleNodeClick(data) {
console.log(data)
},
},
//监听属性 类似于data概念
computed: {},
//监控data中的数据变化
watch: {},
//方法集合
//生命周期 - 创建完成(可以访问当前this实例)
created() {},
//生命周期 - 挂载完成(可以访问DOM元素)
mounted() {},
beforeCreate() {}, //生命周期 - 创建之前
beforeMount() {}, //生命周期 - 挂载之前
beforeUpdate() {}, //生命周期 - 更新之前
updated() {}, //生命周期 - 更新之后
beforeDestroy() {}, //生命周期 - 销毁之前
destroyed() {}, //生命周期 - 销毁完成
activated() {}, //如果页面有keep-alive缓存功能,这个函数会触发
}
</script>
<style lang='scss' scoped>
//@import url(); 引入公共css类
</style>
这里的数据都是写死的死数据,我们需要替换成数据库查询的数据。先将data中数据库删除掉,根据vue生命周期,我们直接把方法写在created里面。我们在methods中写一个方法。
methods: {
handleNodeClick(data) {
console.log(data)
},
getMenus() {
this.$http({
url: this.$http.adornUrl('/product/category/list'),
method: 'get',
}).then(({ data }) => {
console.log(data)
})
},
},
在created里面调用即可。
//生命周期 - 创建完成(可以访问当前this实例)
created() {
this.getMenus();
},
一启动直接404,一看原来是端口不对,我们还需要修改端口。
在src/config/index.js下定义了接口请求地址。
为了统一地址,我们需要给网关发请求,由网关进行统一路由。
// api接口请求地址
window.SITE_CONFIG['baseUrl'] = 'http://localhost:88';
保存后发现发现他要求我们重新登录,且验证码都没有了,原来是他直接给我们网关发请求。
由于需要网关路由,我们还需要把mall-admin这个项目注册到nacos中。在application.yml中加入这两段配置。
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
application:
name: mall-admin
最后在启动类上加入这这个注解,允许服务发现功能@EnableDiscoveryClient
。
成果配置。我们接着去gateway配置中心的gateway.yml配置中心去配置网关,这里约定前端发的请求全部带上/api
前缀。
spring:
cloud:
gateway:
routes:
# 路由到何处
- id: admin-route
# url规则
uri: lb://mall-admin
# 当请求为/api/xxx的全部路由到上面配置的url
predicates:
- Path=/api/**
启动网关和重启前端项目,再次看看是否可以拿到验证码。
很不幸,还是404,这个时候我们需要使用网关的路径重写,使用gateway的filters
的路径重写功能。
filters:
- RewritePath=/api/?(?<segment>.*), /mall-admin/$\{segment}
开始登录!登录后发现出现了跨域问题。
5.1.1.3、解决跨域问题
跨域指的是浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器对javascript施加的安全限制。一般是使用同源策略进行限制。
同源策略:是指协议,域名,端口都要相同,其中有一个不同都会产生跨域。
跨域请求流程:
我们可以发现,他只是发了一个option请求,真正的登录请求还没有发过去就被跨域拦截了。
解决办法:
- 使用nginx部署为同一域。
- 配置当次请求允许跨域:
-
Access-Control-Allow-Origin:支持哪些来源的请求跨域
-
Access-Control-Allow-Methods:支持哪些方法跨域
-
Access-Control-Allow-Credentials:跨域请求默认不包含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里面指定。
-
Access-Control-Max-Age:表明该响应的有效时间为多少秒。在有效时间内,浏览器无 须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果 该首部字段的值超过了最大有效时间,将不会生效。
每个请求都添加这么多请求头,太麻烦了,所以我们可以写一个filter,我们可以在网关写一个Filter,由网关统一进行跨域配置。我们在网关新建一个config目录用于存放配置类,新建一个MallConfiguration类用于解决跨域问题。
package cn.linstudy.gateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
/**
* @author XiaoLin
* @date 2023/1/7
* @description 解决跨域问题配置类
*/
@Configuration
public class MallConfiguration{
@Bean
public CorsWebFilter corsWebFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 配置跨域
// 允许哪些头进行跨域
corsConfiguration.addAllowedHeader("*");
// 允许哪些请求当时进行跨域
corsConfiguration.addAllowedMethod("*");
// 允许哪些请求来源进行跨域
corsConfiguration.addAllowedOrigin("*");
// 是否允许携带cookie进行跨域
corsConfiguration.setAllowCredentials(true);
source.registerCorsConfiguration("/**",corsConfiguration);
return new CorsWebFilter(source);
}
}
启动后会发现仍然报错。
这是因为,脚手架项目也配置了跨域,我们需要把他原来的跨域配置给注释掉。
注释掉后重启mall-admin即可。
5.1.1.4、三级分类树形展示
我们接着写网关路由,即在nacos的网关配置中书写路由规则即可。
# 路由到何处
- id: product-route
# url规则
uri: lb://mall-product
# 当请求为/api/xxx的全部路由到上面配置的url
predicates:
- Path=/api/product/**
filters:
- RewritePath=/api/?(?<segment>.*), /$\{segment}
启动后发现路由配置似乎没有生效。实际上是被上面的路由配置覆盖了。上面这条路由信息会先匹配上,所以会直接走上面的路由配置。我们可以调整一下顺序,把精确的路由放到高的优先级,模糊的路由放到低的优先级即可。
刷新配置,完成!数据成果拿到
接着开始显示数据,通过data.categoryEntityList
可以获取到后台接口传过来的数据。
这里即为展示数据的地方,我们把他改为menus
,修改的地方要和这里对应上即可。由官方文档我们可以看到,
因为我们的列表数据里面,标签名的属性是name,所以我们把label的值改为name即可展示数据。
5.1.1.4、菜单删除
我们可以使用Vue的slot功能。
可以看到只需要给Vue Tree里面写一个span标签即可。
<span class="custom-tree-node" slot-scope="{ node, data }">
<span>{{ node.label }}</span>
<span>
<el-button
type="text"
size="mini"
@click="() => append(data)">
Append
</el-button>
<el-button
type="text"
size="mini"
@click="() => remove(node, data)">
Delete
</el-button>
</span>
</span>
放到el-tree这个标签里面即可。
再把原生的append和remove方法复制过来即可,清空实现,自己实现。
append(data) {
},
remove(node, data) {
},
我们可以发现,在点击append和remove的时候,菜单栏会展开和收缩,我们也需要把这个效果给去掉,参照文档,原来是这个属性在作怪,再把这个属性加到el-tree中。
:expand-on-click-node="false">
这样就去掉了这个烦人的效果,在点击按钮的时候就不会展开与合并了,只有点击箭头的时候才会。接下来就要开始判断啥时候展示append和remove菜单了,只有一级、二级菜单才可以添加,只有菜单下没有子菜单了以后才可以删除。
在slot插槽中有两个对象,node表示当前节点对象
我们打印一下node对象,可以发现一个好东西。
level表示当前的层级,是一级还是二级菜单。node.childNodes表示他的子节点
我们通过node.level
这个属性来判断是否是一级、二级菜单,node.level <= 2
表示为二级菜单。
我们在button中加上两个判断即可实现这个功能。
由于我们还需要做批量删除,所以我们还需要做上单选框,我们需要在el-tree上加入show-checkbox
这个属性。
由于我们使用的是MyBatis-Plus,我们需要配置一下MyBatis-Plus的逻辑删除。老规矩先去官方文档上瞅一眼:https://baomidou.com/pages/6b03c5/。
我们要去实体类字段上加上@TableLogic
注解。我们在CategoryEntity这个实体的showStatus字段上加入这个注解,但是发现一个问题,我们自己定义的规则是0表示不显示,1表示显示,别慌MyBatis-Plus会出手,我们点进去@TableLogic
注解,可以看到,我们可以自己配置删除和不删除的规则。
所以我们给这个注解上加一点东西。
/**
* 是否显示[0-不显示,1显示],@TableLogic代表这是逻辑删除字段
*/
@TableLogic(value = "1",delval = "0")
private Integer showStatus;
接下来我们可以开始去后台项目写逻辑删除接口。
/**
* RequestBody请求体,必须发post请求,SpringMVC会自动将请求体数据(JSON),转为对应的对象
* @param catIds 商品id
* @return
*/
@RequestMapping("/delete")
public R delete(@RequestBody Long[] catIds){
// 检查当前菜单是否被其他地方引用
categoryService.removeMenusByIds(Arrays.asList(catIds));
return R.ok();
}
去Service写接口。
/**
* 批量逻辑删除
* @param asList
*/
void removeMenusByIds(List<Long> asList);
接着去写实现类。
/**
* 用showStatus状态来完成逻辑删除
* @param asList id列表
*/
@Override
public void removeMenusByIds(List<Long> asList) {
baseMapper.deleteBatchIds(asList);
}
后端接口写完以后,就去前端项目开始去测试。在category.vue中的remove方法去书写逻辑删除代码。为了便于开发方便,我们可以直配置好发送get、post请求模板。我们需要新建一个代码模板,不可以在原有的进行添加,否则会无法添加。
"http-get 请求": {
"prefix": "httpget",
"body": [
"this.\\$http({",
"url: this.\\$http.adornUrl(''),",
"method: 'get',",
"params: this.\\$http.adornParams({})",
"}).then(({data}) => {",
"})"
],
"description": "httpGET 请求"
},
"http-post 请求": {
"prefix": "httppost",
"body": [
"this.\\$http({",
"url: this.\\$http.adornUrl(''),",
"method: 'post',",
"data: this.\\$http.adornData(data, false)",
"}).then(({ data }) => { });"
],
"description": "httpPOST 请求"
}
接着我们去写remove方法。
remove(node, data) {
var ids = [data.catId]
this.$http({
url: this.$http.adornUrl('/product/category/delete'),
method: 'post',
data: this.$http.adornData(ids, false),
}).then(({ data }) => {
// 会重新发送请求,更新一次菜单数据
this.getMenus()
})
},
写到这里,删除大体功能就做完了,但是我们还需要一些细化细节:
- 删除前弹出提示框。
- 删除成功后有消息提示。
- 删除完后还是展开状态。
我们首先做删除前弹出提示框,提示框我们可以使用MessageBox弹框组件(https://element.eleme.cn/#/zh-CN/component/message-box)。
开搞!
this.$confirm('此操作将永久删除该文件, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$message({
type: 'success',
message: '删除成功!'
});
}).catch(() => {
this.$message({
type: 'info',
message: '已取消删除'
});
});
为了拿到删除菜单的名字,我们可以使用票号(`)和插值表达式来取值,带data中有一个name属性就是这个菜单的名字。
this.$confirm(`是否删除【${data.name}】菜单, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
this.$message({
type: 'success',
message: '删除成功!',
})
})
.catch(() => {
this.$message({
type: 'info',
message: '已取消删除',
})
})
接着我们改变一下代码顺序,点击确认删除后才发送删除请求即可。
remove(node, data) {
var ids = [data.catId]
this.$confirm(`是否删除【${data.name}】菜单, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
this.$http({
url: this.$http.adornUrl('/product/category/delete'),
method: 'post',
data: this.$http.adornData(ids, false),
}).then(({ data }) => {
// 会重新发送请求,更新一次菜单数据
this.getMenus()
})
this.$message({
type: 'success',
message: '删除成功!',
})
})
}
成功是成功了,但是在点击取消的时候控制台报错了。
原因是取消的按钮没处理,这是因为我们之前删了catch代码,加回去即可。
remove(node, data) {
var ids = [data.catId]
this.$confirm(`是否删除【${data.name}】菜单, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
this.$http({
url: this.$http.adornUrl('/product/category/delete'),
method: 'post',
data: this.$http.adornData(ids, false),
}).then(({ data }) => {
// 会重新发送请求,更新一次菜单数据
this.getMenus()
})
this.$message({
type: 'success',
message: '删除成功!',
})
})
// 取消的操作,先留空,不然控制会报错
.catch(() => {})
}
接着我们来做,删除成功后还是依然处于展开状态,不收回去。需要做到依然展开,我们就需要参照官方文档的属性,去瞅瞅看有没有合适的属性。
恰好找到这个属性default-expanded-keys
。
我们可以在el-tree中动态绑定这个属性。
<el-tree :data="menus"
:props="defaultProps"
@node-click="handleNodeClick"
:expand-on-click-node="false"
show-checkbox
node-key="catId"
:default-expanded-keys="expandedKey">
在data中添加expandedKey数据值,这个是一个数组。
data() {
return {
menus: [],
expandedKey: [],
defaultProps: {
children: 'children',
label: 'name',
},
}
},
在删除成功,刷新菜单后,修改expandedKey的值,默认展示删除的菜单的父菜单。我们在getMenus方法后调用这个方法即可。
// 设置需要默认展开的菜单,赋值为当前节点的父节点的id即可,当前节点的父节点为
this.expandedKey = [node.parent.data.catId]
很好,很完美。
5.1.1.5、菜单添加
弹框添加依然去瞅瞅官方文档,使用Dialog对话框组件(https://element.eleme.cn/#/zh-CN/component/dialog)。
<el-dialog
title="提示"
:visible.sync="dialogVisible"
width="30%"
:before-close="handleClose">
<span>这是一段信息</span>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="dialogVisible = false">确 定</el-button>
</span>
</el-dialog>
复制过去之后可以发现报错了。
原来是他需要一根元素,我们需要用一个div包裹住他。
dialogVisible属性为false表示这个对话框不打开。当我们点击appen也就是添加的时候,就讲将dialogVisible属性设置为true即可。我们首先先定义一个变量,初值为false。
dialogVisible: false,
点击append方法的时候修改为true即可。
append(data) {
this.dialogVisible = true
},
他此时还是会报错,因为在对话框中定义了点击关闭的事件,但是我们还没有定义事件,所以先把这个也删掉。
:before-close="handleClose"
由于我们需要内嵌一个表单,提交的时候提交一个表单给后台添加到数据库中,去瞅一眼官方文档可以发现,他提供了一个内嵌表单的方法。
把对话框里面的span标签替换成form表单即可。
<el-form
:model="category">
<el-form-item
label="分类名称">
<el-input
v-model="category.name"
autocomplete="off">
</el-input>
</el-form-item>
</el-form>
v-model="category.name"
这里双向绑定了一个值,我们还需要在data中声明出来。
category: { name: ' ' },
我们给确定按钮提交表单,为确定按钮绑定一个点击事件。
@click="addCategory"
根据数据库表字段可知,除了名字需要添加外,我们还需要一些其他的字段,这些字段不是前台输入的,而是我们自己赋值设置的。
再点击append的时候,我们就需要赋值,我们先点击append看看可以拿到什么数据。
点击append后可以拿到这些数据,catId就是添加的parentId,而catLevel菜单层级是当前点击菜单的层级+1(由于害怕他是数字,所以我们先*1再+1,把他转为字符串。)
append(data) {
this.dialogVisible = true
this.category.parentCid = data.catId;
this.category.catLevel = data.catLevel*1 + 1;
},
当我们点击确认后,看看提交的数据是什么?
可以看到,数据都拿到了。前端完成后,可以直接发请求即可,因为后端接口都逆向工程生成好了。
addCategory() {
this.$http({
url: this.$http.adornUrl('/product/category/save'),
method: 'post',
data: this.$http.adornData(this.category, false),
}).then(({ data }) => {
this.$message({
type: 'success',
message: '保存成功!',
})
// 关闭对话框
this.dialogVisible = false
// 会重新发送请求,更新一次菜单数据
this.getMenus()
// 设置需要默认展开的菜单,赋值为当前节点的父节点的id即可,当前节点的父节点为
this.expandedKey = [this.category.parentCid]
})
},
保存成功后关闭对话框,我们只需要把dialogVisible属性置为false即可。接着刷新菜单,再展开当前菜单,这个在删除时都做过了。直接copy即可。
5.1.5.6、修改菜单
想要实现修改功能,肯定要先添加修改按钮,这个修改按钮是任何时候都显示的,所以去掉v-if。点击修改的时候弹出对话框,我们需要将dialogVisible值设置为true即可。
我们先来做回显数据。点击修改菜单的时候就可以拿到数据,里面有catId,直接回显即可,都无需向后台发请求。
由于输入框绑定的数据是 v-model="category.name"
,所以我们只需要把category.name的值修改即可。
// 回显编辑框
this.category.name = data.name
我们再给catId赋值,因为需要拿catId去修改数据。
this.category.catId = data.catId
接下来就遇到了一个难题,由于我们需要复用对话框,所以我们需要区分是添加还是修改。我们可以定义一个dialogType的数据,当点击新增的时候就把他的值赋值为save,点击修改的时候把他的值赋值为update。
点击新增的时候我们就把他的值修改为save。
我们还需要把原来确定按钮事件修改一下,自己写一个方法去判断是新增还是编辑。
// 判断是修改还是新增
submitData(){
if(this.dialogType == "save"){
this.addCategory();
}
if(this.dialogType == "update"){
this.updateCategory();
}
},
我们再来做一个小效果,当点击新增的时候,提示框标题为新增,点击编辑的时候,提示框标题为编辑。
点击新增的时候把这个变量的值改了。
this.dialogTitle = '新增分类'
同理,点击修改的时候也是一样。
this.dialogTitle = '修改分类'
新增菜单还需要图标和计量单位两个属性,我们新增两个输入框。这个时候就出问题,如果有多人同时使用的话会出现数据不一致的问题,所以我们的回显数据不可以偷懒,必须是从数据库里面访问得到最新的数据进行回显。
update(data) {
this.dialogTitle = '修改分类'
this.dialogVisible = true
// 回显编辑框
this.$http({
url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
method: 'post',
data: this.$http.adornData(data, false),
}).then(({ data }) => {
this.category.name = data.category.name
this.category.icon = data.category.icon
this.category.productUnit = data.category.productUnit
this.parentCid = data.parentCid;
})
接着就开始真正修改,由于我们只需要修改部分数据,于是乎,我们还需要进行数据拼接。我们在方法中去拼接参数
let data = {catId,name,icon,productUnit}
发完请求以后仿照着删除,去掉对话框、重新获取菜单,展开到当前菜单。
// 修改菜单方法
updateCategory() {
let { catId, name, icon, productUnit } = this.category
let data = { catId, name, icon, productUnit }
this.$http({
url: this.$http.adornUrl('/product/category/update'),
method: 'post',
data: this.$http.adornData(data, false),
}).then(({ data }) => {
this.$message({
type: 'success',
message: '修改成功!',
})
// 关闭对话框
this.dialogVisible = false
// 会重新发送请求,更新一次菜单数据
this.getMenus()
// 设置需要默认展开的菜单,赋值为当前节点的父节点的id即可,当前节点的父节点为
console.log(this.category)
this.expandedKey = [this.category.parentCid]
})
},
我们还会发现,如果我们点击空白页面,对话框会消失,我想让对话框一直在的话可以设置这个属性:close-on-click-modal="false"
为false即可。
接着我们还会发现一个问题,先修改,再添加,会发现,在添加的对话框中会显示我们刚刚修改的值。这是因为我们在回显的时候将对话框的值都设置进去了。
所以我们在append打开对话框的时候需要修改回默认值,否则会出现这个问题。
this.category.name = ''
this.category.icon = ''
this.category.productUnit = ''
5.1.5.7、拖拽效果
在Element-UI中还可以实现可拖拽修改。
我们把他写到el-tree属性中。
但是不是所有的节点都可以这样随意被拖拽,于是乎Element-UI还提供了allow-drag这个方法给我们,我们自己通过业务逻辑来判断是否可以被拖拽。是否可以拖拽的核心就判断总层数是否大于3,大于3则不可以拖拽,否则可以拖拽。
我们需要在el-tree中动态绑定这个方法。
在这个方法里面做逻辑判断。
allowDrop(draggingNode, dropNode, type) {
// 判断是否可以拖拽的核心是当前节点以及所在父节点的总层数不可以超过3
/// 1. 计算被拖拽的节点的总层数
console.log(draggingNode) // draggingNode表示的是拖拽的节点
let level = countNodeLevel(draggingNode)
},
draggingNode表示的是拖拽的东西,而draggingNode.data可以获取到拖拽的节点信息。
我们先来写一个方法,来判断他的最大深度,来确定是否可以拖动,这里用到了递归计算。
countNodeLevel(node) {
// 找到所有子节点,求出最大深度
if (node.children != null || node.children.length > 0) {
// 说明有子节点
// 有子节点就遍历
for (let i = 0; i < node.children.length; i++) {
// 看看当前子节点的深度是否大于最大深度,如果大于就交换
if (node.children[i].catLevel > this.maxLevel) {
this.maxLevel = node.children[i].catLevel // 大于最大层数就递归
}
// 如果还有子节点的话就遍历递归调用
this.countNodeLevel(node.children[i])
}
}
},
最后再写一个方法判断是否可以拖动即可。
countNodeLevel(node) {
// 找到所有子节点,求出最大深度
if (node.children != null || node.children.length > 0) {
// 说明有子节点
// 有子节点就遍历
for (let i = 0; i < node.children.length; i++) {
// 看看当前子节点的深度是否大于最大深度,如果大于就交换
if (node.children[i].catLevel > this.maxLevel) {
this.maxLevel = node.children[i].catLevel // 大于最大层数就递归
}
// 如果还有子节点的话就遍历递归调用
this.countNodeLevel(node.children[i])
}
}
},
5.1.5.7、拖拽数据修改
上面我们只是实现了拖拽效果,但是数据库数据并没有改变,我们现在要做的就是在拖拽的同时把数据库的信息也改了,在Element-UI提供了一个监听事件给我们。
我们还需要在el-tree上加上这个属性 @node-drop="handleDrop"
,拖拽成功后会调用handleDrop函数。
handleDrop(draggingNode, dropNode, dropType) {
console.log('tree drop: ', dropNode.label, dropType)
},
他有三个参数:
- draggingNode:拖动的节点
- dropNode:拖动到哪个节点
- dropType:拖动类型
- ev:事件,暂时用不上
接着我们去自定义handleDrop方法,来完成我们需要实现的功能。
handleDrop(draggingNode, dropNode, dropType) {
console.log('tree drop: ', dropNode.label, dropType)
// 当前节点的父节点id
let pCid = 0
let sillings = null // 兄弟节点
// 以前面或者后面的方式进入
if (dropType == 'before' || dropType == 'after') {
pCid =
dropNode.parent.data.catId == undefined // 防止出现undefined
? 0
: dropNode.parent.data.catId
sillings = dropNode.parent.childNodes
} else {
pCid = dropNode.data.catId
sillings = dropNode.childNodes
}
// 当前拖拽节点的最新顺序
for (let i = 0; i < sillings.length; i++) {
// 如果遍历的是当前正在拖拽的节点
if (sillings[i].data.catId == draggingNode.data.catId) {
// 如果遍历的是当前正在拖拽的节点
let catLevel = draggingNode.level
// 如果当前节点的层级发生变化
if (sillings[i].level != draggingNode.level) {
// 修改他的子节点的层级
catLevel = sillings[i].level
this.updateChildNodeLevel(sillings[i])
}
this.updateNodes.push({ catId: sillings[i].data.catId, sort: i, parentCid: pCid })
} else {
this.updateNodes.push({ catId: sillings[i].data.catId, sort: i })
}
this.pCid.push(pCid)
}
},
updateChildNodeLevel(node) {
// 他有子节点
if (node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
let cNode = node.childNodes[i].data
this.updateNodes.push({ catId: cNode.catId, catLevel: node.childNodes[i].level })
this.updateChildNodeLevel(node.childNodes[i])
}
}
},
前端写好后,我们要把数据交给数据库,真正修改数据。我们先去productController中写一个方法。
/**
* 批量拖拽修改功能
* @param category 需要批量拖拽修改的数组分类对象
* @return
*/
@RequestMapping("/update/sort")
public R updateSort(@RequestBody CategoryEntity[] category){
categoryService.updateBatchById(Arrays.asList(category));
return R.ok();
}
直接调用MyBatis-Plus里面的方法即可。接下来就是开始细化,有些时候我们不一定是需要拖拽,会有一定的误触概率,我们需要定义一个开关,当开启开关的时候才允许拖拽。还是去看看Element-UI的组件,有一个Switch开关(https://element.eleme.cn/#/zh-CN/component/switch)。
<el-switch
v-model="value1"
active-text="开启拖拽"
inactive-text="关闭拖拽">
</el-switch>
active-text表示开关开启的时候显示的文字, inactive-text表示关闭的时候显示的文字。v-model绑定了一个value值,这个value值决定了拖拽功能是否开启。我们再给el-tree中的动态拖拽功能也动态绑定一个属性,用于动态控制是否可以拖拽。
:draggable="draggable"
我们再将原来官方文档动态绑定的value1的值改为我们自己设置的draggable这个值,即可实现动态绑定,最后再在data中设置这个值即可。
接下来还有一点不足,那就是我们每拖动一次都会与数据库交互,这样会造成频繁与数据库进行交互,我们其实可以做一个按钮,当所有操作拖动完成后一次性点击保存即可。使用el-button可以新增一个按钮,用于批量保存。
<el-button type="primary"
@click="batchUpdate">
保存</el-button>
定义的点击事件中,我们直接将刚才写的与数据库交互的代码复制过去,这里还需要注意一个点。
所以我们需要把pCid定义成全局的变量
直接在data中定义成全局变量即可。
当修改了pCid的值以后我们再去将修改后的pCid赋值给全局的pCid。
在我们修改完后,需要展示当前节点时要用到pCid,所以我们需要使用到全局变量的pCid,同时在修改成功后,不仅仅需要重置对话框的值和最大等级的值,此时还需要把pCid的值重置为0.
此时还有点问题,因为我们在不断地拖拽的时候,在以前是实时和数据库交互,我们可以随时拿到最新的最大深度,但是这次不是实时和数据库交互,所以我们判断能否拖动的条件需要改变,因为我们不可以实时从数据库中拿到最新的值,所以我们的判断条件应该拿到当前页面最新的值来计算最大深度,超过了3就无法拖拽。我们去allowDrop方法里面去修改计算最大深度的值即可。
此时为了避免负数,我们还应该使用绝对值来避免负数。
let currentDepth = Math.abs(this.maxLevel - draggingNode.data.catLevel) + 1
如果不实时和数据库交互,那么我们使用的对象也需要发生改变。
updateChildNodeLevel(node) {
// 他有子节点
if (node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
let cNode = node.childNodes[i].data
this.updateNodes.push({ catId: cNode.catId, catLevel: node.childNodes[i].level })
this.updateChildNodeLevel(node.childNodes[i])
}
}
},
countNodeLevel(node) {
// 找到所有子节点,求出最大深度
if (node.childNodes != null || node.childNodes.length > 0) {
// 说明有子节点
// 有子节点就遍历
for (let i = 0; i < node.childNodes.length; i++) {
// 看看当前子节点的深度是否大于最大深度,如果大于就交换
if (node.childNodes[i].level > this.maxLevel) {
this.maxLevel = node.childNodes[i].level // 大于最大层数就递归
}
// 如果还有子节点的话就遍历递归调用
this.countNodeLevel(node.childNodes[i])
}
}
},
allowDrop(draggingNode, dropNode, type) {
// 判断是否可以拖拽的核心是当前节点以及所在父节点的总层数不可以超过3
/// 1. 计算被拖拽的节点的总层数
// draggingNode表示的是拖拽的节点
this.countNodeLevel(draggingNode)
// 计算最大深度
let currentDepth = Math.abs(this.maxLevel - draggingNode.data.catLevel) + 1
// 再根据拖动类型来判断计算公式
if (type == 'inner') {
// 如果是拖动到里面
// 如果深度小于3,说明可以拖动
return currentDepth + dropNode.level <= 3
} else {
// 如果是拖动到外面
return currentDepth + dropNode.parent.level <= 3
}
},
保存后发现,我们在批量保存的时候,他只展开了一个父节点,此时我们需要展开多个父节点,那么我们就需要将父节点声明成一个数组。
pCid: [],
每修改一个节点我们需要把节点的pCid赋值到数组中,便于展开。
this.pCid.push(pCid);
在批量保存方法中还需要把pCid这个数组中的所有值都直接展开。
5.1.5.8、批量删除
这是最后一个功能——批量删除,我们先搞一个按钮出来。
<el-button type="danger">批量删除</el-button>
在点击按钮后需要触发批量删除方法。
<el-button type="danger"
@click="batchDelete">
批量删除
</el-button>
我们需要拿到节点的选中信息,所以我们需要拿到组件信息,所以我们需要使用ref的内置组件。
ref="menuTree"
通过这个可以拿到选中节点相关信息,而通过getCheckedNodes方法可以拿到已选中的菜单信息,通过控制台我们可以看到,他打印的恰好就是我们选中的节点信息。
然后我们遍历这个数组,拿到每一个被选中的id即可实现批量删除,我们这样就可以拿
batchDelete() {
let cheaked = this.$refs.menuTree.getCheckedNodes()
for (let i = 0; i < cheaked.length; i++) {
console.log(cheaked[i].catId)
}
}
再组装成一个数组即可,接着发请求即可实现批量删除。
batchUpdate() {
// 当前拖拽节点的最新层级
this.$http({
url: this.$http.adornUrl('/product/category/update/sort'),
method: 'post',
data: this.$http.adornData(this.updateNodes, false),
}).then(({ data }) => {
this.$message({
type: 'success',
message: '修改顺序成功!',
})
// 修改成功刷新菜单
this.getMenus()
console.log(this.pCid)
this.expandedKey = this.pCid
this.updateNodes = []
this.maxLevel = 0
this.pCid = 0
})
},