谷粒商城之分布式基础(二)

本文详细介绍了谷粒商城商品服务的实现,包括三级分类的数据库设计、查询、网关路由与路径重写,前端树形结构展示,以及删除、新增、修改分类的逻辑。此外,还涉及品牌管理、属性分组、规格参数的管理,以及跨域问题的解决。文章通过代码实例展示了商品微服务如何与nacos、网关、前端交互,实现商品系统的各个功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

6 商品服务

6.1 三级分类

image-20221029090944745

商城的商品页面展示是一个三级分类的。有一级分类、二级分类、三级分类。这就是我们接下来要进行的操作。

6.1.1 数据库

6.1.2 查出所有分类及其子分类

1、CategoryController

gulimall-product中的controller包下的CategoryController

  • 在类中对原来逆向生成的代码进行修改,
@RestController
@RequestMapping("product/category")
public class CategoryController {
   
    @Autowired
    private CategoryService categoryService;
    /**
     * 查出所有分类以及子分类,以树形结构组装起来
     */
    @RequestMapping("/list/tree")
    public R list(){
   
        List<CategoryEntity> entities =  categoryService.listWithTree();

        return R.ok().put("data", entities);
    }
 }
2、CategoryService

接着我们使用idea自带的工具帮助我们生成相应的方法。

/**
 * 商品三级分类
 */
public interface CategoryService extends IService<CategoryEntity> {
   

    List<CategoryEntity> listWithTree();
}
3、CategoryServiceImpl
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
   

	// @Autowired
    // CategoryDao  categoryDao; //其实这里因为继承了ServiceImpl,且其泛型就是 CategoryDao,
    // 所以我们可以直接使用 ServiceImpl里面的 baseMapper来直接注入

	.......
        
    /**
     * 1、Lambda表达式
     * 1、举例:(o1, o2)->Integer.compare(o1, o2)
     *
     * 2、格式:
     *
     * -> :lambda操作符 或 箭头操作符
     * -> 左边: lambda形参列表(其实就是接口中的抽象方法的形参)
     * -> 右边: lambda体(其实就是重写的抽象方法的方法体)
     * 3、总结:
     *
     * -> 左边: lambda形参列表的参数类型可以省略(类型推断),如果形参列表只有一个参数,其一对()也可以省略
     *
     * -> 右边: lambda体应该使用一对{}包裹;如果lambda体只执行一条语句(可能是return语句),可以省略这一对{}和return关键字
     *右边
     */
    @Override
    public List<CategoryEntity> listWithTree() {
   

        //1.查出所有分类
        //没有查询条件,就是代表查询所有
        List<CategoryEntity> entities = baseMapper.selectList(null);

        //2.组装成父子的树形结构
        //2.1 找到所有的一级分类  (categoryEntity) -> {} lambda 表达式
        List<CategoryEntity> level1Menus = entities.stream()
                // .filter((categoryEntity) -> { return categoryEntity.getParentCid() == 0}) 下面的lambda表达式省略了return及{}及()
                .filter(categoryEntity -> categoryEntity.getParentCid() == 0)   //过滤出一级分类,因为其父类id是0
                .map((menu) -> {
      //在菜单收集成list之前先通过递归找到菜单的所有子分类,放在map中,然后排序,即将当前菜单改了之后重新返回, 然后再收集菜单。
                    //设置一级分类的子分类
                    menu.setChildren(getChildren(menu, entities));
                    return menu;
                }).sorted((menu1, menu2) -> {
   
                    //排序,menu1:之前的菜单     menu2:之后的菜单
                    return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());//子菜单肯定有有前一个和后一个之分
                })
                .collect(Collectors.toList());


        return level1Menus;
    }

    //递归查找所有菜单的子菜单
    // root 当前菜单   all 所有菜单
    private List<CategoryEntity> getChildren(CategoryEntity root, List<CategoryEntity> all) {
   

        List<CategoryEntity> children = all.stream().filter(categoryEntity -> {
   
            return categoryEntity.getParentCid() == root.getCatId();   //二级菜单的父分类id == 一级分类的catid
        }).map(categoryEntity -> {
   
            //1.找到子菜单
            //递归查找
            categoryEntity.setChildren(getChildren(categoryEntity, all));//二级菜单下还有三级菜单,继续查找
            return categoryEntity;

            //2.菜单的排序
        }).sorted((menu1, menu2) -> {
      //sorted() 定制排序
            return (menu1.getSort() == null ? 0 : menu1.getSort() - (menu2.getSort() == null ? 0 : menu2.getSort()));
        }).collect(Collectors.toList());

        return children;
    }

这里使用的是流式编程,对于这方面我们可去参考java8新特性的StreamAPI来进行相应的学习。

image-20221029112316511

在学习的过程中,看到老师使用TODO才知道IDEA有一个类似备忘录的功能。

4、启动测试

我们启动gulimall-product微服务进行测试查询。

  • 我们接着进行测试,浏览器发送http://localhost:10000/product/category/list/tree,测试结果如下图,显示正确。这里我们推荐浏览器装一个Json格式的处理的插件可以很好的帮助我们查看Json数据。

  • 查询所有


6.1.3 配置网关路由与路径重写

前后端联调:

启动后台:renren-fast微服务(idea);

启动前端:renren-fast-vue(vscode);

接着我们来到后台系统进行菜单模块的添加。

1、 后台添加目录和菜单

注意:避坑指南

如果系统登录不上,可能是 跨域配置默认不开启

1667742739180

登录成功之后,我们就可以开始进行后台系统的编辑和完善了。

  1. 在菜单管理中添加一个商品系统的目录。如下图。image-20221027213836441

  2. 在商品系统中新增一个分类维护的菜单。菜单的路由其实就是我们商品微服务中的访问路径。

    希望的效果:在左侧点击【分类维护】,希望在此展示3级分类
    注意地址栏http://localhost:8001/#/product-category 可以注意到product-category我们的/被替换为了-

image-20221027214924676

我们在后台系统中修改的,在数据库的gulimall-admin中也会同步进行修改。

