一、通过Java8 Stream API 获取商品三级分类数据

电商项目的分类有多级(一级、二级、三级)。
image.png
我们通过数据库来维护三级分类的数据(对应gulimall_pms库中的pms_category表)。
image.png
向表中插入数据,pms_catelog.sql

1.1 修改gulimall-product模块

目的是为了树形展示三级分类数据

1.1.0 修改CategoryEntity

  1. package com.atguigu.gulimall.gulimallproduct.entity;
  2. import com.baomidou.mybatisplus.annotation.TableField;
  3. import com.baomidou.mybatisplus.annotation.TableId;
  4. import com.baomidou.mybatisplus.annotation.TableName;
  5. import java.io.Serializable;
  6. import java.util.Date;
  7. import java.util.List;
  8. import lombok.Data;
  9. /**
  10. * 商品三级分类
  11. *
  12. * @author mrlinxi
  13. * @email mrzheme@vip.qq.com
  14. * @date 2021-12-07 19:17:01
  15. */
  16. @Data
  17. @TableName("pms_category")
  18. public class CategoryEntity implements Serializable {
  19. private static final long serialVersionUID = 1L;
  20. /**
  21. * 分类id
  22. */
  23. @TableId
  24. private Long catId;
  25. /**
  26. * 分类名称
  27. */
  28. private String name;
  29. /**
  30. * 父分类id
  31. */
  32. private Long parentCid;
  33. /**
  34. * 层级
  35. */
  36. private Integer catLevel;
  37. /**
  38. * 是否显示[0-不显示,1显示]
  39. */
  40. private Integer showStatus;
  41. /**
  42. * 排序
  43. */
  44. private Integer sort;
  45. /**
  46. * 图标地址
  47. */
  48. private String icon;
  49. /**
  50. * 计量单位
  51. */
  52. private String productUnit;
  53. /**
  54. * 商品数量
  55. */
  56. private Integer productCount;
  57. @TableField(exist = false) // 这个注解表示改属性不是表里面的
  58. private List<CategoryEntity> children;
  59. }

1.1.1 修改CategoryController

这里给CategoryController添加一个方法,功能是返回树形结构的所有分类

  1. /**
  2. * 查出所有分类以及子分类,以树形结构组装起来
  3. */
  4. @RequestMapping("/list/tree")
  5. //@RequiresPermissions("gulimallproduct:category:list")
  6. public R list(){
  7. // categoryService的list方法可以查到所有分类
  8. // 但是我们希望以树形结构获得所有分类
  9. // List<CategoryEntity> list = categoryService.list();
  10. // 我们需要一个listWithTree方法来生成分类的树形结构
  11. List<CategoryEntity> entities = categoryService.listWithTree();
  12. return R.ok().put("data", entities);
  13. }

1.1.2 CategoryService

  1. package com.atguigu.gulimall.gulimallproduct.service;
  2. import com.baomidou.mybatisplus.extension.service.IService;
  3. import com.atguigu.common.utils.PageUtils;
  4. import com.atguigu.gulimall.gulimallproduct.entity.CategoryEntity;
  5. import java.util.List;
  6. import java.util.Map;
  7. /**
  8. * 商品三级分类
  9. *
  10. * @author mrlinxi
  11. * @email mrzheme@vip.qq.com
  12. * @date 2021-12-07 19:17:01
  13. */
  14. public interface CategoryService extends IService<CategoryEntity> {
  15. PageUtils queryPage(Map<String, Object> params);
  16. List<CategoryEntity> listWithTree();
  17. }

1.1.3 CategoryServiceImpl

  1. package com.atguigu.gulimall.gulimallproduct.service.impl;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.stereotype.Service;
  4. import java.util.List;
  5. import java.util.Map;
  6. import java.util.stream.Collectors;
  7. import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  8. import com.baomidou.mybatisplus.core.metadata.IPage;
  9. import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
  10. import com.atguigu.common.utils.PageUtils;
  11. import com.atguigu.common.utils.Query;
  12. import com.atguigu.gulimall.gulimallproduct.dao.CategoryDao;
  13. import com.atguigu.gulimall.gulimallproduct.entity.CategoryEntity;
  14. import com.atguigu.gulimall.gulimallproduct.service.CategoryService;
  15. @Service("categoryService")
  16. public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
  17. // 因为CategoryServiceImpl继承了ServiceImpl
  18. // 所以这里可以直接使用ServiceImpl里面的baseMapper
  19. // baseMapper就是一个CategoryDao的实现,通过泛型实现
  20. @Autowired
  21. CategoryDao categoryDao;
  22. @Override
  23. public PageUtils queryPage(Map<String, Object> params) {
  24. IPage<CategoryEntity> page = this.page(
  25. new Query<CategoryEntity>().getPage(params),
  26. new QueryWrapper<CategoryEntity>()
  27. );
  28. return new PageUtils(page);
  29. }
  30. @Override
  31. public List<CategoryEntity> listWithTree() {
  32. // 1、查出所有分类
  33. // List<CategoryEntity> entities = baseMapper.selectList(null);
  34. List<CategoryEntity> entities = categoryDao.selectList(null);
  35. // 2、组装成树形父子结构
  36. // 2.1 找到所有的一级分类
  37. // 这里用到了java8的新特性Stream API 以及lambda表达式
  38. // 首先通过对象的.stream()方法获取Stream的实例
  39. // 然后通过filter过滤器选取流中的元素
  40. // map接收一个函数作为参数,该函数会被应用到每个元素上,并将其映射成一个新的元素
  41. List<CategoryEntity> level1Menu = entities.stream().filter(categoryEntity -> {
  42. return categoryEntity.getParentCid() == 0; // 过滤分类级别,保留1级分类
  43. }).map(menu -> {
  44. menu.setChildren(getChildren(menu, entities)); // 找到当前分类的子分类
  45. return menu;
  46. }).sorted((menu1, menu2) -> { // 按sort属性排序
  47. return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());
  48. }).collect(Collectors.toList()); // 最后将处理完的流转为一个list的形式
  49. return level1Menu;
  50. }
  51. /**
  52. * 递归查找所有菜单的子菜单
  53. */
  54. private List<CategoryEntity> getChildren(CategoryEntity root, List<CategoryEntity> all) {
  55. List<CategoryEntity> children = all.stream().filter(categoryEntity -> {
  56. // 找到all里面父菜单是root的元素
  57. return categoryEntity.getParentCid().equals(root.getCatId());
  58. }).map(categoryEntity -> {
  59. // 找到all里面父菜单是root的元素的子菜单
  60. categoryEntity.setChildren(getChildren(categoryEntity, all));
  61. return categoryEntity;
  62. }).sorted((menu1, menu2) -> {
  63. // 按sort属性排序
  64. return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());
  65. }).collect(Collectors.toList());
  66. return children;
  67. }
  68. }

