谷粒商城笔记+踩坑(3)——商品服务-三级分类、网关跨域

本文涉及的产品
.cn 域名,1个 12个月
简介: 商品服务-三级分类增删改查、跨域问题、逻辑删除

 

导航:

谷粒商城笔记+踩坑汇总篇

Java笔记汇总:

【Java笔记+踩坑汇总】Java基础+JavaWeb+SSM+SpringBoot+SpringCloud+瑞吉外卖/谷粒商城/学成在线+设计模式+面试题汇总+性能调优/架构设计+源码解析-CSDN博客

目录

6、商品服务-三级分类

6.0、三级分类介绍

6.1、查询三级分类,递归树形结构

6.1.0、回顾stream流

6.1.1、数据库导入表pms_category,需求

6.1.2、修改CategoryController的list方法,树形结构查所有分类

6.1.3、CategoryEntity新增子分类属性

6.1.4、编写树形结构查分类的service方法listWithTree()

6.1.5、启动测试

6.2、人人后台页面管理三级分类

6.2.1、启动renren-fast、gateway、product、renren-fast-vue

6.2.2、renren-fast新增商品目录和分类维护菜单

6.2.3、前端展示三级分类树形控件

6.2.4、前端的请求基本路径设为网关路径

6.2.5、renren-fast注册到nacos,前端路由网关

6.3、解决跨域问题

6.3.1、跨域和同源策略

6.3.2、跨域的流程

6.3.3、解决办法1:使用nginx反向代理为同一域

6.3.4、解决方法2(采用): 配置当前请求允许跨域

6.3.5、用方法2新建配置类,解决跨域问题

6.4、前端树形展示三级分类数据

6.4.1、在网关中配置product路由

6.4.2、使用nacos抽取product模块配置

6.4.3、修改前端分类组件,category.vue

6.4.4、启动product测试

6.5、逻辑删除三级分类

6.5.1、前端,仅无子分类可删除,仅一二级分类可新增

6.5.2、前端,删除逻辑实现,抽取请求到代码片段

6.5.3、后端,逻辑删除@TableLogic

6.6、新增三级分类(仅前端)

6.7、修改分类内容

6.7.1、后端,修改“回显方法”结果对象的键为"data"

6.7.2、前端,修改和新增共享表单窗口el-dialog、表单回显

6.8、修改分类层级关系,拖拽

6.8.1、前端,拖拽功能

6.8.2、前端,拖拽后的数据收集

6.8.3、前端,批量拖拽功能

6.8.4、后端实现拖拽功能(批量修改)

6.9、批量删除分类(仅前端)

6.10、前端分类组件category.vue最终代码


6、商品服务-三级分类

6.0、三级分类介绍

image.gif

6.1、查询三级分类,递归树形结构

实现查询出所有分类和子分类,并且把它们以父子的结构组装起

image.gif

6.1.0、回顾stream流

参考文章:

java SE基础+进阶_vincewm的博客-CSDN博客

Java 8 Stream | 菜鸟教程

1.生成流:list.stream();

2.中间流:filter()、map()、sorted()

filter

filter 方法用于通过设置的条件过滤出元素。

list.stream().filter(Lambda参数是list的每个元素,返回值是Boolean,为true时留下)

image.gif

以下代码片段使用 filter 方法过滤掉空字符串,打印非空字符串。

List<String>list = Arrays.asList("abc", "", "bc", "efg", "abcd","", "jkl");
        //forEach终结后,原list不变,处理后的list遍历输出
        list.stream().filter(item -> !item.isEmpty()).forEach(System.out::println);

image.gif

image.gif

map()

中间操作map()用法和filter类似。

list.stream().map(参数是Lambda,Lambda参数是list的每个元素,返回值是加工后的list元素)

image.gif

下面是小写转大写:

List<String>list = Arrays.asList("abc", "", "bc", "efg", "abcd","", "jkl");
        //collect收集后原list不变,处理后的list收集成返回值
        list=list.stream().map(item->item.toUpperCase()).collect(Collectors.toList());
        System.out.println(list);

image.gif

image.gif

sorted()

list.stream().sorted(参数是Lambda,Lambda参数是list的两个元素,返回值>0是升序,返回值<0是降序)

image.gif

先按字母序再按长度排序:

List<String> list = Arrays.asList("abc", "", "bc", "efg", "abcd","", "jkl");
        //forEach终结后,原list不变,处理后的list遍历输出
        list=list.stream().sorted((item1,item2)->{
            if(item1.compareTo(item2)!=0) return item1.compareTo(item2);
            else return item1.length()-item2.length();
        }).collect(Collectors.toList());
        System.out.println(list);
    }

image.gif

输出:[, , abc, abcd, bc, efg, jkl]

3.终结流用于遍历:list.stream().filter().forEach(System.out::println)

4.收集流用于赋值给原list:list.stream().filter().collect(Collectors.otList())

6.1.1、数据库导入表pms_category,需求

image.gif

先把数据导入表pms_category,数据库文件D:\download\尚硅谷谷粒商城电商项目等1个文件\尚硅谷谷粒商城电商项目\资料源码\docs\代码\sql\pms_catelog.sql

image.gif

需求:

/list/tree路径,get请求,R封装三级分类的响应数据,子分类名children,根据sort字段值排序。

image.gif

6.1.2、修改CategoryController的list方法,树形结构查所有分类

/**
     * 查出所有分类以及子分类,以树形结构组装起来
     */
    @RequestMapping("/list/tree")
    public R list(){
        List<CategoryEntity> entities = categoryService.listWithTree();
        return R.ok().put("data", entities);
    }

image.gif

6.1.3、CategoryEntity新增子分类属性

@TableField(exist = false) //表示数据库表中不存在
  private List<CategoryEntity> children;

image.gif

6.1.4、编写树形结构查分类的service方法listWithTree()

categoryService接口新增方法listWithTree(),编写实现类:

@Override
    public List<CategoryEntity> listWithTree() {
        //1、查出所有分类。baseMapper来自于继承的ServiceImpl<>类,跟CategoryDao一样用法
        List<CategoryEntity> entities = baseMapper.selectList(null);
        //2、递归组装多级分类的树形结构。先过滤得到一级分类,再加工递归设置一级分类的子孙分类,再排序,再收集
        List<CategoryEntity> level1Menus = entities.stream()
                .filter(categoryEntity -> categoryEntity.getParentCid() == 0)
                .map((menu)->{
                    // 设置一级分类的子分类
                    menu.setChildren(getChildren(menu, entities));
                    return menu;
                }).sorted((menu1, menu2) -> {
                    //排序,sort是实体类的排序属性,值越小优先级越高,要判断非空防止空指针异常
                    return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());
                })
                .collect(Collectors.toList());
        return level1Menus;
    }
    //递归查找所有菜单的子菜单
    private List<CategoryEntity> getChildren(CategoryEntity root, List<CategoryEntity> all){
        List<CategoryEntity> children = all.stream()
                .filter(CategoryEntity -> CategoryEntity.getParentCid().equals(root.getCatId()))
                .map(categoryEntity -> {
                    //递归查找
                    categoryEntity.setChildren(getChildren(categoryEntity, all));
                    return categoryEntity;
                })
                .sorted((menu1, menu2) -> {
                    return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());
                })
                .collect(Collectors.toList());
        return children;
    }

image.gif

6.1.5、启动测试

