首页 > 技术文章 > 三级分类的写法

yuanchuziwen 2022-01-20 21:58 原文

三级分类的写法

这是一个非常常见的问题,之前写的时候,不太熟悉,往往是让前端的同学写死,或者是先全部渲染,然后再按照权限等选择性展示;

正确的方法主要有两种:

  1. 在 Dao中查出所有数据,然后放到 Service中进行组装
  2. 在 SQL语句中直接通过自己与自己的关联查出树形的分类结构

这里采用第一种

一、数据库的设计

如图,设置如下属性同三级分类相关:

  • parent_cid
    • 表示父分类的 ID,需要靠它来做父子关系的联系
    • 如果是顶层的话,一般就把它设置为 0
    • 所以顶层的 cat_id本身一般至少是从 1开始,反正不能是 0
  • cat_level
    • 表示当前是第几个层次
    • 方便快速确定层级
  • sort
    • 同层次进行排序

部分数据展示:

二、后端

2.1、实体类的设计

总体基本同数据库 DO,只是需要增加派生属性,用于存储子类

package com.zwb.gulimall.product.entity;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.List;

/**
 * 商品三级分类
 *
 * @author OliQ
 * @email yuanchuziwen@qq.com
 * @date 2022-01-16 13:49:02
 */
@Data
@TableName("pms_category")
public class CategoryEntity implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 分类id
     */
    @TableId
    private Long catId;

    /**
     * 分类名称
     */
    private String name;

    /**
     * 父分类id
     */
    private Long parentCid;

    /**
     * 层级
     */
    private Integer catLevel;

    /**
     * 是否显示[0-不显示,1显示]
     */
    private Integer showStatus;

    /**
     * 排序
     */
    private Integer sort;

    /**
     * 图标地址
     */
    private String icon;

    /**
     * 计量单位
     */
    private String productUnit;

    /**
     * 商品数量
     */
    private Integer productCount;

    /**
     * 派生属性,当前分类下的直接子分类
     */
    @TableField(exist = false)
    private List<CategoryEntity> children;
}

2.2、Controller层

基本与普通业务操作一致,一般将查询和数据操作放到 Service层

package com.zwb.gulimall.product.controller;

/**
 * 商品三级分类
 *
 * @author OliQ
 * @email yuanchuziwen@qq.com
 * @date 2022-01-16 13:49:02
 */