这里对stream进行排序的时候不能直接使用自然排序,因为我们需要按照sort属性来排序,所以需要自定一个Comparator。
访问http://localhost:10000/gulimallproduct/category/list/tree 测试一下
image.png

二、前端路由规范/跨域之三级分类

我们先启动后台的renren-fast模块,再启动前端项目renrne-fast-vue
启动renren-fast的时候可能报错:
You aren’t using a compiler supported by lombok, so lombok will not work and has been disabled.
解决方法

2.1 商品系统新增侧边导航目录

image.png
添加的这个目录信息会记录在gulimall_admin数据库的sys_menu表中。

2.2 商品系统新增分类维护菜单

image.png
同样,分类维护菜单信息也会记录在sys_menu表中。我们在分类维护的菜单中维护商品的三级分类。

2.3 Vue脚手架路由规范

当点击侧边栏目录新增的分类维护时,会跳转到 /product-category,对应新增菜单设置的路由 product/categor。菜单路由中填写的’/‘会变成’-‘
image.png
页面:对应前端项目中 src/views/modules/product/category.vue 页面组件(但是目前没有);我们可以看看角色管理(sys-role)页面,其对应的是前端项目中的src\views\modules\sys\role.vue页面组件
所以,我们在src\views\modules下新建一个product文件夹,在里面创建category.vue页面组件,显示三级目录。
通过使用element-ui的Tree 树形控件来实现三级菜单的展示树形控件

三、配置网关和跨域配置

本节会解决一个前端向多个后端服务请求的问题(API网关),此外还有网关服务与前端前后分离的跨域问题。

3.1 前端工程配置API网关作为唯一接口

修改模板的内容,自定义一个getMenu()函数,在创建完成时调用这个函数。
image.png可以发现报404错误,这是因为请求的URL是写死的,我们根据不同的需求,动态的发送不同的请求。我们在vs中搜索http://localhost:8080/renren-fast
image.png,在static\config\indes.js文件中定义了api接口请求地址。为了动态的发送请求,我们将这里改成后端网关的url地址(端口号88)。
image.png
改了之后我们刷新页面发现需要重新登录,而且验证码都没有了,因为前端项目将验证码的url发送给了网关,但网关找不到验证码的后端服务(renren-fast)
image.png
因此,我们需要将renren-fast注册到注册中心。

3.2 将renren-fast接入网关服务配置

上面renren-fast-vue配置了请求为网关地址,那么原来应该发送到renren-fast的请求发送到了网关,所以这里需要请求通过网关转发到renren-fast。

3.2.1 renren-fast注册进nacos

在renren-fast中添加gulimall-common的依赖(common中包含了nacos作为注册/配置中心的依赖)

  1. <dependency>
  2. <groupId>com.atguigu.gulimall</groupId>
  3. <artifactId>gulimall-common</artifactId>
  4. <version>0.0.1-SNAPSHOT</version>
  5. </dependency>

然后修改renrne-fast的application.yml,设置注册到nacos,然后在主启动类上加上@EnableDiscoveryClient注解,重启。
image.png
重启报错:(视频里面是报错com.google.gson不存在,但是我的是报错methodnot exist,可能是项目版本问题)网上搜了下,手动添加guava依赖,并将版本换成31.0-jre(30之前都存在漏洞)重启成功!
image.png

  1. <dependency>
  2. <groupId>com.google.guava</groupId>
  3. <artifactId>guava</artifactId>
  4. <version>30.0-jre</version>
  5. </dependency>

3.2.2 网关增加路由断言转发到不同服务