启动测试,打开控制台,重新发送请求,查看数据

image.gif

6.2、人人后台页面管理三级分类

6.2.1、启动renren-fast、gateway、product、renren-fast-vue

idea启动renren-fast、gateway、product,vscode启动renren-fast-vue

npm run dev

image.gif

http://localhost:8001/#/login

image.gif

可以看到验证码就是后端启动成功。默认密码admin,admin

6.2.2、renren-fast新增商品目录和分类维护菜单

登录进去,新增一级菜单商品系统

image.gif

新增菜单分类维护,在商品系统下,路由为product/category

image.gif

新增后刷新页面:

image.gif

新增之后的菜单在都保存在数据库之中,查看gulimall_admin中的sys_menu

image.gif

6.2.3、前端展示三级分类树形控件

看不懂这节参考前面前端基础5.3.5、分析主页展示逻辑

需求:在左侧点击【商品系统-分类维护】,希望在此展示3级分类。可以看到

  • url是http://localhost:8001/#/product-category
  • 填写的菜单路由是product/category
  • 对应的视图是src/view/modules/product/category.vue

自定义product/category视图

所以如果我们要自定义product/category视图的话,就要创建src/views/mudules/product/category.vue

创建vue模板,然后去elementui看如何使用多级目录

输入vue加回车,可快速生成模板。 image.gif

引入elementUI树形控件:

Element - The world's most popular Vue UI framework

image.gif

<!--  -->
<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: [],
        defaultProps: {
          children: 'children',
          label: 'label'
        }
      };
    },
    methods: {
      handleNodeClick(data) {
        console.log(data);
      },
      //获取后台数据
      getMenus(){
          this.$http({
          url: this.$http.adornUrl('/product/category/list/tree'),
          method: 'get'
        }).then(data=>{
            console.log("成功了获取到菜单数据....", data)
        })
      }
    },
//监听属性 类似于data概念
computed: {},
//监控data中的数据变化
watch: {},
//生命周期 - 创建完成(可以访问当前this实例)
created() {
    //创建完成时,就调用getMenus函数
    this.getMenus();
},
//生命周期 - 挂载完成(可以访问DOM元素)
mounted() {
 
},
beforeCreate() {}, //生命周期 - 创建之前
beforeMount() {}, //生命周期 - 挂载之前
beforeUpdate() {}, //生命周期 - 更新之前
updated() {}, //生命周期 - 更新之后
beforeDestroy() {}, //生命周期 - 销毁之前
destroyed() {}, //生命周期 - 销毁完成
activated() {}, //如果页面有keep-alive缓存功能,这个函数会触发
}
</script>
<style scoped>
 
</style>

image.gif

启动测试

image.gif

发现请求端口问题:

发现,他是给8080端口发的请求,而我们的商品服务在10000端口。我们以后还会同时发向更多的端口,所以需要配置网关,前端只向网关发送请求,然后由网关自己路由到相应端口。

6.2.4、前端的请求基本路径设为网关路径

修改前端static/config/index.js

/**
 * 开发环境
 */
;(function () {
  window.SITE_CONFIG = {};
 
  // api接口请求地址
  window.SITE_CONFIG['baseUrl'] = 'http://localhost:88/api';
 
  // cdn地址 = 域名 + 版本号
  window.SITE_CONFIG['domain']  = './'; // 域名
  window.SITE_CONFIG['version'] = '';   // 版本号(年月日时分)
  window.SITE_CONFIG['cdnUrl']  = window.SITE_CONFIG.domain + window.SITE_CONFIG.version;
})();

image.gif

刷新,发现验证码出不来。

验证码请求路径问题:

分析原因:前端给网关发验证码请求,但是验证码请求在renren-fast服务里,所以要想使验证码好使,需要把renren-fast服务注册到服务中心,并且由网关进行路由

image.gif

6.2.5、renren-fast注册到nacos,前端路由网关

renren-fast引入common模块的依赖

<dependency>
      <groupId>com.vince.gulimall</groupId>
      <artifactId>gulimall-common</artifactId>
      <version>0.0.1-SNAPSHOT</version>
    </dependency>

image.gif

这里common模块引入了nacos注册配置依赖:

<dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>
image.gif

修改配置文件application.yml,起名、配置nacos地址

spring:
  application:
    name: renren-fast
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

image.gif

引导类开启服务注册功能

@EnableDiscoveryClient

image.gif

重启renren-fast、注册成功

image.gif

坑点:如果springboot和SpringCloud和SpringCloudAlibaba版本不兼容,就会报错

2021-03-11 12:38:15.512 ERROR 16912 --- [           main] o.s.boot.SpringApplication               : Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'configurationPropertiesBeans' defined in class path resource [org/springframework/cloud/autoconfigure/ConfigurationPropertiesRebinderAutoConfiguration.class]: Post-processing of merged bean definition failed; nested exception is java.lang.IllegalStateException: Failed to introspect Class [org.springframework.cloud.context.properties.ConfigurationPropertiesBeans] from ClassLoader [sun.misc.Launcher$AppClassLoader@18b4aac2]
image.gif

我的版本是boot 2.7.4,cloud2021.0.4,alibaba2021.0.1.0

最新版本兼容问题请看官网,我这里是22年9月时的兼容信息:

Release Train Boot Version
2021.0.x aka Jubilee 2.6.x, 2.7.x (Starting with 2021.0.3)
2020.0.x aka Ilford 2.4.x, 2.5.x (Starting with 2020.0.3)
Hoxton 2.2.x, 2.3.x (Starting with SR5)
Greenwich 2.1.x
Finchley 2.0.x
Edgware 1.5.x
Dalston 1.5.x
Spring Cloud Alibaba Version Spring Cloud Version Spring Boot Version
2021.0.4.0* Spring Cloud 2021.0.4 2.6.11
2021.0.1.0 Spring Cloud 2021.0.1 2.6.3
2021.1 Spring Cloud 2020.0.1 2.4.2

配置网关yml,新增路由

需求: http://localhost:88/api/xxx 转发--> http://renren-fast:8080/renren-fast/xxx

例如:http://localhost:88/api/captcha.jpg   转发-->  http://renren-fast:8080/renren-fast/captcha.jpg

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
    gateway:
      routes:
# 路由id,自定义,只要唯一即可
        - id: admin_route