@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);
    }
    
    /**
     * 删除,必须接收 POST请求,SpringMVS自动将请求体的数据(json),转为对应的对象
     */
    @RequestMapping("/delete")
    // @RequiresPermissions("product:category:delete")
    public R delete(@RequestBody Long[] catIds) {

        // 1. 检查被删除的菜单是否在别的地方被引用
        // categoryService.removeByIds(Arrays.asList(catIds));
        categoryService.removeMenuByIds(Arrays.asList(catIds));

        return R.ok();
    }
    
    /**
     * 修改
     */
    @RequestMapping("/update")
    // @RequiresPermissions("product:category:update")
    public R update(@RequestBody CategoryEntity category) {
        categoryService.updateById(category);

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

        return R.ok().put("category", category);
    }

    /**
     * 保存
     */
    @RequestMapping("/save")
    // @RequiresPermissions("product:category:save")
    public R save(@RequestBody CategoryEntity category) {
        categoryService.save(category);

        return R.ok();
    }
    
    /**
     * 拖动后保存修改信息
     *
     * @param category
     * @return
     */
    @RequestMapping("/update/sort")
    // @RequiresPermissions("product:category:update")
    public R updateSort(@RequestBody CategoryEntity[] category) {

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

2.3、Service层

业务的重点

具体步骤:

  1. 先把所有单条数据都查询出来
  2. 找到每个顶层节点 Root
  3. 找到每个顶层节点 Root 的子节点 Children,并封装
    1. 封装的时候各个子节点 Children又是该层的 Root
    2. 同样为它找子节点 Children's Children
  4. 如此反复

整体上可以使用流式写法进行快速处理:

package com.zwb.gulimall.product.service.impl;

@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {

    @Autowired
    private CategoryDao categoryDao;

    @Override
    public List<CategoryEntity> listWithTree() {
        // 1. 查出所有分类,baseMapper就是 CategoryDao(因为 MBP)
        List<CategoryEntity> entities = baseMapper.selectList(null);

        // 2. 组装所有分类,成树形结构

        // 1) 先过滤,找到所有的一级分类
        List<CategoryEntity> level1Menus = entities.stream().filter((categoryEntity) -> {
            return categoryEntity.getParentCid() == 0;

            // 2) 针对每一个元素进行操作,找出所有的子节点
        }).map((menu) -> {
            menu.setChildren(getChildren(menu, entities));
            return menu;

            // 3) 排序(可选)
        }).sorted((menu1, menu2) -> {
            return menu1.getSort() - menu2.getSort();

            // 4) 将元素收集起来再整合成一个集合
        }).collect(Collectors.toList());

        return level1Menus;
    }

    /**
     * 根据当前分类节点 和 所有节点,查找子分类节点
     * 
     * @param root
     * @param all
     * @return
     */
    private List<CategoryEntity> getChildren(CategoryEntity root, List<CategoryEntity> all) {

        // 过滤找出所有子节点
        List<CategoryEntity> children = all.stream().filter((categoryEntity) -> {
            return categoryEntity.getParentCid().equals(root.getCatId());

            // 针对每个子节点,寻找并设置它的所有子节点(递归调用自己)
        }).map((menu) -> {
            menu.setChildren(getChildren(menu, all));
            return menu;

            // 排序
        }).sorted((menu1, menu2) -> {
            return menu1.getSort() - menu2.getSort();

            // 整合元素
        }).collect(Collectors.toList());


        return children;
    }
    
    @Transactional(rollbackFor = Exception.class)
    @Override
    public void removeMenuByIds(List<Long> asList) {
        // TODO 检查当前删除菜单是否被别的地方引用
        baseMapper.deleteBatchIds(asList);
    }

}

也可以使用 for循环遍历来代替流式 API

核心是:遍历+递归

  • 第一次遍历,是为了找出最顶层的节点

  • 递归,是为了找子节点,同时在找的时候自己又是顶层节点

三、前端

前端采用 Vue+ElementUI

3.1、页面组件

主要包括 树形组件,按钮,添加修改用的对话框

<template>
  <div>
    <el-switch
      v-model="draggable"
      active-text="开启拖拽"
      inactive-text="关闭拖拽"
    >
    </el-switch>
    <el-button v-if="draggable" @click="batchSave">批量保存</el-button>
    <el-button type="danger" @click="batchDel">批量删除</el-button>
    <!-- 树形结构 -->
    <el-tree
      show-checkbox
      :data="menus"
      :props="defaultProps"
      :expand-on-click-node="false"
      node-key="catId"
      :default-expanded-keys="expandedKeys"
      :draggable="draggable"
      :allow-drop="allowDrop"
      @node-drop="handleDrop"
      ref="menuTree"
    >
      <!-- 添加、删除 按钮 -->
      <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
          <el-button
            v-if="node.level <= 2"
            type="text"
            size="mini"
            @click="() => append(data)"
          >
            添加
          </el-button>
          <el-button
            v-if="node.childNodes.length == 0"
            type="text"
            size="mini"
            @click="() => remove(node, data)"
          >
            删除
          </el-button>

          <el-button
            v-if="true"
            type="text"
            size="mini"
            @click="() => edit(data)"
          >
            修改
          </el-button>
        </span>
      </span>
    </el-tree>

    <!-- 对话框 -->
    <el-dialog
      :title="formTitle"
      :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="cancelDia">取 消</el-button>
        <el-button
          v-if="category.dialogType == 'add'"
          type="primary"
          @click="addCategory"
          >确 定</el-button
        >
        <el-button
          v-if="category.dialogType == 'edit'"
          type="primary"
          @click="modifyCategory"
          >确 定</el-button
        >
      </span>
    </el-dialog>
  </div>
</template>

3.2、数据

主要包括 单个菜单的属性,级别,拖拽信息

data() {
    return {
      pCid: [],
      draggable: false,
      updateNodes: [],
      maxLevel: 0,
      formTitle: "表格头",
      category: {
        dialogType: "",
        name: "",
        parentCid: null,
        catLevel: null,
        showStatus: 1,
        sort: 0,
        productCount: null,
        catId: null,
        icon: null,
        productUnit: null,
      },
      dialogVisible: false,
      menus: [],
      defaultProps: {
        children: "children",
        label: "name",
      },
      expandedKeys: [],
    };
  },

3.3、JS代码

主要包括 数据获取,修改,删除,拖动

methods: {
    /**
     * 批量删除
     */
    batchDel() {
        let checkedMenus = this.$refs.menuTree.getCheckedNodes();
        let catIds = [];
        for (let i = 0; i < checkedMenus.length; i++) {
            catIds.push(checkedMenus[i].catId);
        }

        // 先进行弹窗确认
        this.$confirm(`是否删除 【${catIds}】 菜单?`, {
            confirmButtonText: "确定",
            cancelButtonText: "取消",
            type: "warning",
        })
            .then(() => {
            // 确认删除
            // 先发请求
            this.$http({
                url: this.$http.adornUrl("/product/category/delete"),
                method: "post",
                data: this.$http.adornData(catIds, false),
            }).then(({ data }) => {
                console.log("删除成功");

                // 刷新出新的菜单
                this.getMenus();
            });

            // 展示反馈的消息
            this.$message({
                type: "success",
                message: "菜单删除成功!",
            });
        })
            .catch(() => {
            // 取消删除
            // 展示反馈的消息
            this.$message({
                type: "info",
                message: "已取消删除",
            });
        });
    },

    /**
     * 拖动后批量保存
     */
        batchSave() {
            this.$http({
                url: this.$http.adornUrl("/product/category/update/sort"),
                method: "post",
                data: this.$http.adornData(this.updateNodes, false),
            }).then(({ data }) => {
                // 展示反馈的消息
                this.$message({
                    type: "success",
                    message: "拖拽信息记录成功!",
                });

                // 刷新菜单
                this.getMenus();

                // 展开
                this.expandedKeys = this.pCid;
            });

            this.updateNodes = [];
            this.maxLevel = 0;
            // this.pCid = [];
        },

            /**
             * 移动后保存数据
             */
            handleDrop(draggingNode, dropNode, dropType, ev) {
                // this.updateNodes = [];
                console.log("tree drop: ", dropNode.label, dropType);
                // 1. 当前节点最新的父节点 id,如果 type是 before和 after,那么 dropNode就是兄弟节点的 id
                let pCid = 0;
                let siblings = null;
                if (dropType == "before" || dropType == "after") {
                    pCid =
                        dropNode.parent.data.catId == undefined
                        ? 0
                    : dropNode.parent.data.catId;
                    siblings = dropNode.parent.childNodes;
                } else {
                    pCid = dropNode.data.catId;
                    siblings = dropNode.childNodes;
                }
                this.pCid.push(pCid);

                // 2. 当前拖拽节点的最新顺序
                for (let i = 0; i < siblings.length; i++) {
                    if (siblings[i].data.catId == draggingNode.data.catId) {
                        // 如果遍历的是当前正在拖拽的节点
                        let catLevel = draggingNode.level;
                        if (siblings[i].level != draggingNode.level) {
                            // 当前节点的层级发生变化
                            catLevel = siblings[i].level;

                            // 修改子节点的层级
                            this.updateChildNodeLevel(siblings[i]);
                        }

                        this.updateNodes.push({
                            catId: siblings[i].data.catId,
                            sort: i,
                            parentCid: pCid,
                        });
                    } else {
                        this.updateNodes.push({ catId: siblings[i].data.catId, sort: i });
                    }
                }

                // 3. 当前拖拽节点的最新层级
                console.log(this.updateNodes);
            },

                updateChildNodeLevel(node) {
                    if (node.length > 0) {
                        for (let i = 0; i < node.childNodes.length; i++) {
                            let cNode = node.childNodes[i].data;
                            this.updateNodes.push({
                                catId: cNode.catId,
                                catLevel: node.childNodes[i].level,
                            });
                            this.updateChildNodeLevel(node.childNodes[i]);
                        }
                    }
                },

                    /**
                     * 判断是否允许移动
                     */
                    allowDrop(draggingNode, dropNode, type) {
                        // 判断被拖动的当前节点以及所在的父节点总层数不能大于3
                        this.maxLevel = draggingNode.data.catLevel;
                        this.countNodeLevel(draggingNode.data);
                        let deep = Math.abs(this.maxLevel - draggingNode.level + 1);
                        // console.log(deep, dropNode.data.catLevel);

                        if (type == "inner") {
                            return deep + dropNode.data.catLevel <= 3;
                        }
                        return deep + dropNode.parent.data.catLevel <= 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]);
                                }
                            }
                            return 1;
                        },
                            /**
                             * 对话框取消后将数据设为默认值
                             */
                            cancelDia() {
                                this.dialogVisible = false;
                                this.category.name = "";
                                this.category.icon = null;
                                this.category.productUnit = null;
                                this.category.catId = null;
                                this.category.catLevel = null;
                                this.category.productCount = null;
                                this.category.parentCid = null;
                            },

                                /**
                                 * 修改节点的数据
                                 */
                                edit(data) {
                                    console.log(data);
                                    this.category.dialogType = "edit";
                                    this.formTitle = "修改";
                                    this.dialogVisible = true;

                                    this.$http({
                                        url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
                                        method: "get",
                                        params: this.$http.adornParams({}),
                                    }).then(({ data }) => {
                                        // 请求成功
                                        console.log(data);
                                        this.category.name = data.category.name;
                                        this.category.catId = data.category.catId;
                                        this.category.icon = data.category.icon;
                                        this.category.productUnit = data.category.productUnit;
                                        this.category.parentCid = data.category.parentCid;
                                    });
                                },

                                    /**
                                     * 发送修改请求
                                     */
                                    modifyCategory() {
                                        let { 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.expandedKeys = [this.category.parentCid];

                                            // 数据清空
                                            this.category.name = "";
                                            this.category.icon = null;
                                            this.category.productUnit = null;
                                            this.category.catId = null;
                                            this.category.catLevel = null;
                                            this.category.productCount = null;
                                            this.category.parentCid = null;
                                        })
                                            .catch(() => {
                                            // 展示反馈的消息
                                            this.$message({
                                                type: "info",
                                                message: "添加失败!",
                                            });

                                            // 数据清空
                                            this.category.name = "";
                                            this.category.icon = null;
                                            this.category.productUnit = null;
                                            this.category.catId = null;
                                            this.category.catLevel = null;
                                            this.category.productCount = null;
                                            this.category.parentCid = null;
                                        });
                                    },

                                        /**
                                         * 添加分类
                                         */
                                        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({
                                                    type: "success",
                                                    message: "菜单添加成功!",
                                                });

                                                // 关闭对话框
                                                this.dialogVisible = false;

                                                // 刷新结点
                                                this.getMenus();

                                                // 展开菜单
                                                this.expandedKeys = [this.category.parentCid];

                                                // 数据清空
                                                this.category.name = "";
                                                this.category.icon = null;
                                                this.category.productUnit = null;
                                                this.category.catId = null;
                                                this.category.catLevel = null;
                                                this.category.productCount = null;
                                                this.category.parentCid = null;
                                            })
                                                .catch(() => {
                                                // 展示反馈的消息
                                                this.$message({
                                                    type: "info",
                                                    message: "添加失败!",
                                                });

                                                // 数据清空
                                                this.category.name = "";
                                                this.category.icon = null;
                                                this.category.productUnit = null;
                                                this.category.catId = null;
                                                this.category.catLevel = null;
                                                this.category.productCount = null;
                                                this.category.parentCid = null;
                                            });
                                        },

                                            /**
                                             * 获取整个树形分类信息
                                             */
                                            getMenus() {
                                                this.$http({
                                                    url: this.$http.adornUrl("/product/category/list/tree"),
                                                    method: "get",

                                                    // 将响应中的 data数据进行解构
                                                }).then(({ data }) => {
                                                    console.log("成功获取到菜单数据");
                                                    console.log(data.data);
                                                    this.menus = data.data;
                                                });
                                            },

                                                /**
                                                 * 分类添加
                                                 */
                                                append(data) {
                                                    console.log("append", data);
                                                    this.formTitle = "添加";
                                                    this.dialogVisible = true;
                                                    this.category.parentCid = data.catId;
                                                    this.category.catLevel = data.catLevel + 1;
                                                    this.category.productCount = 0;
                                                    this.category.dialogType = "add";
                                                },

                                                    /**
                                                     * 分类移除
                                                     */
                                                    remove(node, data) {
                                                        let ids = [data.catId];
                                                        console.log("remove", data, node);

                                                        // 先进行弹窗确认
                                                        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 }) => {
                                                                console.log("删除成功");

                                                                // 刷新出新的菜单
                                                                this.getMenus();

                                                                // 设置需要默认展开的菜单
                                                                this.expandedKeys = [node.parent.data.catId];
                                                            });

                                                            // 展示反馈的消息
                                                            this.$message({
                                                                type: "success",
                                                                message: "菜单删除成功!",
                                                            });
                                                        })
                                                            .catch(() => {
                                                            // 取消删除
                                                            // 展示反馈的消息
                                                            this.$message({
                                                                type: "info",
                                                                message: "已取消删除",
                                                            });
                                                        });
                                                    },
},
    //生命周期 - 创建完成(可以访问当前 this 实例)
    created() {
        this.getMenus();
    },

四、总结

三级分类难点主要在于:

  1. 全部数据查询时,后端的组装
    • 一般在 Service内组装
    • 一般需要 遍历+递归
  2. 拖拽更新
    • 前端需要记录拖拽后所有需更新的节点数据
    • 后端增量更新

推荐阅读