在gulimall-gateway中添加路由规则,这里我们约定从前端过来的请求在url上均有/api前缀

  1. server:
  2. port: 88
  3. spring:
  4. application:
  5. name: gulimall-gateway
  6. cloud:
  7. nacos:
  8. discovery:
  9. server-addr: localhost:8848 #nacos注册中心的地址
  10. gateway:
  11. routes:
  12. #Query A 参数有A就行,Query B,C 参数B的值为C即可 C可以是正则表达式
  13. #实现针对于“http://localhost:88/hello?url=baidu”,转发到“https://www.baidu.com/hello”,
  14. #针对于“http://localhost:88/hello?url=qq”的请求,转发到“https://www.qq.com/hello”
  15. - id: baidu_router
  16. uri: https://www.baidu.com # 要转发的地址
  17. predicates:
  18. - Query=url,baidu
  19. - id: qq_router
  20. uri: https://www.qq.com
  21. predicates:
  22. - Query=url,qq
  23. - id: admin_router
  24. uri: lb://renren-fast # lb://服务名 进行负载均衡转发
  25. predicates: # 我们约定,前端项目来的请求均带有api的前缀
  26. - Path=/api/**
  27. filters:
  28. - RewritePath=/api/(?<segment>.*), /renren-fast/$\{segment} #路径重写,替换/api前缀,加上/renren-fast

image.png
但是在网关配置转发后还是报404,这是因为网关转发的时候会转发成http://localhost:8080/api/captcha.jpg,而我们需要的转发url是http://localhost:8080/renren-fast/captcha.jpg。因此需要用路由中的路径重写`RewritePath`。[springcloudgateway路径重写文档](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-rewritepath-gatewayfilter-factory)
现在可以显示验证码了,但是登陆还是报错:
image.png
image.png
被CORS策略(Access-Control-Allow-Origin,访问控制允许来源)阻塞,这是由跨域引起的错误。

3.3 网关服务配置跨域

那么什么是跨域呢?跨域指的是浏览器不能执行其他网站的脚本。他是由浏览器的同源策略造成的,是浏览器对javascript施加的安全限制。
同源策略:是指协议、域名、端口都要相同,其中有一个不同都会产生跨域
image.png
解决方式:

  1. 使用nginx部署为同一域

image.png

  1. 配置当次允许跨域请求

添加响应头

  • 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:表明该响应的有效时间为多少秒。在有效时间内,浏览器无 须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果 该首部字段的值超过了最大有效时间,将不会生效。

因为每个服务都可能产生跨域,不可能每个服务都进行修改,而我们是通过网关代理给其他服务,所以直接在网关中统一配置跨域即可。

3.3.1 gulimall-gateway配置跨域

我们新建config包,然后新建一个跨域配置类

  1. package com.atguigu.gulimall.gulimallgateway.config;
  2. import org.springframework.context.annotation.Bean;
  3. import org.springframework.context.annotation.Configuration;
  4. import org.springframework.web.cors.CorsConfiguration;
  5. import org.springframework.web.cors.reactive.CorsWebFilter;
  6. import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
  7. /**
  8. * @author mrlinxi
  9. * @create 2022-03-03 14:13
  10. */
  11. @Configuration
  12. public class GulimallCorsConfiguration {
  13. @Bean
  14. public CorsWebFilter corsWebFilter() {
  15. // 这个是CorsConfigurationSource的一个实现类
  16. // reactive包下的,因为我们使用的响应式编程
  17. UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  18. // 新建一个Cors的配置对象,在这个配置对象中指定跨域配置
  19. CorsConfiguration corsConfiguration = new CorsConfiguration();
  20. //1、配置跨域
  21. corsConfiguration.addAllowedHeader("*"); //允许哪些头进行跨域
  22. corsConfiguration.addAllowedMethod("*"); // 允许所有请求方式进行跨域
  23. corsConfiguration.addAllowedOrigin("*"); // 允许任意请求来源 进行跨域
  24. corsConfiguration.setAllowCredentials(true); // 允许携带cookie进行跨域
  25. // /**表示所有路径,我们对所有路径都用corsConfiguration这个跨域配置
  26. source.registerCorsConfiguration("/**", corsConfiguration);
  27. return new CorsWebFilter(source);
  28. }
  29. }

如果springboot版本高于2.4的话 corsConfiguration.addAllowedOrigin(““);要替换成corsConfiguration.addAllowedOriginPattern(““);

image.pngOption域前请求成功,但是真实的登陆请求存在报错:

Access to XMLHttpRequest at ‘http://localhost:88/api/sys/login’ from origin ‘http://localhost:8001‘ has been blocked by CORS policy: The ‘Access-Control-Allow-Origin’ header contains multiple values ‘http://localhost:8001, http://localhost:8001‘, but only one is allowed.

这是因为renren-fast服务中自己配置了跨域,与gateway重复了,这里会导致请求头添加重复,导致跨域失败。所以需要将其注释掉。删除 src/main/java/io/renren/config/CorsConfig.java 中的配置内容。

3.3.2 测试

重启renren-fast后,可以看到成功登陆。
image.pngimage.png

3.4 配置网关转发到gulimall-product

我们前面成功登陆了前端页面,在浏览三级目录的时候可以发现报错:
image.png
这是因为我们将前端的请求都转到了renrne-fast,而这里需要转到gulimall-product微服务。

注意:转发到renrne-fast的断言是/api/,转发到gulimall-product的断言是/api/gulimallproduct/,在设置路由规则的时候,较精确的断言要放在模糊的前面,网关是根据配置的顺序依次判断,如果模糊断言在精确断言之前,那么精确断言会失效。

  1. server:
  2. port: 88
  3. spring:
  4. application:
  5. name: gulimall-gateway
  6. cloud:
  7. nacos:
  8. discovery:
  9. server-addr: localhost:8848 #nacos注册中心的地址
  10. gateway:
  11. routes:
  12. #Query A 参数有A就行,Query B,C 参数B的值为C即可 C可以是正则表达式
  13. #实现针对于“http://localhost:88/hello?url=baidu”,转发到“https://www.baidu.com/hello”,
  14. #针对于“http://localhost:88/hello?url=qq”的请求,转发到“https://www.qq.com/hello”
  15. - id: baidu_router
  16. uri: https://www.baidu.com # 要转发的地址
  17. predicates:
  18. - Query=url,baidu
  19. - id: qq_router
  20. uri: https://www.qq.com
  21. predicates:
  22. - Query=url,qq
  23. # 注意product_route 跟 admin_router的顺序,网关在进行断言时,会根据断言的先后顺序进行操作
  24. # 所以精确断言需要写在模糊断言之前
  25. - id: product_route
  26. uri: lb://gulimall-product # lb://服务名 进行负载均衡转发
  27. predicates: # 对包含有/api/gulimallproduct的url请求进行路径重写
  28. - Path=/api/gulimallproduct/**
  29. filters:
  30. - RewritePath=/api/(?<segment>.*), /$\{segment}
  31. - id: admin_router
  32. uri: lb://renren-fast # lb://服务名 进行负载均衡转发
  33. predicates: # 我们约定,前端项目来的请求均带有api的前缀
  34. - Path=/api/**
  35. filters:
  36. - RewritePath=/api/(?<segment>.*), /renren-fast/$\{segment}
  37. # http://localhost:88/api/captcha.jpg?uuid=xxxx http://localhost:8080/renren-fast/captcha.jpg?uuid=xxxx

在nacos新建gulimall-product的命名空间,用于存储其配置文件(视频到这里并没有把配置放在nacos中);视频里面将gulimall-product注册到了nacos,这个我在最开始就弄了,所以就不过多介绍了。

四、三级分类——增删改

4.1 删除

我们需要实现三级菜单分类的删除功能,即前端点击删除,发送请求到guliamll-product模块,后端模块进行删除操作。
逆向工程生成的代码可以完成删除,但是在删除前,我们需要判断当前删除的菜单是否在其他地方被引用,所以我们需要修改逆向工程生成的代码。同时我们在删除记录时,并不是使用物理删除(删除数据库中的记录),而是使用逻辑删除(不删除原始记录,只是不显示,数据库表中show_status属性)。这里需要使用到mybatis-plus的逻辑删除。

4.1.1 配置逻辑删除

修改guliamll-product,配置mybatis-plus的逻辑删除:mybatis-plus逻辑删除文档

  1. 在application.yml文件中加入逻辑删除的配置
  2. 实体字段上加@TableLogic注解(如果第一步配置了logic-delete-field(3.3.0版本生效),则该步骤可以省略)

注:@TableLogic注解,可以设定image.png,value表示逻辑不删除时的值,delval表示逻辑删除时候的值,不指定会从配置文件获取,指定会优先使用指定的值。

application.yml

  1. # 配置数据源
  2. spring:
  3. datasource:
  4. username: root
  5. password: 10086
  6. url: jdbc:mysql://192.168.190.135:3306/gulimall_pms?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
  7. driver-class-name: com.mysql.cj.jdbc.Driver
  8. application:
  9. name: gulimall-product # 注册到nacos中的服务名
  10. cloud:
  11. nacos:
  12. discovery:
  13. server-addr: localhost:8848 #nacos注册中心的地址
  14. # 配置mybatis-plus
  15. mybatis-plus:
  16. mapper-locations: classpath:/mapper/**/*.xml # 配置sql映射目录
  17. global-config:
  18. db-config:
  19. id-type: auto # 配置主键自增
  20. logic-delete-field: showStatus # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
  21. # 我们数据库里面未删除是1,已删除是0,所以需要换一下,在@TableLogic指定也可以
  22. logic-delete-value: 0 # 逻辑已删除值(默认为 1)
  23. logic-not-delete-value: 1 # 逻辑未删除值(默认为 0)
  24. server:
  25. port: 10000