# uri路由的目标地址。lb就是负载均衡,后面跟服务名称。
          uri: lb://renren-fast
          #断言工厂的Path,请求路径必须符合指定规则
          predicates:
            - Path=/api/**    # 把所有api开头的请求都转发给renren-fast
          #局部过滤器。回顾默认过滤器default-filters是与routes同级
          filters:
#路径重写。逗号左边是原路径,右边是重写后的路径
            - RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
            # 默认规则, 请求过来:http://localhost:88/api/captcha.jpg   转发-->  http://renren-fast:8080/renren-fast/captcha.jpg

image.gif

image.gif

测试访问,出现跨域问题。

6.3、解决跨域问题

6.3.1、跨域和同源策略

点击登录后报错:

image.gif

跨域:指的是浏览器不能执行其他网站的脚本,它是由浏览器的同源策略造成的,是浏览器对javascript施加的安全限制。

同源策略是指协议,域名,端口都要相同,其中有一个不同都会产生跨域问题

URL 说明 是否允许通信
http://www.a.com/a.js http://www.a.com/b.js 同一域名下 允许
http://www.a.com/lab/a.js http://www.a.com/script/b.js 同一域名下不同文件夹 允许
http://www.a.com:8000/a.js http://www.a.com/b.js 同一域名,不同端口 不允许
http://www.a.com/a.js https://www.a.com/b.js 同一域名,不同协议 不允许
http://www.a.com/a.js http://70.32.92.74/b.js 域名和域名对应ip 不允许
http://www.a.com/a.js http://script.a.com/b.js 主域相同,子域不同 不允许
http://www.a.com/a.js http://a.com/b.js 同一域名,不同二级域名(同上) 不允许(cookie这种情况下也不允许访问
http://www.cnblogs.com/a.js http://www.a.com/b.js 不同域名 不允许

6.3.2、跨域的流程

跨域请求的实现是通过预检请求实现的,先发送一个OPSTIONS探路,收到响应允许跨域后再发送真实请求

image.gif

6.3.3、解决办法1:使用nginx反向代理为同一域

使用Nginx反向代理,不同地址、端口都被同一个域名反向代理了,这就是统一域了。这种方法在开发时没法用,所以不采用。

image.gif

6.3.4、解决方法2(采用): 配置当前请求允许跨域

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

  • Access-Control-Allow-Origin : 支持哪些来源的请求跨域
  • Access-Control-Allow-Method : 支持那些方法跨域
  • 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 :表明该响应的有效时间为多少秒。在有效时间内,浏览器无须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将失效。

6.3.5、用方法2新建配置类,解决跨域问题

配置filter,每个请求来了以后,返回给浏览器之前都添加上那些字段

在gulimall-gateway的gulimall.gateway.config包下中新建配置类

//这个包别导错了,有一个很像的。
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@Configuration
public class GulimallCorsConfiguration{
    @Bean
    public CorsWebFilter corsWebFilter(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration= new CorsConfiguration();
        //1、配置跨域
        // 允许跨域的请求头
        corsConfiguration.addAllowedHeader("*");
        // 允许跨域的请求方式
        corsConfiguration.addAllowedMethod("*");
        // 允许跨域的请求来源
        corsConfiguration.addAllowedOriginPattern("*");
//注释的这句会报错。因为当allowCredentials为真时,allowedorigin不能包含特殊值"*",因为不能在"访问-控制-起源“响应头中设置该值。
        //corsConfiguration.addAllowedOrigin("*");//这句会报错
        // 是否允许携带cookie跨域
        corsConfiguration.setAllowCredentials(true);
        // 任意url都要进行跨域配置,两个*号就是可以匹配包含0到多个/的路径
        source.registerCorsConfiguration("/**",corsConfiguration);
        return new CorsWebFilter(source);
    }
}

image.gif

坑点:包别导错:

image.gif

注释掉renren-fast中的跨域,不然会有一些重复的规则导致跨域失败:

io.renren/config/CorsConfig

image.gif

成功解决跨域问题,登录到后台管理界面

image.gif

坑点:

报错:When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.

分析:当allowCredentials为真时,allowedorigin不能包含特殊值"*",因为不能在"访问-控制-起源“响应头中设置该值。

解决:

修改

corsConfiguration.addAllowedOrigin("*");
image.gif

允许跨域的请求来源为

corsConfiguration.addAllowedOrigin("http://localhost:8001");
image.gif

或者:

corsConfiguration.addAllowedOriginPattern("*");
image.gif

6.4、前端树形展示三级分类数据

6.4.1、在网关中配置product路由

查询不到数据,需要在网关配置product路径的请求路由:

image.gif

需求:

localhost:88/api/product/xx        ->        localhost:10000/product/xx

image.gif

配置路由

# 精确的路由要放在/api的admin_route上面
        - id: product_route
          uri: lb://gulimall-product        #路由的目标地址
          predicates:                    # 路由断言。也就是判断请求是否符合路由规则的条件。
            - Path=/api/product/**        # 路径断言。这个是按照路径匹配,只要以/api/product/开头就符合要求
          filters:        #局部过滤器
            - RewritePath=/api/(?<segment>.*),/$\{segment}    #重写路径,/api/xx过滤成/xx

image.gif

坑点:

  • 精确的路由要放到上面,高优先级
  • 服务名不能有下划线,否则会报错: image.gif

目前gateway的yml:

server:
  port: 88
spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
    gateway:
      routes:
        #        商品,路径路由
        - id: product_route
          uri: lb://gulimall-product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/(?<segment>.*),/$\{segment}
        # 人人管理,路径路由。路由id,自定义,只要唯一即可
        - id: admin_route
          # uri路由的目标地址。lb就是负载均衡,后面跟服务名称。
          uri: lb://renren-fast
          #断言工厂的Path,请求路径必须符合指定规则,才能进行转发
          predicates:
            - Path=/api/**    # 把所有api开头的请求都转发给renren-fast
          #局部过滤器。回顾默认过滤器default-filters是与routes同级
          filters:
            - RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
            # 默认规则, 请求过来:http://localhost:88/api/captcha.jpg   转发-->  http://renren-fast:8080/api/captcha.jpg
            # 但是真正的路径是http://renren-fast:8080/renren-fast/captcha.jpg
            # 所以使用路径重写把/api/* 改变成 /renren-fast/*
  application:
    name: gulimall-gateway
image.gif

6.4.2、使用nacos抽取product模块配置

新建product的nacos命名空间

image.gif

在product命名空间下下新建配置 :

image.gif

image.gif

在bootstrap.yml配置

spring:
  application:
    name: gulimall-product
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml
        namespace: 改成刚创建的命名空间id

image.gif

然后配置application.yml中的nacos地址, 在启动类注解@EnableDiscoveryClient,这个之前配置过,检查一下你配置了没有。

6.4.3、修改前端分类组件,category.vue

发现返回的是一个对象,对象.data.data才是我们要的数据。  

src/views/mudules/product/category.vue

image.gif image.gif

image.gif

<!--  -->
<template>
<el-tree :data="menus" :props="defaultProps" @node-click="handleNodeClick"></el-tree>
</template>
 
<script>
//这里可以导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等)
//例如:import 《组件名称》 from '《组件路径》';
 
export default {
//import引入的组件需要注入到对象中才能使用
components: {},
data() {
      return {
        menus: [],
        defaultProps: {
          children: 'children', //子节点
          label: 'name' //name属性作为标签的值,展示出来
        }
      };
    },
    methods: {
      handleNodeClick(data) {
        console.log(data);
      },
      getMenus(){
          this.$http({
          url: this.$http.adornUrl('/product/category/list/tree'),
          method: 'get'
        }).then(({data})=>{
            console.log("成功了获取到菜单数据....", data.data)
            this.menus = data.data;
        })
      }
    },
//监听属性 类似于data概念
computed: {},
//监控data中的数据变化
watch: {},
//生命周期 - 创建完成(可以访问当前this实例)
created() {
    this.getMenus();
},
//生命周期 - 挂载完成(可以访问DOM元素)
mounted() {
 
},
beforeCreate() {}, //生命周期 - 创建之前
beforeMount() {}, //生命周期 - 挂载之前
beforeUpdate() {}, //生命周期 - 更新之前
updated() {}, //生命周期 - 更新之后
beforeDestroy() {}, //生命周期 - 销毁之前
destroyed() {}, //生命周期 - 销毁完成
activated() {}, //如果页面有keep-alive缓存功能,这个函数会触发
}
</script>
<style scoped>
 
</style>

image.gif

6.4.4、启动product测试

启动product模块,访问http://localhost:10000/product/category/list/tree

image.gif

启动renren-fast后台管理模块,访问http://localhost:8001/#/product-category

image.gif

nacos也已经注册:

image.gif

6.5、逻辑删除三级分类

6.5.1、前端,仅无子分类可删除,仅一二级分类可新增

准备:

  • elementUI选择tree树形控件的自定义节点内容,复制粘贴两个按钮到自己category.vue的<el-tree>标签下

Element - The world's most popular Vue UI framework

image.gif

<!-- node代表当前结点(是否展开等信息,element-ui自带属性),data是结点数据,是自己的数据。 -->
      <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>

image.gif

分析步骤:

  • 在每一个菜单后面添加append, delete
  • 点击按钮时,不进行菜单的打开合并,仅点击箭头时展示子分类:expand-on-click-node="false"
  • 当没有子菜单时,才可以显示delete按钮当为一级、二级菜单时,才显示append按钮。使用v-if
  • <el-tree>添加多选框show-checkbox
  • 设置node-key=""标识每一个节点的不同
<!--  -->
<template>
  <el-tree
    :data="menus"
    show-checkbox
    :props="defaultProps"
    @node-click="handleNodeClick"
    :expand-on-click-node="false"
    node-key="catId"
  >
    <span class="custom-tree-node" slot-scope="{ node, data }">
      <span>{{ node.label }}</span>
      <span>
        <el-button
          type="text"
          v-if="node.level <= 2"
          size="mini"
          @click="() => append(data)"
        >
          Append
        </el-button>
        <el-button
          type="text"
          v-if="node.childNodes.length == 0"
          size="mini"
          @click="() => remove(node, data)"
        >
          Delete
        </el-button>
      </span>
    </span>
  </el-tree>
</template>
<script>
//这里可以导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等)
//例如:import 《组件名称》 from '《组件路径》';
export default {
  //import引入的组件需要注入到对象中才能使用
  components: {},
  data() {
    return {
      menus: [],
      defaultProps: {
        children: "children", //子节点
        label: "name", //name属性作为标签的值,展示出来
      },
    };
  },
  methods: {
    handleNodeClick(data) {},
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get",
      }).then(({ data }) => {
        console.log("成功了获取到菜单数据....", data.data);
        this.menus = data.data;
      });
    },
    append(data) {
      console.log("append", data);
    },
    remove(node, data) {
      console.log("remove", node, data);
    },
  },
  //监听属性 类似于data概念
  computed: {},
  //监控data中的数据变化
  watch: {},
  //生命周期 - 创建完成(可以访问当前this实例)
  created() {
    this.getMenus();
  },
  //生命周期 - 挂载完成(可以访问DOM元素)
  mounted() {},
  beforeCreate() {}, //生命周期 - 创建之前
  beforeMount() {}, //生命周期 - 挂载之前
  beforeUpdate() {}, //生命周期 - 更新之前
  updated() {}, //生命周期 - 更新之后
  beforeDestroy() {}, //生命周期 - 销毁之前
  destroyed() {}, //生命周期 - 销毁完成
  activated() {}, //如果页面有keep-alive缓存功能,这个函数会触发
};
</script>
<style scoped>
</style>

image.gif

6.5.2、前端,删除逻辑实现,抽取请求到代码片段

抽取请求代码块到代码片段:

新增两个代码块,发送get请求和post请求。 文件->首选项->用户片段->新建全局代码片段,文件名vue.code-snippets(snippets译为代码片段,片段)

{
  // Place your 全局 snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 
  // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 
  // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 
  // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 
  // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 
  // Placeholders with the same ids are connected.
  // Example:
  // "Print to console": {
  //  "scope": "javascript,typescript",
  //  "prefix": "log",
  //  "body": [
  //    "console.log('$1');",
  //    "$2"
  //  ],
  //  "description": "Log output to console"
  // }
  "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请求"
    }
}

image.gif

保存。

使用代码片段:

在category.vue的remove方法里输入httppost回车即可:

image.gif

image.gif

然后根据下面的删除逻辑进行操作:

删除逻辑:

  • 编写前端remove方法,实现向后端发送请求
  • 点击delete弹出提示框,是否删除这个节点: elementui中MessageBox 弹框中的确认消息添加到删除之前
  • 删除成功后有消息提示: elementui中Message 消息提示
  • 删除后刷新页面后,分类应该保持之前展开状态: el-tree组件的default-expanded-keys属性,默认展开。 每次删除之后,把删除菜单的父菜单的id值赋给默认展开值即可。
//在<el-tree>中设置默认展开属性,绑定给expandedKey
:default-expanded-keys="expandedKey"
//data中添加属性,删除后给它赋值父节点id,令树形控件刷新后展开
expandedKey: [],
//完整的remove方法
    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.$message({
              message: "菜单删除成功",
              type: "success",
            });
            //刷新出新的菜单
            this.getMenus();
            //设置需要默认展开的菜单。删除后给它赋值父节点id,令树形控件刷新后展开
            this.expandedKey = [node.parent.data.catId]
          });
        })
        .catch(() => {});
    },

image.gif

6.5.3、后端,逻辑删除@TableLogic

  • 查看controller,发现参数@RequestBody注解,请求体里面的数据,只有post请求才有请求体。

mybatisplus逻辑删除步骤:

逻辑删除 | MyBatis-Plus

  • 方法一(mp3.3.0版本后,不推荐怕忘):配置yml,全局
mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置本项后可以忽略不配置实体类注解@TableLogic)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)。
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
  • image.gif
  • 方法二(mp所有版本,推荐):
  • 实体类成员变量加上@TableLogic(value = "0", delval = "1")注解

注意:本项目使用的是category表的show_status字段逻辑删除值正好相反,状态为1表示未删除,状态为0表示删除。

CategoryEntity注解逻辑删除:

/**
   * 是否显示[0-不显示,1显示]
   */
  @TableLogic(value = "1",delval = "0")
  private Integer showStatus;