image-20221027215105624

  • 我们可以看到如果我们点击角色管理的话,地址栏是/sys-role,但是我们实际发送的请求应该是/sys/role,

    sys-role 具体的视图在 renren-fast-vue/views/modules/sys/role.vue

    所以由此可以知道后台会将 /自动转换为 - ,同理我们去访问/product/category也会自动被转换为/product-category

    具体地址栏如下所示:

    1667742855965

    1667742879863

  • 我们在renren-fast-vue中可以看到有一个文件,对应的其实就是/sys-role对应的页面视图,,即sys文件夹下的role.vue对应的就是角色管理这个页面的展示。所以对于商品分类/product/category,我们接下来要做的就是在renren-fast-vue下创建一个product文件夹,文件夹中创建一个category.vue来进行页面展示。

1667743264194

1667743447915

2、编写树形结构
  1. 对于这一段前端开发的代码,我们可以借鉴element.eleme.cn中的快速开发指南进行编写。
<template>
    <el-tree :data="data" :props="defaultProps" @node-click="handleNodeClick"></el-tree>
</template>

<script>
export default {
    name: 'category',
    components: {},
    directives: {},
     data() {
      return {
        data: [],
        defaultProps: {
          children: 'children',
          label: 'label'
        }
      };
    },
    mounted() {
        
    },
    methods: {
        handleNodeClick(data) {
        console.log(data);
      },
      getMenus(){
        this.$http({
          url: this.$http.adornUrl('/product/category/list/tree'),
          method: 'get'
        }).then(data=>{
            console.log(data)
        })
      }
    },
    created(){
        this.getMenus();
    }
};
</script>

<style scoped>

</style>
  1. 进行测试

测试中发现检查网页源代码发现,本来应该是给商品微服务10000端口发送的查询的,但是发送到了renren-fast 8080端口去了。

1667745152504

image-20210927115040661

我们以后还会同时发向更多的端口,所以需要配置网关,前端只向网关发送请求,然后由网关自己路由到相应服务器端口。

renren-fast-vue中有一个 Index.js是管理 api 接口请求地址的,如下图。如果我们本次只是简单的将8080改为10000端口,那么当下次如果是10001呢?难道每次都要改吗?所以我们的下一步做法是使用网关进行路由。通过网关映射到具体的请求地址。

ps:此处也可以参考其他人的理解:

借鉴:他要给8080发请求读取数据,但是数据是在10000端口上,如果找到了这个请求改端口那改起来很麻烦。方法1是改vue项目里的全局配置,方法2是搭建个网关,让网关路由到10000。

1667745414358

ps: 上面这个图明显有错误,vscode 已经报错,这里我没有注意到,以致 后面处理 跨域问题的时候 白白浪费了我 9个半 小时的时间啊!!!!1

前端项目报错也会影响!!!

切记!!!!!!!!!!!!!!!!

在这里,对于微服务,后面我们统一改为加 api 前缀能路由过去。

  // api接口请求地址
  window.SITE_CONFIG['baseUrl'] = 'http://localhost:88/api'

接下来进行测试访问

image-20210927115040661

我们发现 验证码 一直加载不出来。检查网页源代码发现是因为我们直接给网关发送验证码请求了。但是真实的应该是给 renren-fast 发送请求。

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

image-20221027222409536

3、将renren-fast注册进 nacos ,使用网关进行统一管理

问题引入:他要去 nacos 中查找api服务,但是nacos里是fast服务,就通过把api改成fast服务,所以让fast注册到服务注册中心,这样请求88网关转发到8080fast。
让fast里加入注册中心的依赖,所以引入common

  • 引入gulimall-common

image-20221027222544585

  • 在renren-fast的 application.yml文件中配置nacos注册中心地址

    spring:
     application:
       name: renren-fast    //给 renren-fast  起一个名字,方便nacos服务注册发现
     cloud:
       nacos:
         discovery:
           server-addr: 127.0.0.1:8848   //注册进nacos
    
  • 在renren-fast的主启动类上加入@EnableDiscoveryClient注解,使得该微服务会被注册中心发现image-20221027222947987

  • 注册成功

1667826652781

4、启动测试
  1. 最开始进行启动,在renren-fast的CorsConfig跨域配置中,allowedOriginPatterns报错。出现原因是因为:我们使用的springboot版本是2.1.8.RELEASE。所以将这个.allowedOriginPatterns换成.allowedOrigins即可。

image-20221028084313631

image-20221028084331356

  1. 最开始报错,在b站看了评论和弹幕之后将gulimall-common这个依赖给取消了,因为启动报依赖循环报错。后面我将所有的依赖都换成老师的同样的版本之后就没有了。

    启动报错:

    java: Annotation processing is not supported for module cycles. Please ensure that all modules from cycle [gulimall-common,renren-fast] are excluded from annotation processing

    指的是 循环依赖的问题

    1667747244272>解决办法:不要引入公共依赖,直接引入 nacos的服务注册发现的依赖

    		<!--nacos作为注册中心,服务注册与发现-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
                <version>2.1.0.RELEASE</version>
            </dependency>
    		<!--<dependency>-->
            <!--    <groupId>com.atguigu.gulimall</groupId>-->
            <!--    <artifactId>gulimall-common</artifactId>-->
            <!--    <version>0.0.1-SNAPSHOT</version>-->
            <!--</dependency>-->
    

    启动成功

1667749050576

鉴于上面出现很多错误,但是老师视频中没有出现这些错误,大概率是因为依赖的原因,所以对于gulimall中所有的依赖进行统一,按照老师的依赖进行配置。以防止后面出现很多突发的错误。

  • 根据老师的依赖进行重新设置,然后重新运行网关。

启动报错:Caused by: org.yaml.snakeyaml.scanner.ScannerException: mapping values are not allowed here

这个地方报错的原因大概率是yml文件语法错误:注意这个坑找了好久,id uri predicates filters都要对齐,同一层级。

image-20221028160052335

完整代码示例如下:

# 在 yml  配置文件中配置,可以很方便的让我们在 项目上线后将配置直接转移到配置中心
spring:
  cloud:
    gateway:
      routes:
        - id: admin_route
          uri: lb://renren-fast    # 路由给renren-fast,lb代表负载均衡
          predicates:            # 什么情况下路由给它
            - Path=/api/**     # 把所有api开头的请求都转发给renren-fast:因为默认前端项目都带上api前缀,
          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/*

修改后运行成功,验证码出现。

5、浏览器跨域问题

上面我们验证码出现了,但是我们登录却报错,原因在于浏览器的跨域问题。

从 8001访问88,引发 CORS 跨域请求,浏览器会拒绝跨域请求

1667827145407

image-20221028141815905

跨域
问题描述:已拦截跨源请求:同源策略禁止读取位于 http://localhost:88/api/sys/login 的远程资源。(原因:CORS 头缺少 ‘Access-Control-Allow-Origin’)。

问题分析:这是一种跨域问题。访问的域名和端口和原来的请求不同,请求就会被限制

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

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

  1. 引入浏览器跨域知识

    image-20221029163137756

    跨域流程:

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

    1667827620189

    1667827655318

    1667827678853

    前面跨域的解决方案:

    方法1:设置nginx包含admin和gateway
    方法2:让服务器告诉预检请求能跨域

    1. 这里我们采用的解决办法:在gulimall-gateway中配置跨域配置列GulimallCorsConfiguration解决跨域问题------配置filter,每个请求来了以后,返回给浏览器之前都添加上那些字段

    我们在gulimall-gateway中创建一个config来存放GulimallCorsConfiguration。注意这个包一定是要在gateway这个包下,否则启动报错(坑)。

    @Configuration
    public class GulimallCorsConfiguration {
         
    
        @Bean   // 添加过滤器,当请求一过来走完 corsWebFilter 就给他们添加上跨域的相应配置
        public CorsWebFilter corsWebFilter(){
         
    
            // 基于url路径跨域,选择reactive包下的
            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            // 跨域配置信息
            CorsConfiguration corsConfiguration = new CorsConfiguration();
    
            // 允许跨域的头
            corsConfiguration.addAllowedHeader("*");
            // 允许跨域的请求方式
            corsConfiguration.addAllowedMethod("*");
            // 允许跨域的请求来源
            corsConfiguration.addAllowedOrigin("*");
            // 是否允许携带cookie跨域
            corsConfiguration.setAllowCredentials(true);
            // 任意url都要进行跨域配置
            //对接口进行配置,“/*”代表所有,“/**”代表适配的所有接口
            source.registerCorsConfiguration("/**",corsConfiguration);
            //CorsWebFilter的构造器需要传递一个
            //org.springframework.web.cors.reactive.CorsConfigurationSource的接口作为参数
            //接口不能实例化,所以选择CorsConfigurationSource的实现类
            //UrlBasedCorsConfigurationSource作为参数
            return new CorsWebFilter(source);
        }
    }
    
    1. 再次启动测试

    浏览器检查报错,报错的原因是:renren-fast 中也配置了跨域,但是我们只需要一个,所以要给注释掉。

    http://localhost:8001/renren已拦截跨源请求:同源策略禁止读取位于 http://localhost:88/api/sys/login 的远程资源。(原因:不允许有多个 ‘Access-Control-Allow-Origin’ CORS 头)n-fast/captcha.jpg?uuid=69c79f02-d15b-478a-8465-a07fd09001e6

    出现了多个请求,并且也存在多个跨源请求。

    为了解决这个问题,需要修改renren-fast项目,注释掉“io.renren.config.CorsConfig”类。然后再次进行访问。

    image-20221028163003311

    image-20221029165122758

    1. 跨域问题困扰了我 9个半小时的时间,最后发现 竟然是 renren-fast-vue 前端代码 格式问题,真是崩溃了。

      这里也给了我一个 提醒,有时候需要从多方面进行问题的查找!!!!

      前端 有时候也会报错,一定要注意。 其实只要依赖版本和老师的一样,有很多坑是可以避免的。

6.1.4 树形展示三级分类数据

1667829358668

image-20221029170058736

在显示商品系统/分类信息的时候,出现了404异常,请求的http://localhost:88/api/product/category/list/tree不存在
只有通过http://localhost:10000/product/category/list/tree路径才能够正常访问,所以会报404异常。这是路径映射错误。我们需要在网关中进行路径重写,让网关帮我们转到正确的地址。

1667830493930

1、 商品微服务注册进nacos

首先我们需要将 gulimall-product 服务 注册进 nacos,方便网关进行路由。

我们在nacos中新建一个 product 命名空间,以后关于 product商品微服务下的配置就放在该命名空间下,目前我们注册微服务的话,都默认放在 public 命名空间下就行,配置文件放在各自微服务的命名空间下即可。

1667831861085

首先这里我们先回顾一下 nacos的配置步骤:

  1. 微服务注册进nacos:
    • 首先 需要在 application.yml / application.properties 文件中配置nacos的服务注册地址,并且最好每一个微服务都有属于自己的一个 应用名字
  spring:
    cloud:
      nacos:
        discovery:
          server-addr: 127.0.0.1:8848
  1. 微服务 配置 进 nacos
    • 如果想要 用nacos作为配置中心 ,需要 新建 bootstrap.properties 文件,然后在里面配置nacos 配置中心的地址; 此外,我们规定每一个微服务都有属于自己的命名空间,以后隶属于该微服务下的配置文件都配置在 该命名空间中。
  spring.application.name=gulimall-product
  # 配置nacos 配置中心地址
  spring.cloud.nacos.config.server-addr=127.0.0.1:8848
  spring.cloud.nacos.config.namespace=832f36b7-7878-47b7-8968-408f7b98b1e6
  1. 在启动类 上 添加注解 @EnableDiscoveryClient : 为了发现服务注册和配置

1667832554205

注册和配置成功。

2、在网关配置文件中配置路由规则,进行路径重写

在 gulimall-gateway 下的 application.yml中进行配置

      - id: product_route
          uri: lb://gulimall_product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/(?<segment>/?.*), /$\{
   segment}
        #          http://localhost:88/api/product/category/list/tree  http://localhost:10000/product/category/list/tree

注意:

如果直接访问 localhost:88/api/product/category/list/tree invalid token这个url地址的话,会提示非法令牌,后台管理系统中没有登录,所以没有带令牌

1667833363607

原因:先匹配的先路由,renren-fast 和 product 路由重叠,fast 要求登录

修正:在路由规则的顺序上,将精确的路由规则放置到模糊的路由规则的前面,否则的话,精确的路由规则将不会被匹配到,类似于异常体系中try catch子句中异常的处理顺序。

http://localhost:88/api/product/category/list/tree 正常

访问http://localhost:8001/#/product-category,正常

原因是:先访问网关88,网关路径重写后访问nacos8848,nacos找到服务

1667834266000

1667834385389

1667834451527

成功访问。

3、前端代码修改

1667835501339

因为我们 对 整个对象 中的 data 数据感兴趣 ,所以我们 将 对象中的 data 解构出来。

我们使用{}将data的数据进行解构:data.data是我们需要的数组内容

image-20221028170907519

 //获取菜单集合
    methods: {
        handleNodeClick(data) {
            console.log(data);
        },
        //获取后台数据
        getMenus() {
            this.$http({
                url: this.$http.adornUrl('/product/category/list/tree'),
                method: 'get'
            }).then(({data}) => {  //将整个对象中的data数据结构出来,因为只有data才是我们需要的
                console.log("成功了获取到菜单数据....", data.data)
                this.menus = data.data; // 数组内容,把数据给menus,就是给了vue实例,最后绑定到视图上
            })
        }
    },

image-20221028171253462

1667877445043

此时有了3级结构,但是没有数据,在category.vue的模板中,数据是menus,而还有一个props。这是element-ui的规则
<template>
    <el-tree :data="menus" :props="defaultProps" @node-click="handleNodeClick"></el-tree>
</template>

export default {
    //import引入的组件需要注入到对象中才能使用
    components: {},
    data() {
        return {
            menus: [],  //真正的数据需要发送请求从数据库中进行查找
            defaultProps: {
                children: 'children', //子节点
                label: 'name'  //name属性作为标签的值,展示出来
            }
        };
    },

修改完毕后,测试:

1667835845586

6.1.5 删除数据----逻辑删除

1、前端代码

node 与 data
在element-ui的tree中,有2个非常重要的属性

node代表当前节点对象(是否展开等信息,element-ui自带属性)
data是节点数据,是自己的数据。
data从哪里来:前面ajax发送请求,拿到data,赋值给menus属性,而menus属性绑定到标签的data属性。而node是 ui 的默认规则

删除效果预想:

  • 在每一个菜单后面添加 append, delete
  • 点击按钮时,不进行菜单的打开合并:expand-on-click-node=“false”
  • 当没有子菜单或者没有引用(后台数据库判断是否有被引用,这里暂时不考虑)的时候,才可以显示delete按钮。当为一级、二级菜单时,才显示append按钮
    • 利用 v-if 进行判断是否显示 按钮:
      1. 如果 当前节点 node 的等级 ≤ 2,表示是一级菜单或二级菜单,不显示删除按钮------- v-if=“node.level <= 2”, level表示当前 是几级节点;
      2. 如果 当前节点 的子节点的 数组长度为0,表示 没有子菜单----v-if=“node.childNodes.length == 0”
  • 添加多选框 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>

效果展示:

1667837745363

2、逻辑删除
  1. 首先我们先测试一下 gulimall-product中的 CategoryController删除功能。

测试删除数据,打开postman(APIfox也可以)输入“ http://localhost:88/api/product/category/delete ”,请求方式设置为POST,请求体body选 json 数组

1667878782034

可以看到删除成功,而且数据库中也没有该数据了。

ps:这里将限制行数给取消勾选,不然默认是只显示 1000行。

1667878990545

这是一种 物理删除(不推荐),数据库中也同样被修改了。

接下来我们正式编写删除逻辑。

  1. 在真正的删除之前,我们要先检查该菜单是否被引用了。
  • 修改gulimall-product 中的CategoryController类
 @RequestMapping("/delete")
    public R delete(@RequestBody Long[] catIds){
   
        //删除之前需要判断待删除的菜单那是否被别的地方所引用。
//		categoryService.removeByIds(Arrays.asList(catIds));

        categoryService.removeMenuByIds(Arrays.asList(catIds));
        return R.ok();
    }
  • CategoryServiceImpl类
@Override
    public void removeMenuByIds(List<Long> asList) {
   
        //TODO 1.检查当前删除的菜单,是否被别的地方引用
        //其实开发中使用的都是逻辑删除,并不是真正物理意义上的删除
        baseMapper.deleteBatchIds(asList);
    }

这里我们还不清楚后面有哪些服务需要用到product,所以我们建一个备忘录,以后再来补充。

1667878556834

在学习的过程中,看到老师使用TODO才知道IDEA有一个类似备忘录的功能。

  1. 对于开发中,我们常常采用的是逻辑删除(我们并不希望删除数据,而是标记它被删除了,这就是逻辑删除),即在数据库表设计时设计一个表示逻辑删除状态的字段,在pms_category我们选择 show_status 字段,当它为0,表示被删除。

    逻辑删除是mybatis-plus 的内容,会在项目中配置一些内容,告诉此项目执行delete语句时并不删除,只是标志位。

    我们使用mybatis-plus中的逻辑删除语法:

1667880442914

1)、配置全局逻辑删除规则

application.yml中

mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  global-config:
    db-config:
      id-type: auto             #主键自增
      logic-delete-value: 1     #1表示删除
      logic-not-delete-value: 0   #0表示未删除

注意:这里有一个坑,数据库中我们最开始设置的是1:未删除,0:删除。这个坑马上解决。

/**
	 * 是否显示[0-不显示,1显示]
	 */
	@TableLogic(value = "1",delval = "0")//因为application.yml和数据库中的设置刚好相反,所以我们这里按数据库中的效果单独设置 
	private Integer showStatus;