CategoryEntity

  1. @TableLogic(value = "1", delval = "0")
  2. private Integer showStatus;

4.1.2 修改删除逻辑

修改gulimall-product的CategoryController
在delete方法中,新定义一个removeMenuByIds,改方法在删除前会检查是否存在引用。

  1. /**
  2. * 删除
  3. * @RequestBody: 获取请求体,只有post请求才有请求体
  4. * SpringMVC自动将请求体的数据(json),转为对应的对象
  5. */
  6. @RequestMapping("/delete")
  7. //@RequiresPermissions("gulimallproduct:category:delete")
  8. public R delete(@RequestBody Long[] catIds){
  9. ///categoryService.removeByIds(Arrays.asList(catIds));
  10. categoryService.removeMenuByIds(Arrays.asList(catIds));
  11. return R.ok();
  12. }

在这里添加方法后CategoryService跟CategoryServiceImpl都需要进行相应的修改,CategoryService直接生成一个方法即可。
CategoryServiceImpl实现removeMenuByIds方法

  1. @Override
  2. public void removeMenuByIds(List<Long> asList) {
  3. //TODO 1、检查当前删除的菜单是否被别的地方引用
  4. // 我们一般不使用直接删除的方式,而是使用逻辑删除,即不是删除数据库中的记录,而是不显示,showStatus
  5. baseMapper.deleteBatchIds(asList);
  6. }

这里我们暂时还没有完成引用检查,所以用了个TODO,使用TODO注释后,在Idea下面的TODO栏可以看到代办事项:
image.png

4.1.3 批量删除(视频顺序是在4.3节之后)

这里也都是前端代码的修改,后台五分钟,前端两小时。

4.2 添加&修改