image.gif

service和controller不用改,使用mybatisplus生成的即可。

  • 重启项目,用postman发请求:
### 6.Send POST request with json body
POST http://localhost:88/api/product/category/delete
Content-Type: application/json
[1431]

image.gif

测试成功

image.gif

image.gif

前端删除也是成功的:

image.gif

可以打印日志,在配置文件中修改日志级别

logging:
  level:
    com.xmh.guliamll.product: debug
image.gif

删除后恢复数据:sql语句

UPDATE pms_category SET show_status=1;

image.gif

6.6、新增三级分类(仅前端)

后端已完成,用mybatisplus自动生成的新增即可。

实现步骤:

1、elementui中Dialog 对话框

  • 一个会话的属性为:visible.sync=“dialogVisible”
  • 导出的data中"dialogVisible = false"
  • 点击确认或者取消后的逻辑都是@click=“dialogVisible = false” 关闭会话

2、点击append,弹出对话框,输入分类名称

3、点击确定,发送post请求: 新建方法addCategory发送post请求到后端; 因为要把数据添加到数据库,所以在前端数据中按照数据库的格式声明一个category属性。点击append时,计算category属性,点击确定时发送post请求。

对话框组件el-dialog:

对话框标签el-dialog放在el-tree标签上下都是可以的,主要是visible.sync属性控制对话框的显示和隐藏。