配置之后,我们可以继续使用APIFox进行测试,实际测试成功。为了验证,我们也可以在application.yml设置一个全局打印日志,将sql语句打印出来。

1667879871854

logging:
  level:
    com.atguigu.gulimall: debug  #设置日志打印级别
3、测试删除

测试删除数据,打开postman或者是APIFox都可以(推荐使用APIFox)

输入“ http://localhost:88/api/product/category/delete ”,请求方式设置为POST,为了比对效果,可以在删除之前查询数据库的pms_category表:

delete请求传入的是数组,所以我们使用json数据。

1667880187492

1667880145971

删除1433,之后从 数据库中 show_status 1—>0,即逻辑删除正确。

1667880208982

控制台打印的SQL语句:

Preparing: UPDATE pms_category SET show_status=0 WHERE cat_id IN ( ? ) AND show_status=1
Parameters: 1433(Long)
Updates: 1

由此可见,逻辑删除成功,SQL语句为 更新字段。

4、前端代码编写

发送的请求:delete

发送的数据:this.$http.adornData(ids, false)

util/httpRequest.js中,封装了一些拦截器

http.adornParams是封装get请求的数据

http.adornData封装post请求的数据

ajax 的 get 请求第一次向服务器请求数据之后,后续的请求可能会被缓存,就不会请求服务器要新的数据了。