我们想达到的目的是,点击Append/Edit按钮,弹出一个对话框,在对话框中输入我们要添加/修改的分类目录信息,随后即可添加至目录。(需要用到el的dialog)添加/修改主要是前端的修改,后端代码不需要进行修改。
image.pngimage.png
前端代码:

  1. <template>
  2. <div>
  3. <!-- 使用el的树形控件 -->
  4. <el-tree
  5. :data="menus"
  6. :props="defaultProps"
  7. :expand-on-click-node="false"
  8. show-checkbox
  9. node-key="catId"
  10. :default-expanded-keys="expandedKey"
  11. >
  12. <span class="custom-tree-node" slot-scope="{ node, data }">
  13. <span>{{ node.label }}</span>
  14. <span>
  15. <!-- 添加按钮 只有1、2级菜单能添加子菜单-->
  16. <el-button
  17. v-if="node.level <= 2"
  18. type="text"
  19. size="mini"
  20. @click="() => append(data)"
  21. >
  22. Append
  23. </el-button>
  24. <!-- 修改按钮 -->
  25. <el-button type="text" size="mini" @click="() => edit(data)">
  26. Edit
  27. </el-button>
  28. <!-- 删除按钮 没有子菜单的菜单才能进行删除-->
  29. <el-button
  30. v-if="node.childNodes.length == 0"
  31. type="text"
  32. size="mini"
  33. @click="() => remove(node, data)"
  34. >
  35. Delete
  36. </el-button>
  37. </span>
  38. </span>
  39. </el-tree>
  40. <el-dialog
  41. v-bind:title="title"
  42. :visible.sync="dialogVisible"
  43. width="30%"
  44. :close-on-click-modal="false"
  45. >
  46. <el-form :model="category">
  47. <el-form-item label="分类名称">
  48. <el-input v-model="category.name" autocomplete="off"></el-input>
  49. </el-form-item>
  50. </el-form>
  51. <el-form :model="category">
  52. <el-form-item label="图标">
  53. <el-input v-model="category.icon" autocomplete="off"></el-input>
  54. </el-form-item>
  55. </el-form>
  56. <el-form :model="category">
  57. <el-form-item label="计量单位">
  58. <el-input
  59. v-model="category.prodcutUnit"
  60. autocomplete="off"
  61. ></el-input>
  62. </el-form-item>
  63. </el-form>
  64. <span slot="footer" class="dialog-footer">
  65. <el-button @click="dialogVisible = false">取 消</el-button>
  66. <el-button type="primary" @click="submitData">确 定</el-button>
  67. </span>
  68. </el-dialog>
  69. </div>
  70. </template>
  71. <script>
  72. //这里可以导入其他文件(比如:组件,工具 js,第三方插件 js,json文件,图片文件等等)
  73. //例如:import 《组件名称》 from '《组件路径》';
  74. export default {
  75. //import 引入的组件需要注入到对象中才能使用
  76. components: {},
  77. props: {},
  78. data() {
  79. return {
  80. title: "", //用于显示修改/添加
  81. dialogType: "", //用于区分是修改对话框还是添加对话框 edit,add
  82. category: {
  83. name: "",
  84. parentCid: 0,
  85. catLevel: 0,
  86. showStatus: 1,
  87. sort: 0,
  88. productUnit: "",
  89. icon: "",
  90. catId: null,
  91. },
  92. dialogVisible: false,
  93. menus: [],
  94. expandedKey: [],
  95. defaultProps: {
  96. children: "children",
  97. label: "name",
  98. },
  99. };
  100. },
  101. methods: {
  102. // 获取三级菜单
  103. getMenus() {
  104. this.$http({
  105. // url表示我们的请求地址
  106. url: this.$http.adornUrl("/gulimallproduct/category/list/tree"),
  107. method: "get",
  108. }).then(({ data }) => {
  109. console.log("成功获取到菜单数据。。。", data.data);
  110. this.menus = data.data;
  111. });
  112. },
  113. // 获取要修改的菜单信息
  114. edit(data) {
  115. console.log("要修改的数据是:", data);
  116. this.title = "修改分类";
  117. this.dialogType = "edit";
  118. this.dialogVisible = true;
  119. // 为了防止回显的数据不是最新的(类似于脏读),发送请求获取当前节点最新的数据
  120. this.$http({
  121. url: this.$http.adornUrl(
  122. `/gulimallproduct/category/info/${data.catId}`
  123. ),
  124. method: "get",
  125. }).then(({ data }) => {
  126. //请求成功
  127. console.log("需要回显的数据", data);
  128. // this.category.name = data.category.name;
  129. // this.category.catId = data.category.catId;
  130. // this.category.icon = data.category.icon;
  131. // this.category.productUnit = data.category.productUnit;
  132. // this.category.parentCid = data.category.parentCid;
  133. // this.category.catLevel = data.category.catLevel;
  134. // this.category.sort = data.category.sort;
  135. // this.category.showStatus = data.category.showStatus;
  136. this.category = data.category; // 可以直接用这种方式,自动解构
  137. });
  138. },
  139. // 获取要添加的菜单信息
  140. append(data) {
  141. //this.category = {};
  142. console.log("append", data);
  143. this.title = "添加分类";
  144. this.dialogType = "add";
  145. this.dialogVisible = true;
  146. this.category.parentCid = data.catId;
  147. this.category.catLevel = data.catLevel * 1 + 1;
  148. this.category.name = "";
  149. this.category.catId = null;
  150. this.category.icon = "";
  151. this.category.productUnit = "";
  152. this.category.sort = 0;
  153. this.category.showStatus = 1;
  154. },
  155. // submitData,这个方法根据dialogTyep值调用editCategory跟addCategory方法
  156. submitData() {
  157. if (this.dialogType == "add") {
  158. this.addCategory();
  159. }
  160. if (this.dialogType == "edit") {
  161. this.editCategory();
  162. }
  163. },
  164. // 修改三级分类
  165. editCategory() {
  166. var { catId, name, icon, productUnit } = this.category; // 通过结构表达式获取当前要修改的属性
  167. // var data = {catId: catId, name: name, icon: icon, productUnit: productUnit};
  168. var data = { catId, name, icon, productUnit }; // 属性名跟变量名相同可以省略
  169. console.log("提交的三级分类数据", this.category);
  170. this.$http({
  171. url: this.$http.adornUrl("/gulimallproduct/category/update"),
  172. method: "post",
  173. // 这里data其实可以写成this.category,这样的话后台会将全部字段更新,但是我们只需要修改部分字段。
  174. // 所以还是推荐改哪几个字段,传哪几个字段
  175. data: this.$http.adornData(data, false),
  176. }).then(({ data }) => {
  177. this.$message({
  178. showClose: true,
  179. message: "菜单修改成功",
  180. type: "success",
  181. });
  182. // 关闭对话框
  183. this.dialogVisible = false;
  184. // 刷新出新的菜单
  185. this.getMenus();
  186. // 设置需要默认展开的菜单
  187. this.expandedKey = [this.category.parentCid];
  188. });
  189. },
  190. // 添加三级分类
  191. addCategory() {
  192. console.log("提交的三级分类数据", this.category);
  193. this.$http({
  194. url: this.$http.adornUrl("/gulimallproduct/category/save"),
  195. method: "post",
  196. data: this.$http.adornData(this.category, false),
  197. }).then(({ data }) => {
  198. this.$message({
  199. showClose: true,
  200. message: "菜单添加成功",
  201. type: "success",
  202. });
  203. // 关闭对话框
  204. this.dialogVisible = false;
  205. // 刷新出新的菜单
  206. this.getMenus();
  207. // 设置需要默认展开的菜单
  208. this.expandedKey = [this.category.parentCid];
  209. });
  210. },
  211. // 移除三级分类
  212. remove(node, data) {
  213. var ids = [data.catId];
  214. this.$confirm(`是否删除【${data.name}】菜单?`, "提示", {
  215. confirmButtonText: "确定",
  216. cancelButtonText: "取消",
  217. type: "warning",
  218. })
  219. .then(() => {
  220. this.$http({
  221. url: this.$http.adornUrl("/gulimallproduct/category/delete"),
  222. method: "post",
  223. data: this.$http.adornData(ids, false),
  224. }).then(({ data }) => {
  225. this.$message({
  226. showClose: true,
  227. message: "菜单删除成功",
  228. type: "success",
  229. });
  230. // 刷新出新的菜单
  231. this.getMenus();
  232. // 设置需要默认展开的菜单
  233. this.expandedKey = [node.parent.data.catId];
  234. });
  235. })
  236. .catch(() => {});
  237. console.log("remove", node, data);
  238. },
  239. },
  240. //计算属性 类似于 data 概念
  241. computed: {},
  242. //监控 data 中的数据变化
  243. watch: {},
  244. //方法集合
  245. // methods: {},
  246. //生命周期 - 创建完成(可以访问当前 this 实例)
  247. created() {
  248. this.getMenus();
  249. },
  250. //生命周期 - 挂载完成(可以访问 DOM 元素)
  251. mounted() {},
  252. beforeCreate() {}, //生命周期 - 创建之前
  253. beforeMount() {}, //生命周期 - 挂载之前
  254. beforeUpdate() {}, //生命周期 - 更新之前
  255. updated() {}, //生命周期 - 更新之后
  256. beforeDestroy() {}, //生命周期 - 销毁之前
  257. destroyed() {}, //生命周期 - 销毁完成
  258. activated() {}, //如果页面有 keep-alive 缓存功能,这个函数会触发
  259. };
  260. </script>
  261. <style lang='scss' scoped>
  262. //@import url(); 引入公共 css 类
  263. </style>