<!--对话框组件-->
  <el-dialog title="提示" :visible.sync="dialogVisible" width="30%">
      <el-form :model="categroy">
        <el-form-item label="分类名称">
          <el-input v-model="categroy.name" autocomplete="off"></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="addCategory">确 定</el-button>
      </span>
    </el-dialog>

image.gif

js代码:

注意:category属性用来接收输入框的参数,需要赋值默认属性,包括父id,层级(这两项el-tree增函数实参data里就有)、展示状态为1,排序值是0

//1data中新增数据
//按照数据库格式声明的数据。注意category属性用来接收输入框的参数,需要赋值默认属性,包括父id,层级、展示状态为1,排序值是0
      categroy: { name: "", parentCid: 0, catLevel: 0, showStatus: 1, sort: 0 },
//判断是否显示对话框
      dialogVisible: false,
          
//修改append方法,新增addCategory方法
//点击append后,计算category属性,显示对话框
    append(data) {
      console.log("append", data);
      this.dialogVisible = true;
      this.categroy.parentCid = data.catId;
      this.categroy.catLevel = data.catLevel * 1 + 1;
    },
//点击确定后,发送post请求
//成功后显示添加成功,展开刚才的菜单
    addCategory() {
      console.log("提交的数据", this.categroy);
      this.$http({
        url: this.$http.adornUrl("/product/category/save"),
        method: "post",
        data: this.$http.adornData(this.categroy, false),
      }).then(({ data }) => {
          this.$message({
              message: "添加成功",
              type: "success",
            });
            //刷新出新的菜单
            this.getMenus();
            //设置需要默认展开的菜单
            this.expandedKey = [this.categroy.parentCid];
            this.dialogVisible = false;
      });

image.gif

6.7、修改分类内容

6.7.1、后端,修改“回显方法”结果对象的键为"data"

product模块的CategoryController

image.gif

/**
     * 信息
     */
    @RequestMapping("/info/{catId}")
    public R info(@PathVariable("catId") Long catId){
    CategoryEntity category = categoryService.getById(catId);
        return R.ok().put("data", category);
    }

image.gif

6.7.2、前端,修改和新增共享表单窗口el-dialog、表单回显

后端已完成,用mybatisplus自动生成的新增即可。

实现修改名称,图标,计量单位。

1、新增Edit按钮:复制之前的append

2、查看controller,发现updata方法是由id进行更新的,所以data中的category中新增catId

3、增加、修改的时候也修改图标和计量单位,所以data的category新增inco,productUnit

4、新建edit方法,用来绑定Edit按钮。新建editCategory方法,用来绑定对话框的确定按钮。

5、复用对话框:

  • data数据中新增属性dialogType用来区分提交时是新增还是

image.gif

  • 新建方法submitData,与对话框的确定按钮进行绑定,在方法中判断,如果dialogType==add调用addCategory(),如果dialogType==edit调用editCategory()
  • data数据中新增title,绑定对话框的title,用来做提示信息。判断dialogType的值,来选择提示信息。

6、修改回显必须发请求,而非直接从实参中获取。防止多个人同时操作,对话框中的回显的信息应该是由数据库中读出来的:点击Edit按钮,发送httpget请求。(看好返回的数据)

7、editCategory方法:

  • controller之中的更新是动态更新,根据id,发回去什么值修改什么值,所以把要修改的数据发回后端就好。
  • 成功之后发送提示消息,展开刚才的菜单。

8、之后,再点击添加,发现会回显刚才的信息。所以在append方法中重置回显的信息。

坑点:

  • 修改回显必须发请求,而非直接从实参中获取
  • 因为新增修改共享表单对话框,所以还要修改添加方法,第一步初始化数据,这样data里定义category时也不用初始化了

image.gif

  • 提交修改表单时,不能像新增一样把携带初始化值的category直接提交上去。

方法一:局部更新,只解构出表单里的属性封装成对象,再提交。

image.gif

方法二:全量更新,回显时,把其他数据库字段也赋值:

image.gif

代码+注释:

<!--编辑按钮-->
      <el-button type="text" size="mini" @click="() => edit(data)">
            Edit
          </el-button>
<!--可复用的对话框-->
  <el-dialog :title="title" :visible.sync="dialogVisible" width="30%">
      <el-form :model="categroy">
        <el-form-item label="分类名称">
          <el-input v-model="categroy.name" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="图标">
          <el-input v-model="categroy.inco" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="计量单位">
          <el-input
            v-model="categroy.productUnit"
            autocomplete="off"
          ></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitData">确 定</el-button>
      </span>
    </el-dialog>

image.gif

//data, 新增了title、dialogType。 categroy中新增了inco、productUnit、catId
  data() {
    return {
      title: "",
      dialogType: "",
      categroy: {
        name: "",
        parentCid: 0,
        catLevel: 0,
        showStatus: 1,
        sort: 0,
        inco: "",
        productUnit: "",
        catId: null,
      },
      dialogVisible: false,
      menus: [],
      expandedKey: [],
      defaultProps: {
        children: "children", //子节点
        label: "name", //name属性作为标签的值,展示出来
      },
    };
      
//方法
     //绑定对话框的确定按钮,根据dialogType判断调用哪个函数
    submitData() {
      if (this.dialogType == "add") {
        this.addCategory();
      }
      if (this.dialogType == "edit") {
        this.editCategory();
      }
    },
        
        //绑定Edit按钮,设置dialogType、title,从后台读取数据,展示到对话框内
    edit(data) {
      this.dialogType = "edit";
      this.title = "修改菜单";
      this.dialogVisible = true;
      this.$http({
        url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
        method: "get",
      }).then(({ data }) => {
        console.log(data);
        this.categroy.catId = data.data.catId;
        this.categroy.name = data.data.name;
        this.categroy.inco = data.data.inco;
        this.categroy.productUnit = data.data.productUnit;
      });
    },
        
        //绑定对话框的确定按钮,向后台发送更新请求,传过去想要修改的字段
    editCategory() {
      var { catId, name, inco, productUnit } = this.categroy;
      this.$http({
        url: this.$http.adornUrl("/product/category/update"),
        method: "post",
        data: this.$http.adornData({ catId, name, inco, productUnit }, false),
      }).then(({ data }) => {
        this.$message({
          message: "修改成功",
          type: "success",
        });
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = [this.categroy.parentCid];
        this.dialogVisible = false;
      });
    },
    
    //点击append按钮,清空编辑之后的回显数据
    append(data) {
      this.dialogType = "add";
      this.title = "添加菜单";
      console.log("append", data);
      this.dialogVisible = true;
      this.categroy.parentCid = data.catId;
      this.categroy.catLevel = data.catLevel * 1 + 1;
      this.categroy.name = "",
      this.categroy.inco = "",
      this.categroy.productUnit = ""
    },

image.gif

6.8、修改分类层级关系,拖拽

6.8.1、前端,拖拽功能

Element - The world's most popular Vue UI framework

  • <el-tree>中加入属性draggable表示节点可拖拽
  • <el-tree>中加入属性:allow-drop="allowDrop"拖拽时判定目标节点能否被放置。
  • allowDrop有三个参数draggingNode表示拖拽的节点,dropNode表示拖拽到哪个节点,type表示拖拽的类型’prev’、‘inner’ 和 ‘next’,表示拖拽到目标节点之前、里面、之后。

注意:函数实现判断,拖拽后必须保持数型的三层结构。

  • 节点的深度 = 最深深度 - 当前深度 + 1
  • 当拖拽节点拖拽到目标节点的内部,要满足: 拖拽节点的深度 + 目标节点的深度 <= 3
  • 当拖拽节点拖拽的目标节点的两侧,要满足: 拖拽节点的深度 + 目标节点的父节点的深度 <= 3

代码 + 注释:

<!--el-tree中添加属性-->
     draggable
      :allow-drop="allowDrop"

image.gif

// data中新增属性,用来记录当前节点的最大深度
maxLevel: 1,
    
    
//新增方法
    allowDrop(draggingNode, dropNode, type) {
      console.log("allowDrag:", draggingNode, dropNode, type);
      //节点的最大深度
      this.countNodeLevel(draggingNode.data);
      console.log("level:", this.maxLevel);
      //当前节点的深度
      let deep = (this.maxLevel - draggingNode.data.catLevel) + 1;
      console.log(deep)
      if (type == "inner"){
          return (deep + dropNode.level) <= 3;
      }else{
          return (deep + dropNode.parent.level) <= 3;
      }
    },
    //计算当前节点的最大深度
    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]);
            }
        }
    },