所以为了不缓存,我们在url后面拼接个 date时间戳 或者一个随机数,让他每次都请求服务器获取实时的数据了。

  • 编写前端 remove 方法,实现向后端发送请求
  • 点击delete弹出提示框,是否删除这个节点: elementui 中 MessageBox 弹框中的确认消息添加到删除之前
  • 删除成功后有消息提示: elementui 中 Message 消息提示
  • 原来展开状态的菜单栏,在删除之后也应该展开: el-tree组件的 default-expanded-keys 属性,默认展开。 每次删除之后,把删除菜单的父菜单的id值赋给默认展开值即可。

注意:

前端向后端发送post请求和get请求。对于这个我们可以设置一个自定义的代码块。文件->首选项->用户片段,以后我们就可以通过快捷键直接进行输出了。

"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请求"
    }

要求:删除之后,显示弹窗,而且展开的菜单仍然展开。

1667901001847

//在el-tree中设置默认展开属性,绑定给expandedKey
:default-expanded-keys="expandedKey"

//data中添加属性
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();
            //设置需要默认展开的菜单
            this.expandedKey = [node.parent.data.catId]
          });
        })
        .catch(() => {});
    },

6.1.6 新增分类

1、elementui中 Dialog 对话框

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

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

3、点击确定,添加到数据库: 新建方法addCategory发送post请求到后端; 因为要把数据添加到数据库,所以在前端数据中按照数据库的格式声明一个category。点击append时,计算category属性(比如 父id,以及当前层级等),点击确定时发送 post 请求(后台代码使用的是 @RequestBody 注解,需要发送 post请求)。

4、点击确定后,需要刷新菜单,显示出新的菜单;此外还需要展开菜单方便查看。

<!--对话框组件-->
	<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>

//data中新增数据
//按照数据库格式声明的数据
      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;
      });

6.1.7 修改分类

  1. gulimall-product中的 CategoryController
/**
     * 信息
     */
    @RequestMapping("/info/{catId}")
    //@RequiresPermissions("product:category:info")
    public R info(@PathVariable("catId") Long catId){
   
		CategoryEntity category = categoryService.getById(catId);

        return R.ok().put("data", category); //我们统一 为 data
    }

2.前端代码

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

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

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

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

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

5、复用对话框:

data数据中新增dialogType,用来标记此时对话框是由 edit打开的,还是由 append打开的。
新建方法 submitData,与对话框的确定按钮进行绑定,在方法中判断,如果 dialogTypeadd调用addCategory(),如果 dialogTypeedit调用editCategory()
data数据中新增 title,绑定对话框的title,用来做提示信息。判断dialogType的值,来选择提示信息。
6、防止多个人同时操作,对话框中的回显的信息应该是由数据库中读出来的:点击Edit按钮,发送httpget请求。(看好返回的数据)

7、编辑editCategory方法:

controller之中的更新是动态更新,根据id,发回去什么值修改什么值,所以把要修改的数据发回后端就好。
成功之后发送提示消息,展开刚才的菜单。
8、编辑之后,再点击添加,发现会回显刚才编辑的信息。所以在append方法中重置回显的信息。

9、这里给 对话框 添加一个 close-on-click-modal = false:这样我们点对话框之外的空白处就不会直接不显示对话框了。

1667923063606

<!--编辑按钮-->
		  <el-button type="text" size="mini" @click="() => edit(data)">
            Edit
          </el-button>

<!--可复用的对话框-->
	 <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>


//data, 新增了title、dialogType。 categroy中新增了inco、productUnit、catId
  data() {
        return {
            title: "",
            dialogType: "", //edit,add
            dialogVisible: false,
            menus: [],
            expandedKey: [],
            category: {
                name: "",
                parentCid: 0,
                catLevel: 0,
                showStatus: 1,
                sort: 0,
                icon: "",
                productUnit: "",
                catId: null,
            },
            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) {
            console.log("要修改的数据", data);
            this.dialogType = "edit";
            this.title = "修改分类";
            // 发送请求获取节点最新的数据
            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.dialogVisible = true;
            });
        },
        
       //修改三级分类数据
        //绑定对话框的确定按钮,向后台发送更新请求,传过去想要修改的字段
        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({
                        type: "success",
                        message: "菜单修改成功!",
                    });
                    // 关闭对话框
                    this.dialogVisible = false;
                    // 刷新出新的菜单
                    this.getMenus();
                    // 设置需要默认展开的菜单
                    this.expandedKey = [this.category.parentCid];
                })
                .catch(() => { });
        },
    
    //点击append按钮,清空编辑之后的回显数据
        append(data) {
            console.log("append----", data);
            this.dialogType = "add";
            this.title = "添加分类";
            this.category.parentCid = data.catId;
            this.category.catLevel = data.catLevel * 1 + 1;
            this.category.catId = null;
            this.category.name = null;
            this.category.icon = "";
            this.category.productUnit = "";
            this.category.sort = 0;
            this.category.showStatus = 1;
            this.dialogVisible = true;
        },

6.1.8 拖曳效果

1、前端代码