4.3 拖拽商品目录

在拖拽商品时,涉及到若干个商品目录的更新,我们将需要修改的目录信息,以数组的方式发送给后端商品服务。
在gulimall-product的CategoryController中添加一个批量修改的方法updateSort

  1. /**
  2. * 批量修改,前端拖拽菜单后更新菜单信息使用此方法
  3. * @param category
  4. * @return
  5. */
  6. @RequestMapping("/update/sort")
  7. //@RequiresPermissions("gulimallproduct:category:update")
  8. public R updateSort(@RequestBody CategoryEntity[] category){
  9. // 直接通过逆向工程生成的批量修改方法修改
  10. categoryService.updateBatchById(Arrays.asList(category));
  11. return R.ok();
  12. }

前端代码

  1. <template>
  2. <div>
  3. <el-switch
  4. v-model="draggable"
  5. active-text="开启拖拽"
  6. inactive-text="关闭拖拽"
  7. >
  8. </el-switch>
  9. <el-button v-if="draggable" @click="batchSave">批量保存</el-button>
  10. <el-button type="danger" @click="batchDelete">批量删除</el-button>
  11. <!-- 使用el的树形控件 显示三级目录 -->
  12. <el-tree
  13. :data="menus"
  14. :props="defaultProps"
  15. :expand-on-click-node="false"
  16. show-checkbox
  17. node-key="catId"
  18. :default-expanded-keys="expandedKey"
  19. :draggable="draggable"
  20. :allow-drop="allowDrop"
  21. @node-drop="handleDrop"
  22. ref="menuTree"
  23. >
  24. <span class="custom-tree-node" slot-scope="{ node, data }">
  25. <span>{{ node.label }}</span>
  26. <span>
  27. <!-- 添加按钮 只有1、2级菜单能添加子菜单-->
  28. <el-button
  29. v-if="node.level <= 2"
  30. type="text"
  31. size="mini"
  32. @click="() => append(data)"
  33. >
  34. Append
  35. </el-button>
  36. <!-- 修改按钮 -->
  37. <el-button type="text" size="mini" @click="() => edit(data)">
  38. Edit
  39. </el-button>
  40. <!-- 删除按钮 没有子菜单的菜单才能进行删除-->
  41. <el-button
  42. v-if="node.childNodes.length == 0"
  43. type="text"
  44. size="mini"
  45. @click="() => remove(node, data)"
  46. >
  47. Delete
  48. </el-button>
  49. </span>
  50. </span>
  51. </el-tree>
  52. <el-dialog
  53. v-bind:title="title"
  54. :visible.sync="dialogVisible"
  55. width="30%"
  56. :close-on-click-modal="false"
  57. >
  58. <el-form :model="category">
  59. <el-form-item label="分类名称">
  60. <el-input v-model="category.name" autocomplete="off"></el-input>
  61. </el-form-item>
  62. </el-form>
  63. <el-form :model="category">
  64. <el-form-item label="图标">
  65. <el-input v-model="category.icon" autocomplete="off"></el-input>
  66. </el-form-item>
  67. </el-form>
  68. <el-form :model="category">
  69. <el-form-item label="计量单位">
  70. <el-input
  71. v-model="category.prodcutUnit"
  72. autocomplete="off"
  73. ></el-input>
  74. </el-form-item>
  75. </el-form>
  76. <span slot="footer" class="dialog-footer">
  77. <el-button @click="dialogVisible = false">取 消</el-button>
  78. <el-button type="primary" @click="submitData">确 定</el-button>
  79. </span>
  80. </el-dialog>
  81. </div>
  82. </template>
  83. <script>
  84. //这里可以导入其他文件(比如:组件,工具 js,第三方插件 js,json文件,图片文件等等)
  85. //例如:import 《组件名称》 from '《组件路径》';
  86. export default {
  87. //import 引入的组件需要注入到对象中才能使用
  88. components: {},
  89. props: {},
  90. data() {
  91. return {
  92. pCid: [],
  93. draggable: false,
  94. updateNodes: [],
  95. maxLevel: 0, //统计当前节点的最大层级
  96. title: "", //用于显示修改/添加
  97. dialogType: "", //用于区分是修改对话框还是添加对话框 edit,add
  98. category: {
  99. name: "",
  100. parentCid: 0,
  101. catLevel: 0,
  102. showStatus: 1,
  103. sort: 0,
  104. productUnit: "",
  105. icon: "",
  106. catId: null,
  107. },
  108. dialogVisible: false,
  109. menus: [],
  110. expandedKey: [],
  111. defaultProps: {
  112. children: "children",
  113. label: "name",
  114. },
  115. };
  116. },
  117. methods: {
  118. // 获取三级菜单
  119. getMenus() {
  120. this.$http({
  121. // url表示我们的请求地址
  122. url: this.$http.adornUrl("/gulimallproduct/category/list/tree"),
  123. method: "get",
  124. }).then(({ data }) => {
  125. console.log("成功获取到菜单数据。。。", data.data);
  126. this.menus = data.data;
  127. });
  128. },
  129. // 批量删除,选定菜点全面的框后批量删除
  130. batchDelete() {
  131. let delCatIds = [];
  132. let checkNodes = this.$refs.menuTree.getCheckedNodes();
  133. console.log("被选中需要删除的元素:", checkNodes);
  134. for (let i = 0; i < checkNodes.length; i++) {
  135. delCatIds.push(checkNodes[i].catId);
  136. }
  137. this.$confirm(`是否批量删除【${delCatIds}】菜单?`, "提示", {
  138. confirmButtonText: "确定",
  139. cancelButtonText: "取消",
  140. type: "warning",
  141. })
  142. .then(() => {
  143. this.$http({
  144. url: this.$http.adornUrl("/gulimallproduct/category/delete"),
  145. method: "post",
  146. data: this.$http.adornData(delCatIds, false),
  147. }).then(({ data }) => {
  148. this.$message({
  149. showClose: true,
  150. message: "菜单批量删除成功",
  151. type: "success",
  152. });
  153. // 刷新出新的菜单
  154. this.getMenus();
  155. });
  156. })
  157. .catch(() => {});
  158. },
  159. // 等拖拽全部完成以后,点击批量保存按钮调用保存方法
  160. // 以免每拖拽一次,数据库更新一次
  161. batchSave() {
  162. this.$http({
  163. url: this.$http.adornUrl("/gulimallproduct/category/update/sort"),
  164. method: "post",
  165. data: this.$http.adornData(this.updateNodes, false),
  166. }).then(({ data }) => {
  167. this.$message({
  168. showClose: true,
  169. message: "菜单顺序等修改成功",
  170. type: "success",
  171. });
  172. // 刷新菜单
  173. this.getMenus();
  174. // 展开拖拽后的菜单
  175. this.expandedKey = this.pCid;
  176. this.updateNodes = [];
  177. this.maxLevel = 0;
  178. // this.pCid = 0;
  179. });
  180. },
  181. // 拖拽成功后触发该函数
  182. handleDrop(draggingNode, dropNode, dropType) {
  183. console.log("handle drop: ", draggingNode, dropNode, dropType);
  184. //1、当前节点最新的父节点id
  185. let pCid = 0; // 拖拽完后,当前节点父节点
  186. let siblings = null; // 拖拽完后,当前节点兄弟
  187. // 当以after和before方式拖拽,其父节点就是dropNode的父节点
  188. // 其兄弟节点就是dropNode的父节点的childNodes
  189. if (dropType == "before" || dropType == "after") {
  190. pCid =
  191. dropNode.parent.data.catId == undefined
  192. ? 0
  193. : dropNode.parent.data.catId; // 当前节点的父节点
  194. siblings = dropNode.parent.childNodes;
  195. } else {
  196. // 这是为inner的情况
  197. // 当以inner方式拖拽,其父节点就是dropNode
  198. // 其兄弟节点就是dropNode的childNodes
  199. pCid = dropNode.data.catId;
  200. siblings = dropNode.childNodes;
  201. }
  202. this.pCid.push(pCid);
  203. //2、当前拖拽节点的最新顺序(求兄弟节点)
  204. //3、当前拖拽节点的最新层级
  205. for (let i = 0; i < siblings.length; i++) {
  206. // 如果遍历到当前正在拖拽的节点,则需要修改其父节点ID
  207. if (siblings[i].data.catId == draggingNode.data.catId) {
  208. let catLevel = draggingNode.level;
  209. // 如果拖拽节点层级发生变化,那么就需要更新拖拽节点与其子节点的层级
  210. if (siblings[i].level != draggingNode.level) {
  211. // 修改当前节点层级
  212. catLevel = siblings[i].level;
  213. // 修改拖拽节点子节点的层级
  214. this.updateChildNodeLevel(siblings[i]);
  215. }
  216. this.updateNodes.push({
  217. catId: siblings[i].data.catId,
  218. sort: i,
  219. parentCid: pCid,
  220. catLevel,
  221. });
  222. } else {
  223. this.updateNodes.push({ catId: siblings[i].data.catId, sort: i });
  224. }
  225. }
  226. console.log("updatenodes:", this.updateNodes);
  227. },
  228. // 更新子节点的层级信息
  229. updateChildNodeLevel(node) {
  230. if (node.childNodes.length != 0) {
  231. for (let i = 0; i < node.childNodes.length; i++) {
  232. var cNode = node.childNodes[i].data;
  233. this.updateNodes.push({
  234. catId: cNode.catId,
  235. catLevel: node.childNodes[i].level,
  236. });
  237. this.updateChildNodeLevel(node.childNodes[i]);
  238. }
  239. }
  240. },
  241. // 判断菜单栏是否能够拖拽 因为我们只有三级菜单
  242. allowDrop(draggingNode, dropNode, type) {
  243. //1、被拖动的当前节点以及所在的父节点总层数不能大于3
  244. // 1)判断被拖动的当前节点总层数
  245. console.log("allowDrop:", draggingNode, dropNode, type);
  246. this.countNodeLevel(draggingNode);
  247. // 当前正在拖动节点拥有的深度+父节点所在的深度不大于3即可
  248. let deep = Math.abs(this.maxLevel - draggingNode.level + 1);
  249. console.log("深度", deep);
  250. if (type == "inner") {
  251. // console.log(
  252. // `this.maxLevel:${this.maxLevel};draggingNode.data.catLevel:${draggingNode.data.catLevel};dropNode.level:${dropNode.level}`
  253. // );
  254. return deep + dropNode.level <= 3;
  255. } else {
  256. return deep + dropNode.parent.level <= 3;
  257. }
  258. // return false;
  259. },
  260. countNodeLevel(node) {
  261. // 找到所有子节点,求出最大深度
  262. if (node.childNodes != null && node.childNodes.length > 0) {
  263. for (let i = 0; i < node.childNodes.length; i++) {
  264. if (node.childNodes[i].level > this.maxLevel) {
  265. this.maxLevel = node.childNodes[i].level;
  266. }
  267. this.countNodeLevel(node.childNodes[i]);
  268. }
  269. }
  270. },
  271. // 获取要修改的菜单信息
  272. edit(data) {
  273. console.log("要修改的数据是:", data);
  274. this.title = "修改分类";
  275. this.dialogType = "edit";
  276. this.dialogVisible = true;
  277. // 为了防止回显的数据不是最新的(类似于脏读),发送请求获取当前节点最新的数据
  278. this.$http({
  279. url: this.$http.adornUrl(
  280. `/gulimallproduct/category/info/${data.catId}`
  281. ),
  282. method: "get",
  283. }).then(({ data }) => {
  284. //请求成功
  285. console.log("需要回显的数据", data);
  286. // this.category.name = data.category.name;
  287. // this.category.catId = data.category.catId;
  288. // this.category.icon = data.category.icon;
  289. // this.category.productUnit = data.category.productUnit;
  290. // this.category.parentCid = data.category.parentCid;
  291. // this.category.catLevel = data.category.catLevel;
  292. // this.category.sort = data.category.sort;
  293. // this.category.showStatus = data.category.showStatus;
  294. this.category = data.category; // 可以直接用这种方式,自动解构
  295. });
  296. },
  297. // 获取要添加的菜单信息
  298. append(data) {
  299. //this.category = {};
  300. console.log("append", data);
  301. this.title = "添加分类";
  302. this.dialogType = "add";
  303. this.dialogVisible = true;
  304. this.category.parentCid = data.catId;
  305. this.category.catLevel = data.catLevel * 1 + 1;
  306. this.category.name = "";
  307. this.category.catId = null;
  308. this.category.icon = "";
  309. this.category.productUnit = "";
  310. this.category.sort = 0;
  311. this.category.showStatus = 1;
  312. },
  313. // submitData,这个方法根据dialogTyep值调用editCategory跟addCategory方法
  314. submitData() {
  315. if (this.dialogType == "add") {
  316. this.addCategory();
  317. }
  318. if (this.dialogType == "edit") {
  319. this.editCategory();
  320. }
  321. },
  322. // 修改三级分类
  323. editCategory() {
  324. var { catId, name, icon, productUnit } = this.category; // 通过结构表达式获取当前要修改的属性
  325. // var data = {catId: catId, name: name, icon: icon, productUnit: productUnit};
  326. var data = { catId, name, icon, productUnit }; // 属性名跟变量名相同可以省略 :变量名
  327. console.log("提交的三级分类数据", this.category);
  328. this.$http({
  329. url: this.$http.adornUrl("/gulimallproduct/category/update"),
  330. method: "post",
  331. // 这里data其实可以写成this.category,这样的话后台会将全部字段更新,但是我们只需要修改部分字段。
  332. // 所以还是推荐改哪几个字段,传哪几个字段
  333. data: this.$http.adornData(data, false),
  334. }).then(({ data }) => {
  335. this.$message({
  336. showClose: true,
  337. message: "菜单修改成功",
  338. type: "success",
  339. });
  340. // 关闭对话框
  341. this.dialogVisible = false;
  342. // 刷新出新的菜单
  343. this.getMenus();
  344. // 设置需要默认展开的菜单
  345. this.expandedKey = [this.category.parentCid];
  346. });
  347. },
  348. // 添加三级分类
  349. addCategory() {
  350. console.log("提交的三级分类数据", this.category);
  351. this.$http({
  352. url: this.$http.adornUrl("/gulimallproduct/category/save"),
  353. method: "post",
  354. data: this.$http.adornData(this.category, false),
  355. }).then(({ data }) => {
  356. this.$message({
  357. showClose: true,
  358. message: "菜单添加成功",
  359. type: "success",
  360. });
  361. // 关闭对话框
  362. this.dialogVisible = false;
  363. // 刷新出新的菜单
  364. this.getMenus();
  365. // 设置需要默认展开的菜单
  366. this.expandedKey = [this.category.parentCid];
  367. });
  368. },
  369. // 移除三级分类
  370. remove(node, data) {
  371. var ids = [data.catId];
  372. this.$confirm(`是否删除【${data.name}】菜单?`, "提示", {
  373. confirmButtonText: "确定",
  374. cancelButtonText: "取消",
  375. type: "warning",
  376. })
  377. .then(() => {
  378. this.$http({
  379. url: this.$http.adornUrl("/gulimallproduct/category/delete"),
  380. method: "post",
  381. data: this.$http.adornData(ids, false),
  382. }).then(({ data }) => {
  383. this.$message({
  384. showClose: true,
  385. message: "菜单删除成功",
  386. type: "success",
  387. });
  388. // 刷新出新的菜单
  389. this.getMenus();
  390. // 设置需要默认展开的菜单
  391. this.expandedKey = [node.parent.data.catId];
  392. });
  393. })
  394. .catch(() => {});
  395. console.log("remove", node, data);
  396. },
  397. },
  398. //计算属性 类似于 data 概念
  399. computed: {},
  400. //监控 data 中的数据变化
  401. watch: {},
  402. //方法集合
  403. // methods: {},
  404. //生命周期 - 创建完成(可以访问当前 this 实例)
  405. created() {
  406. this.getMenus();
  407. },
  408. //生命周期 - 挂载完成(可以访问 DOM 元素)
  409. mounted() {},
  410. beforeCreate() {}, //生命周期 - 创建之前
  411. beforeMount() {}, //生命周期 - 挂载之前
  412. beforeUpdate() {}, //生命周期 - 更新之前
  413. updated() {}, //生命周期 - 更新之后
  414. beforeDestroy() {}, //生命周期 - 销毁之前
  415. destroyed() {}, //生命周期 - 销毁完成
  416. activated() {}, //如果页面有 keep-alive 缓存功能,这个函数会触发
  417. };
  418. </script>
  419. <style lang='scss' scoped>
  420. //@import url(); 引入公共 css 类
  421. </style>