image.gif

6.8.2、前端,拖拽后的数据收集

  • <el-tree>中加入属性@node-drop="handleDrop",表示拖拽事件结束后触发事件handleDrop,handleDrop共四个参数,draggingNode:被拖拽节点对应的 Node; dropNode:结束拖拽时最后进入的节点; dropType:被拖拽节点的放置位置(before、after、inner);ev:event
  • 拖拽可能影响的节点的数据:parentCid、catLevel、sort
  • data中新增updateNodes,把所有要修改的节点都传进来。
  • 要修改的数据:拖拽节点的parentCid、catLevel、sort
  • 要修改的数据:新的兄弟节点的sort (把新的节点收集起来,然后重新排序)
  • 要修改的数据:子节点的catLevel

代码 + 注释:

//el-tree中新增属性,绑定handleDrop,表示拖拽完触发
@node-drop="handleDrop"
//data 中新增数据,用来记录需要更新的节点(拖拽的节点(parentCid、catLevel、sort),拖拽后的兄弟节点(sort),拖拽节点的子节点(catLevel))
updateNodes: [],
    
//新增方法
    handleDrop(draggingNode, dropNode, dropType, ev) {
      console.log("handleDrop: ", draggingNode, dropNode, dropType);
      //1、当前节点最新父节点的id
      let pCid = 0;
      //拖拽后的兄弟节点,分两种情况,一种是拖拽到两侧,一种是拖拽到内部
      let sibings = null;
      if (dropType == "before" || dropType == "after") {
        pCid = dropNode.parent.data.catId == undefined ? 0: dropNode.parent.data.catId;
        sibings = dropNode.parent.childNodes;
      } else {
        pCid = dropNode.data.catId;
        sibings = dropNode.childNodes;
      }
      //2、当前拖拽节点的最新顺序
      //遍历所有的兄弟节点,如果是拖拽节点,传入(catId,sort,parentCid,catLevel),如果是兄弟节点传入(catId,sort)
      for (let i = 0; i < sibings.length; i++) {
          if (sibings[i].data.catId == draggingNode.data.catId){
              //如果遍历的是当前正在拖拽的节点
              let catLevel = draggingNode.level;
              if (sibings[i].level != draggingNode.level){
                  //当前节点的层级发生变化
                  catLevel = sibings[i].level;
                  //修改他子节点的层级
                  this.updateChildNodeLevel(sibings[i]);
              }
              this.updateNodes.push({catId:sibings[i].data.catId, sort: i, parentCid: pCid, catLevel:catLevel});
          }else{
              this.updateNodes.push({catId:sibings[i].data.catId, sort: i});
          }
          
      }
    
    
    
    //每次拖拽后把数据清空,否则要修改的节点将会越拖越多
    this.updateNodes = [],
    this.maxLevel = 1,
    }
// 修改拖拽节点的子节点的层级
updateChildNodeLevel(node){
        if (node.childNodes.length > 0){
            for (let i = 0; i < node.childNodes.length; i++){
                //遍历子节点,传入(catId,catLevel)
                var cNode = node.childNodes[i].data;
                this.updateNodes.push({catId:cNode.catId,catLevel:node.childNodes[i].level});
                //处理子节点的子节点
                this.updateChildNodeLevel(node.childNodes[i]);
            }
        }
    },

image.gif

前端发送请求:

this.$http({
        url: this.$http.adornUrl("/product/category/update/sort"),
        method: "post",
        data: this.$http.adornData(this.updateNodes, false),
      }).then(({ data }) => {
        this.$message({
          message: "菜单顺序修改成功",
          type: "success",
        });
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = [pCid];
      });

image.gif

6.8.3、前端,批量拖拽功能

  • 添加开关,控制拖拽功能是否开启
  • 每次拖拽都要和数据库交互,不合理。批量拖拽过后,一次性保存。
<!--添加拖拽开关和批量保存按钮-->
  <el-switch
      v-model="draggable"
      active-text="开启拖拽"
      inactive-text="关闭拖拽"
    >
    </el-switch>
    <el-button v-if="draggable" size="small" round @click="batchSave"
      >批量保存</el-button
    >

image.gif

//data中新增数据
 pCid:[], //批量保存过后要展开的菜单id
 draggable: false, //绑定拖拽开关是否打开
  
//修改了一些方法,修复bug,修改过的方法都贴在下面了
     
//点击批量保存按钮,发送请求
    batchSave() {
      this.$http({
        url: this.$http.adornUrl("/product/category/update/sort"),
        method: "post",
        data: this.$http.adornData(this.updateNodes, false),
      }).then(({ data }) => {
        this.$message({
          message: "菜单顺序修改成功",
          type: "success",
        });
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = this.pCid;
      });
      this.updateNodes = [];
    },
  
        
//
    handleDrop(draggingNode, dropNode, dropType, ev) {
      console.log("handleDrop: ", draggingNode, dropNode, dropType);
      //1、当前节点最新父节点的id
      let pCid = 0;
      let sibings = null;
      if (dropType == "before" || dropType == "after") {
        pCid =
          dropNode.parent.data.catId == undefined
            ? 0
            : dropNode.parent.data.catId;
        sibings = dropNode.parent.childNodes;
      } else {
        pCid = dropNode.data.catId;
        sibings = dropNode.childNodes;
      }
      //2、当前拖拽节点的最新顺序
      for (let i = 0; i < sibings.length; i++) {
        if (sibings[i].data.catId == draggingNode.data.catId) {
          //如果遍历的是当前正在拖拽的节点
          let catLevel = draggingNode.level;
          if (sibings[i].level != draggingNode.level) {
            //当前节点的层级发生变化
            catLevel = sibings[i].level;
            //修改他子节点的层级
            this.updateChildNodeLevel(sibings[i]);
          }
          this.updateNodes.push({
            catId: sibings[i].data.catId,
            sort: i,
            parentCid: pCid,
            catLevel: catLevel,
          });
        } else {
          this.updateNodes.push({ catId: sibings[i].data.catId, sort: i });
        }
      }
      this.pCid.push(pCid);
      console.log(this.pCid)
      //3、当前拖拽节点的最新层级
      //console.log("updateNodes", this.updateNodes)
      //拖拽之后重新置1
      this.maxLevel = 1;
    },
        