1、拖拽功能的前端实现:ementui树型控件->可拖拽节点

  • 在中加入属性 draggable表示节点可拖拽。
  • 在中加入属性 :allow-drop=“allowDrop”,拖拽时判定目标节点能否被放置。
  • allowDrop有三个参数: draggingNode表示拖拽的节点, dropNode表示拖拽到哪个节点,type表示拖拽的类型 ’prev’、‘inner’ 和 ‘next’,表示拖拽到目标节点之前、里面、之后。
  • allowDrop函数实现判断,拖拽后必须保持树形的三层结构。
    • 节点的深度 = 最深深度 - 当前深度 + 1
    • 当拖拽节点拖拽到目标节点的内部,要满足: 拖拽节点的深度 + 目标节点的深度 <= 3
    • 当拖拽节点拖拽的目标节点的两侧,要满足: 拖拽节点的深度 + 目标节点的父节点的深度 <= 3
<!--el-tree中添加属性-->
	   draggable
      :allow-drop="allowDrop"
// data中新增属性,用来记录当前节点的最大深度
maxLevel: 0,
    
    
//新增方法
    allowDrop(draggingNode, dropNode, type) {
            //1、被拖动的当前节点以及所在的父节点总层数不能>3

            //1)、被拖动的当前节点总层数
            console.log("allowDrop", draggingNode, dropNode, type);
            this.countNodeLevel(draggingNode.data);
            //当前正在拖动的节点+父节点所在的深度不大于3即可
            let deep = this.maxLevel - draggingNode.data.catLevel + 1;
            console.log("深度:", deep);

            //this.maxLevel
            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]);
                }
            }
        },
  1. 拖拽功能的数据收集
  • 在中加入属性@node-drop=“handleDrop”, 表示拖拽事件结束后触发事件handleDrop,handleDrop共四个参数:
    • draggingNode:被拖拽节点对应的 Node;
    • dropNode:结束拖拽时最后进入的节点;
    • dropType:被拖拽节点的放置位置(before、after、inner);
    • ev:event
  • 拖拽可能影响的节点的数据:parentCid、catLevel、sort
    • data中新增updateNodes ,把所有要修改的节点都传进来。
    • 要修改的数据:拖拽节点的parentCid、catLevel、sort
    • 要修改的数据:新的兄弟节点的sort (把新的节点收集起来,然后重新排序)
    • 要修改的数据:子节点的catLeve
//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});
          }
          
      }
    
      //3 当前拖拽节点的最新层级
     console.log("updateNodes", this.updateNodes);
    }

// 修改拖拽节点的子节点的层级
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]);
            }
        }
    },

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

CategoryController修改方法

@RestController
@RequestMapping("product/category")
public class CategoryController {
   
    @Autowired
    private CategoryService categoryService;


    /**
     * 批量修改分类
     */
    @RequestMapping("/update/sort")
    // @RequiresPermissions("product:category:update")
    public R update(@RequestBody CategoryEntity[] category){
   

        categoryService.updateBatchById(Arrays.asList(category));
        return R.ok();
    }

}

利用 APIfox 测试 批量修改效果

1668005619852

测试成功。接下来我们完善下 前端的代码。

前端发送请求:

//3 当前拖拽节点的最新层级
            console.log("updateNodes", this.updateNodes);
            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];
                //每次拖拽后把数据清空,否则要修改的节点将会越拖越多
                this.updateNodes = [],
                this.maxLevel = 0
            });

  1. 批量拖拽功能
  • 添加开关,控制拖拽功能是否开启
  • 每次拖拽都要和数据库交互,不合理。批量拖拽过后,一次性保存。
<!--添加拖拽开关和批量保存按钮-->
	<el-switch
      v-model="draggable"
      active-text="开启拖拽"
      inactive-text="关闭拖拽"
    >
    </el-switch>
    <el-button v-if="draggable" size="small" round @click="batchSave"
      >批量保存</el-button
    >
   
    <el-tree 
  :draggable="draggable"
   </el-tree>
//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 = [],
                this.maxLevel = 0;
                // this.pCid = 0;
            })
                .catch(() => { });
        },
  
        
    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;
            }
            this.pCid.push(pCid);

            //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 });
                }

            }
            //3 当前拖拽节点的最新层级
            console.log("updateNodes", this.updateNodes);
        },
  
                                               
// 修改拖拽判断逻辑
    allowDrop(draggingNode, dropNode, type) {
            //1 被拖动的当前节点以及所在的父节点总层数不能大于3

            //1 被拖动的当前节点总层数
            console.log("allowDrop:", draggingNode, dropNode, type);

            var level = this.countNodeLevel(draggingNode);

            // 当前正在拖动的节点+父节点所在的深度不大于3即可
            let deep = Math.abs(this.maxLevel - draggingNode.level) + 1;
            console.log("深度:", deep);

            // this.maxLevel
            if (type == "innner") {
                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]);
                }
            }
        },

6.1.9 批量删除

前端代码

  • 新增删除按钮
<el-button type="danger" size="small" @click="batchDelete" round>批量删除</el-button>

<!--eltree中新增属性,用作组件的唯一标示-->
ref="menuTree"

  • 批量删除方法
//批量删除
        batchDelete() {
            let catIds = [];
            let catNames = [];
            let checkedNodes = this.$refs.menuTree.getCheckedNodes();
            console.log("被选中的元素", checkedNodes);
            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(() => { });
                })
                .catch(() => { });
        },

6.1.10 前端代码(总)

