三级分类的写法
三级分类的写法
这是一个非常常见的问题,之前写的时候,不太熟悉,往往是让前端的同学写死,或者是先全部渲染,然后再按照权限等选择性展示;
正确的方法主要有两种:
- 在 Dao中查出所有数据,然后放到 Service中进行组装
- 在 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层
业务的重点
具体步骤:
- 先把所有单条数据都查询出来
- 找到每个顶层节点
Root
- 找到每个顶层节点
Root
的子节点Children
,并封装- 封装的时候各个子节点
Children
又是该层的Root
- 同样为它找子节点
Children's Children
- 封装的时候各个子节点
- 如此反复
整体上可以使用流式写法进行快速处理:
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();
},
四、总结
三级分类难点主要在于:
- 全部数据查询时,后端的组装
- 一般在 Service内组装
- 一般需要 遍历+递归
- 拖拽更新
- 前端需要记录拖拽后所有需更新的节点数据
- 后端增量更新