// 修改拖拽判断逻辑
   allowDrop(draggingNode, dropNode, type) {
      console.log("allowDrag:", draggingNode, dropNode, type);
      this.maxLevel = draggingNode.level;
      //节点的最大深度
      this.countNodeLevel(draggingNode);
      console.log("maxLevel:", this.maxLevel);
      //当前节点的深度
      let deep = Math.abs(this.maxLevel - draggingNode.level) + 1;
      console.log("level",deep);
      if (type == "inner") {
        return deep + dropNode.level <= 3;
      } else {
        return deep + dropNode.parent.level <= 3;
      }
    },
        
//计算深度时,用当前数据,而不是数据库中的数据。因为可能还没来得及保存到数据库
  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]);
        }
      }
    },

image.gif

6.8.4、后端实现拖拽功能(批量修改)

思路:

  • 在后端编写批量修改的方法update/sort
  • 前端发送post请求,把要修改的数据发送过来
  • 提示信息,展开拖拽节点的父节点

CategoryController批量修改

//批量修改,参数要传数组,不能传list
  @RequestMapping("/update/sort")
    public R updateSort(@RequestBody CategoryEntity[] category){
        categoryService.updateBatchById(Arrays.asList(category));
        return R.ok();
    }

image.gif

测试批量修改:

image.gif

POST http://localhost:88/api/product/category/update/sort
Content-Type: application/json
[
  {
    "catId": 1,
    "sort" : 10
  },
  {
    "catId": 226,
    "catLevel": 2
  }
]

image.gif

image.gif

6.9、批量删除分类(仅前端)

批量删除 后端已完成,用mybatisplus自动生成的新增即可。

image.gif

  • 新增删除按钮
<el-button type="danger" size="small" @click="batchDelete" round>批量删除</el-button>
<!--eltree中新增属性,用作组件的唯一标示-->
ref="menuTree"

image.gif

  • 批量删除方法
batchDelete(){
      let catIds = [];
      let catNames = [];
      let checkedNodes = this.$refs.menuTree.getCheckedNodes();
      for (let i = 0; i < checkedNodes.length; i++){
          catIds.push(checkedNodes[i].catId);
          catNames.push(checkedNodes[i].name);
      }
      this.$confirm(`是否批量删除【${catNames}】菜单?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning",
      }).then(()=>{
        this.$http({
        url:this.$http.adornUrl('/product/category/delete'),
        method:'post',
        data: this.$http.adornData(catIds, false)
        }).then(({data})=>{ 
          this.$message({
          message: "菜单批量删除成功",
          type: "success",
        });
        this.getMenus();
        })
      }).catch(()=>{
      });
    },

image.gif

6.10、前端分类组件category.vue最终代码

image.gif

<template>
  <div>
    <el-switch v-model="draggable" active-text="开启拖拽" inactive-text="关闭拖拽"></el-switch>
    <el-button v-if="draggable" @click="batchSave">拖拽保存</el-button>
    <el-button type="danger" @click="batchDelete">批量删除</el-button>
    <el-tree
      :data="menus"
      :props="defaultProps"
      :expand-on-click-node="false"
      show-checkbox
      node-key="catId"
      :default-expanded-keys="expandedKey"
      :draggable="draggable"
      :allow-drop="allowDrop"
      @node-drop="handleDrop"
      ref="menuTree"
    >
      <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
          <el-button
            v-if="node.level <=2"
            type="text"
            size="mini"
            @click="() => append(data)"
          >新增</el-button>
          <el-button type="text" size="mini" @click="edit(data)">编辑</el-button>
          <el-button
            v-if="node.childNodes.length==0"
            type="text"
            size="mini"
            @click="() => remove(node, data)"
          >删除</el-button>
        </span>
      </span>
    </el-tree>
    <el-dialog
      :title="title"
      :visible.sync="dialogVisible"
      width="30%"
      :close-on-click-modal="false"
    >
      <el-form :model="category">
        <el-form-item label="分类名称">
          <el-input v-model="category.name" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="图标">
          <el-input v-model="category.icon" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="计量单位">
          <el-input v-model="category.productUnit" autocomplete="off"></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitData">确 定</el-button>
      </span>
    </el-dialog>
  </div>
</template>
<script>
//这里可以导入其他文件(比如:组件,工具js,第三方插件js,json文件,图片文件等等)
//例如:import 《组件名称》 from '《组件路径》';
export default {
  //import引入的组件需要注入到对象中才能使用
  components: {},
  props: {},
  data() {
    return {
      pCid: [],
      draggable: false,
      updateNodes: [],
      maxLevel: 0,
      title: "",
      dialogType: "", //edit,add
      category: {
        name: "",
        parentCid: 0,
        catLevel: 0,
        showStatus: 1,
        sort: 0,
        productUnit: "",
        icon: "",
        catId: null
      },
      dialogVisible: false,
      menus: [],
      expandedKey: [],
      defaultProps: {
        children: "children",
        label: "name"
      }
    };
  },
  //计算属性 类似于data概念
  computed: {},
  //监控data中的数据变化
  watch: {},
  //方法集合
  methods: {
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        console.log("成功获取到菜单数据...", data.data);
        this.menus = data.data;
      });
    },
    batchDelete() {
      let catIds = [];
      let checkedNodes = this.$refs.menuTree.getCheckedNodes();
      console.log("被选中的元素", checkedNodes);
      for (let i = 0; i < checkedNodes.length; i++) {
        catIds.push(checkedNodes[i].catId);
      }
      this.$confirm(`是否批量删除【${catIds}】菜单?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      })
        .then(() => {
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(catIds, false)
          }).then(({ data }) => {
            this.$message({
              message: "菜单批量删除成功",
              type: "success"
            });
            this.getMenus();
          });
        })
        .catch(() => {});
    },
    batchSave() {
      this.$http({
        url: this.$http.adornUrl("/product/category/update/sort"),
        method: "post",
        data: this.$http.adornData(this.updateNodes, false)
      }).then(({ data }) => {
        this.$message({
          message: "菜单顺序等修改成功",
          type: "success"
        });
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = this.pCid;
        this.updateNodes = [];
        this.maxLevel = 0;
        // this.pCid = 0;
      });
    },
    handleDrop(draggingNode, dropNode, dropType, ev) {
      console.log("handleDrop: ", draggingNode, dropNode, dropType);
      //1、当前节点最新的父节点id
      let pCid = 0;
      let siblings = null;
      if (dropType == "before" || dropType == "after") {
        pCid =
          dropNode.parent.data.catId == undefined
            ? 0
            : dropNode.parent.data.catId;
        siblings = dropNode.parent.childNodes;
      } else {
        pCid = dropNode.data.catId;
        siblings = dropNode.childNodes;
      }
      this.pCid.push(pCid);
      //2、当前拖拽节点的最新顺序,
      for (let i = 0; i < siblings.length; i++) {
        if (siblings[i].data.catId == draggingNode.data.catId) {
          //如果遍历的是当前正在拖拽的节点
          let catLevel = draggingNode.level;
          if (siblings[i].level != draggingNode.level) {
            //当前节点的层级发生变化
            catLevel = siblings[i].level;
            //修改他子节点的层级
            this.updateChildNodeLevel(siblings[i]);
          }
          this.updateNodes.push({
            catId: siblings[i].data.catId,
            sort: i,
            parentCid: pCid,
            catLevel: catLevel
          });
        } else {
          this.updateNodes.push({ catId: siblings[i].data.catId, sort: i });
        }
      }
      //3、当前拖拽节点的最新层级
      console.log("updateNodes", this.updateNodes);
    },
    updateChildNodeLevel(node) {
      if (node.childNodes.length > 0) {
        for (let i = 0; i < node.childNodes.length; i++) {
          var cNode = node.childNodes[i].data;
          this.updateNodes.push({
            catId: cNode.catId,
            catLevel: node.childNodes[i].level
          });
          this.updateChildNodeLevel(node.childNodes[i]);
        }
      }
    },
    allowDrop(draggingNode, dropNode, type) {
      //1、被拖动的当前节点以及所在的父节点总层数不能大于3
      //1)、被拖动的当前节点总层数
      console.log("allowDrop:", draggingNode, dropNode, type);
      //
      this.countNodeLevel(draggingNode);
      //当前正在拖动的节点+父节点所在的深度不大于3即可
      let deep = Math.abs(this.maxLevel - draggingNode.level) + 1;
      console.log("深度:", deep);
      //   this.maxLevel
      if (type == "inner") {
        // console.log(
        //   `this.maxLevel:${this.maxLevel};draggingNode.data.catLevel:${draggingNode.data.catLevel};dropNode.level:${dropNode.level}`
        // );
        return deep + dropNode.level <= 3;
      } else {
        return deep + dropNode.parent.level <= 3;
      }
    },
    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]);
        }
      }
    },
    edit(data) {
      console.log("要修改的数据", data);
      this.dialogType = "edit";
      this.title = "修改分类";
      this.dialogVisible = true;
      //发送请求获取当前节点最新的数据
      this.$http({
        url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
        method: "get"
      }).then(({ data }) => {
        //请求成功
        console.log("要回显的数据", data);
        this.category.name = data.data.name;
        this.category.catId = data.data.catId;
        this.category.icon = data.data.icon;
        this.category.productUnit = data.data.productUnit;
        this.category.parentCid = data.data.parentCid;
        this.category.catLevel = data.data.catLevel;
        this.category.sort = data.data.sort;
        this.category.showStatus = data.data.showStatus;
        /**  
         *         parentCid: 0,
        catLevel: 0,
        showStatus: 1,
        sort: 0,
         */
      });
    },
    append(data) {
      console.log("append", data);
      this.dialogType = "add";
      this.title = "添加分类";
      this.dialogVisible = true;
      this.category.parentCid = data.catId;
      this.category.catLevel = data.catLevel * 1 + 1;
      this.category.catId = null;
      this.category.name = "";
      this.category.icon = "";
      this.category.productUnit = "";
      this.category.sort = 0;
      this.category.showStatus = 1;
    },
    submitData() {
      if (this.dialogType == "add") {
        this.addCategory();
      }
      if (this.dialogType == "edit") {
        this.editCategory();
      }
    },
    //提交修改三级分类数据
    editCategory() {
      var { catId, name, icon, productUnit } = this.category;
      this.$http({
        url: this.$http.adornUrl("/product/category/update"),
        method: "post",
        data: this.$http.adornData({ catId, name, icon, productUnit }, false)
      }).then(({ data }) => {
        this.$message({
          message: "菜单修改成功",
          type: "success"
        });
        //关闭对话框
        this.dialogVisible = false;
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = [this.category.parentCid];
      });
    },
    //提交添加三级分类
    addCategory() {
      console.log("提交的三级分类数据", this.category);
      this.$http({
        url: this.$http.adornUrl("/product/category/save"),
        method: "post",
        data: this.$http.adornData(this.category, false)
      }).then(({ data }) => {
        this.$message({
          message: "菜单保存成功",
          type: "success"
        });
        //关闭对话框
        this.dialogVisible = false;
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = [this.category.parentCid];
      });
    },
    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.$message({
              message: "菜单删除成功",
              type: "success"
            });
            //刷新出新的菜单
            this.getMenus();
            //设置需要默认展开的菜单
            this.expandedKey = [node.parent.data.catId];
          });
        })
        .catch(() => {});
      console.log("remove", node, data);
    }
  },
  //生命周期 - 创建完成(可以访问当前this实例)
  created() {
    this.getMenus();
  },
  //生命周期 - 挂载完成(可以访问DOM元素)
  mounted() {},
  beforeCreate() {}, //生命周期 - 创建之前
  beforeMount() {}, //生命周期 - 挂载之前
  beforeUpdate() {}, //生命周期 - 更新之前
  updated() {}, //生命周期 - 更新之后
  beforeDestroy() {}, //生命周期 - 销毁之前
  destroyed() {}, //生命周期 - 销毁完成
  activated() {} //如果页面有keep-alive缓存功能,这个函数会触发
};
</script>
<style scoped>
</style>