<!--  -->
<template>
    <div>
        <el-switch v-model="draggable" active-text="开启拖拽" inactive-text="关闭拖拽">
        </el-switch>
        <el-button v-if="draggable" size="small" round @click="batchSave">批量保存</el-button>
        <el-button type="danger" size="small" @click="batchDelete" round>批量删除</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)">
                        Append
                    </el-button>
                    <el-button type="text" size="mini" @click="() => edit(data)">
                        Edit
                    </el-button>
                    <el-button v-if="node.childNodes.length == 0" type="text" size="mini"
                        @click="() => remove(node, data)">
                        Delete
                    </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: [], //批量保存过后需要展开的菜单id
            draggable: false, //绑定拖拽开关是否打开
            title: "",
            dialogType: "", //edit,add
            dialogVisible: false,
            menus: [],
            expandedKey: [],
            maxLevel: 0, // data中新增属性,用来记录初始化当前节点的最大深度
            updateNodes: [],//data 中新增数据,用来记录需要更新的节点(拖拽的节点(parentCid、catLevel、sort),拖拽后的兄弟节点(sort),拖拽节点的子节点(catLevel))
            category: {
                name: "",
                parentCid: 0,
                catLevel: 0,
                showStatus: 1,
                sort: 0,
                icon: "",
                productUnit: "",
                catId: null,
            },
            defaultProps: {
                children: "children",  //子节点
                label: "name",  //name属性作为标签的值,展示出来
            },
        };
    },
    //监听属性 类似于data概念
    computed: {},
    //监控data中的数据变化
    watch: {},
    //获取菜单集合
    methods: {
        handleNodeClick(data) {
            console.log(data);
        },
        //获取后台数据
        getMenus() {
            this.$http({
                url: this.$http.adornUrl('/product/category/list/tree'),
                method: 'get'
            }).then(({ data }) => {  //将整个对象中的data数据结构出来,因为只有data才是我们需要的
                console.log("成功了获取到菜单数据....", data.data)
                this.menus = data.data; // 数组内容,把数据给menus,就是给了vue实例,最后绑定到视图上
            })
        },
        //点击append按钮,清空编辑之后的回显数据
        append(data) {
            console.log("append----", data);
            this.dialogType = "add";
            this.title = "添加分类";
            this.category.parentCid = data.catId;
            this.category.catLevel = data.catLevel * 1 + 1;
            this.category.catId = null;
            this.category.name = null;
            this.category.icon = "";
            this.category.productUnit = "";
            this.category.sort = 0;
            this.category.showStatus = 1;
            this.dialogVisible = true;
        },
        //添加三级分类
        // 点击确定按钮后,因为后台是@RequestBody 注解,所以需要发送post请求
        //成功后显示添加成功,展开刚才的菜单
        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.getMenus();
                //设置需要默认展开的菜单
                this.expandedKey = [this.category.parentCid];
                //关闭对话框
                this.dialogVisible = false;
            })
                .catch(() => { });
        },
        //绑定对话框的确定按钮,根据dialogType判断调用哪个函数
        submitData() {
            if (this.dialogType == "add") {
                this.addCategory();
            }
            if (this.dialogType == "edit") {
                this.editCategory();
            }
        },
        //绑定Edit按钮,设置dialogType、title,从后台读取数据,展示到对话框内
        edit(data) {
            console.log("要修改的数据", data);
            this.dialogType = "edit";
            this.title = "修改分类";
            // 发送请求获取节点最新的数据
            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.dialogVisible = true;
            });
        },
        //修改三级分类数据
        //绑定对话框的确定按钮,向后台发送更新请求,传过去想要修改的字段
        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({
                        type: "success",
                        message: "菜单修改成功!",
                    });
                    // 关闭对话框
                    this.dialogVisible = false;
                    // 刷新出新的菜单
                    this.getMenus();
                    // 设置需要默认展开的菜单
                    this.expandedKey = [this.category.parentCid];
                })
                .catch(() => { });
        },
        allowDrop(draggingNode, dropNode, type) {
            //1 被拖动的当前节点以及所在的父节点总层数不能大于3

            //1 被拖动的当前节点总层数
            console.log("allowDrop:", draggingNode, dropNode, type);

            var level = this.countNodeLevel(draggingNode);

            // 当前正在拖动的节点+父节点所在的深度不大于3即可
            let deep = Math.abs(this.maxLevel - draggingNode.level) + 1;
            console.log("深度:", deep);

            // this.maxLevel
            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]);
                }
            }
        },
        //点击批量保存按钮,发送请求
        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;
            })
                .catch(() => { });
        },
        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;
            }
            this.pCid.push(pCid);

            //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 });
                }

            }
            //3 当前拖拽节点的最新层级
            console.log("updateNodes", this.updateNodes);
        },
        // 修改拖拽节点的子节点的层级
        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]);
                }
            }
        },
        //批量删除
        batchDelete() {
            let catIds = [];
            let catNames = [];
            let checkedNodes = this.$refs.menuTree.getCheckedNodes();
            console.log("被选中的元素", checkedNodes);
            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(() => { });
                })
                .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.$message({
                            message: "菜单删除成功",
                            type: "success",
                        });
                        //刷新出新的菜单
                        this.getMenus();
                        //设置需要默认展开的菜单
                        this.expandedKey = [node.parent.data.catId]
                    });
                })
                .catch(() => { });
        },
    },

    //生命周期 - 创建完成(可以访问当前this实例)
    created() {
        //创建完成时,就调用getMenus函数
        this.getMenus();
    },
    //生命周期 - 挂载完成(可以访问DOM元素)
    mounted() {

    },
    beforeCreate() { }, //生命周期 - 创建之前
    beforeMount() { }, //生命周期 - 挂载之前
    beforeUpdate() { }, //生命周期 - 更新之前
    updated() { }, //生命周期 - 更新之后
    beforeDestroy() { }, //生命周期 - 销毁之前
    destroyed() { }, //生命周期 - 销毁完成
    activated() { }, //如果页面有keep-alive缓存功能,这个函数会触发
}
</script>
<style scoped>

</style>

至此三级分类告一段落。


6.2 品牌管理

这次要用到的代码是通过renren-generator代码生成器中生成的前端代码。在前面中如果我们不小心进行删除了,可以通过idea自带的恢复功能进行恢复。

步骤:

  1. 右键点击resources->Local History->Show History

1668094833949

  1. 找到删除前端的记录
  2. 右键->Revert。 找回成功!

1668094852011

6.2.1 使用逆向工程前端代码

  1. 菜单管理—新增菜单

1668088448888

  1. 将gulimall-product中的前端代码复制到前端工程product下。

1668094904675

  1. 没有新增删除按钮: 修改权限,Ctrl+Shift+F查找isAuth,全部返回为true

image-20210927135728379

image-20210927135749448

  1. 查看效果

image-20210927135815283

这里提一嘴,我们可以将es6语法检查关闭。

1668094990323

6.2.2 效果优化-快速显示开关

  1. 在列表中添加自定义列:中间加标签。可以通过 Scoped slot 可以获取到 row, column, $index 和 store(table 内部的状态管理)的数据
  2. 修改开关状态,发送修改请求
  3. 数据库中showStatus是0和1,开关默认值是true/false。 所以在开关中设置:active-value=“1” 、:inactive-value="0"属性,与数据库同步