image.gif

相关文章
|
6月前
|
前端开发 Java 应用服务中间件
解决跨域问题的8种方法,含网关、Nginx和SpringBoot~
解决跨域问题的8种方法,含网关、Nginx和SpringBoot~
658 0
解决跨域问题的8种方法,含网关、Nginx和SpringBoot~
|
边缘计算 负载均衡 网络虚拟化
在GitHub首页3分钟被下架!爱奇艺《高并发网关设计》笔记被盗?
阿嘴又又又又又来给大家分享好书了:爱奇艺网络虚拟化团队的 《负载均衡:高并发网关设计原理与实践》,出版才半年,开源版本也只能想想!小编会在文末附电子版免费下载方式。
|
2月前
|
安全 Java 数据安全/隐私保护
|
3月前
|
微服务 应用服务中间件
微服务跨域(通过网关配置进行跨域)
在单体架构中,我们通常通过SpringMVC配置类实现CORS跨域支持,设置允许的来源、请求头、方法及凭证等。然而,在微服务架构下,因浏览器首先访问网关再进行服务路由,需在网关配置跨域。对于无SpringMVC环境的网关(如使用Gateway组件),我们可在YAML文件中配置`spring.cloud.gateway.globalcors`属性,以实现全局跨域支持。
79 0
|
5月前
|
JSON Java API
技术笔记:springboot项目使用拦截器实现一个简单的网关请求透传
技术笔记:springboot项目使用拦截器实现一个简单的网关请求透传
215 0
|
6月前
|
前端开发 Java 应用服务中间件
Springboot解决跨域问题方案总结(包括Nginx,Gateway网关等)
Springboot解决跨域问题方案总结(包括Nginx,Gateway网关等)
跨域问题以及经过网关二次转发重复跨域问题完美解决
跨域问题以及经过网关二次转发重复跨域问题完美解决
|
6月前
|
负载均衡 前端开发 Java
字节后端面试题(前端发送请求到后端的过程(MVC),网关gateway作用,怎么解决跨域,各微服务组件作用)
字节后端面试题(前端发送请求到后端的过程(MVC),网关gateway作用,怎么解决跨域,各微服务组件作用)
415 0
|
12月前
Gateway网关的cors跨域配置
Gateway网关的cors跨域配置
198 0
Gateway网关的cors跨域配置
|
前端开发
网关的cors跨域配置
网关的cors跨域配置
138 0