1668095136790

1668095227943

<!--brand.vue中显示状态那一列-->
      <el-table-column
        prop="showStatus"
        header-align="center"
        align="center"
        label="显示状态"
      >
        <template slot-scope="scope">
          <el-switch
            v-model="scope.row.showStatus"
            active-color="#13ce66"
            inactive-color="#ff4949"
            :active-value="1" 
            :inactive-value="0"
            @change="updateBrandStatus(scope.row)" 
          >
          </el-switch>
        </template>
      </el-table-column>

<!--brand-add-or-update.vue中显示状态那一列-->
      <el-form-item label="显示状态" prop="showStatus">
        <el-switch
          v-model="dataForm.showStatus"
          active-color="#13ce66"
          inactive-color="#ff4949"
        >
        </el-switch>
      </el-form-item>

1668095460218

效果如下:品牌logo地址显示在一栏了。

1668095492061

//brand.vue中新增方法,用来修改状态
updateBrandStatus(data) {
      console.log("最新信息",data);
      let { brandId, showStatus } = data;
      this.$http({
        url: this.$http.adornUrl("/product/brand/update"),
        method: 'post',
        data: this.$http.adornData({brandId,showStatus}, false)
      }).then(({ data }) => { 
        this.$message({
          type:"success",
          message:"状态更新成功"
        })
      });
    },

6.2.3 文件上传功能

  1. 知识补充

1668179369722

1668179389169

1668179431503

这里我们选用服务端签名后直传进行文件上传功能,好处是:

上传的账号信息存储在应用服务器
上传先找应用服务器要一个policy上传策略,生成防伪签名

  1. 开通阿里云OSS对象存储服务,并创建新的Bucket

1668179540018

https://help.aliyun.com/document_detail/32007.html sdk–java版本

1668179622440

  1. 如何使用

阿里云关于文件上传的帮助文档

根据官网的文档,我们可以直接在项目中引入依赖进行安装

这个依赖是最原始的。配置什么要写一大堆。

<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.15.0</version>
</dependency>

文件上传的具体配置,我们在 gulimall-product 的 test 包下的 GulimallProductApplicationTests类中进行测试,代码如下:

 @Test
    public void testUpload() throws FileNotFoundException {
   
        // Endpoint以杭州为例,其它Region请按实际情况填写。
        String endpoint = "oss-cn-hangzhou.aliyuncs.com";
        // 云账号AccessKey有所有API访问权限,建议遵循阿里云安全最佳实践,创建并使用RAM子账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建。
        String accessKeyId = "LTAI5tABh1pjUprZGrKi92w1";
        String accessKeySecret = "enVYmXd9p1sHvVub5gBf21E3tjuIFJ";

        // // 创建OSSClient实例。
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

        // 上传文件流。
        InputStream inputStream = new FileInputStream("F:\\JAVA listen\\尚硅谷Java学科全套教程(总207.77GB)\\谷粒商城\\课件\\课件和文档\\基础篇\\资料\\pics\\0d40c24b264aa511.jpg");
        ossClient.putObject("gulimall-wystart", "0d40c24b264aa511.jpg", inputStream);

        // 关闭OSSClient。
        ossClient.shutdown();
        System.out.println("上传成功");
    }
endpoint的取值:点击概览就可以看到你的endpoint信息,endpoint在
这里就是上海等地区,如 oss-cn-qingdao.aliyuncs.com
bucket域名:就是签名加上bucket,如gulimall-fermhan.oss-cn-qingdao.aliyuncs.com
accessKeyId和accessKeySecret需要创建一个RAM账号:

接下来就是具体如何获取的示例:

  • 获取EndpointAccessKey IDAccessKey Secret

    • Endpoint

      1668176643712

    • AccessKey IDAccessKey Secret

      ​ 注意,这里我们需要创建阿里云的子账户,这样可以避免我们主账号直接在网络上进行暴露。

1668179996224

1668180035023

1668180078690

对子账户分配权限,管理OSS对象存储服务。这里我们允许读和写,方便我们实现上传功能。

1668176844543

测试:1668180265994

1668180289200

可以看到上传到云服务成功。

  1. 直接使用SpringCloud Alibaba已经封装好的 oss

    1668181215108

1668181255946

  • 引入依赖(和老师版本一致)

    <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alicloud-oss</artifactId
gulimall_pms 商品 drop table if exists pms_attr; drop table if exists pms_attr_attrgroup_relation; drop table if exists pms_attr_group; drop table if exists pms_brand; drop table if exists pms_category; drop table if exists pms_category_brand_relation; drop table if exists pms_comment_replay; drop table if exists pms_product_attr_value; drop table if exists pms_sku_images; drop table if exists pms_sku_info; drop table if exists pms_sku_sale_attr_value; drop table if exists pms_spu_comment; drop table if exists pms_spu_images; drop table if exists pms_spu_info; drop table if exists pms_spu_info_desc; /*==============================================================*/ /* Table: pms_attr */ /*==============================================================*/ create table pms_attr ( attr_id bigint not null auto_increment comment '属性id', attr_name char(30) comment '属性名', search_type tinyint comment '是否需要检索[0-不需要,1-需要]', icon varchar(255) comment '属性图标', value_select char(255) comment '可选值列表[用逗号分隔]', attr_type tinyint comment '属性类型[0-销售属性,1-基本属性,2-既是销售属性又是基本属性]', enable bigint comment '启用状态[0 - 禁用,1 - 启用]', catelog_id bigint comment '所属分类', show_desc tinyint comment '快速展示【是否展示在介绍上;0-否 1-是】,在sku中仍然可以调整', primary key (attr_id) ); alter table pms_attr comment '商品属性'; /*==============================================================*/ /* Table: pms_attr_attrgroup_relation */ /*==============================================================*/ create table pms_attr_attrgroup_relation ( id bigint not null auto_increment comment 'id', attr_id bigint comment '属性id', attr_group_id bigint comment '属性分组id', attr_sort int comment '属性组内排序', primary key (id) ); alter table pms_attr_attrgroup_relation comment '属性&
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值