一、基本概念(非常重要的电商概念)
1、SPU和SKU的初步理解:
SPU仿佛是Java中的类,SKU像是Java中具体的对象
-
SPU = Standard Product Unit (标准产品单位)
SPU是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以称为一个SPU。
——例如:iphone4就是一个SPU,与商家,与颜色、款式、套餐都无关。
-
SKU=stock keeping unit(库存量单位)
SKU即库存进出计量的单位, 可以是以件、盒、托盘等为单位。SKU是物理上不可分割的最小存货单元。在使用时要根据不同业态,不同管理模式来处理。在服装、鞋类商品中使用最多最普遍。
SKU是对于大型连锁超市的DC(配送中心)物流管理的一个必要的方法,现在已经被引申为产品统一编号的简称,每一种具体产品均对应有唯一的SKU号,这样就不会弄错!
——例如:纺织品中一个SKU通常表示:规格、颜色、款式。
2、基本属性(规格参数)与销售属性
说到SPU和SKU,就会引申出另外两个概念:规格参数 与 销售属性
每个产品分类下(三级分类树)的商品共享规格参数与销售属性,只是有些商品不一定要用这个分类下的全部属性:
-
属性是以三级分类组织起来的;
-
规格参数中有些是可以提供检索的;
-
规格参数也是基本属性,它们拥有自己的分组;
-
属性的分组也是以三级分类组织起来的;
-
属性名确定的,但是值是每个具体商品不同自己决定的;
这些讲得太抽象,我们就以京东的截图来说话:选择一个具体的商品页面:https://item.jd.com/100000177740.html#crumb-wrap
基本属性(规格参数,商品介绍等):
销售属性:
总结一下:
每个SPU拥有自己的基本属性(规格参数、商品介绍等);
每个SKU对应自己的一个SKU编号,对应着自己的销售属性(颜色、内存等),直接决定了销售价格、库存等销售信息;
一个SPU下面的所有SKU共享SPU的基本属性(规格参数、商品介绍等);
二、数据库表设计:
基于上面的概念介绍,我们的数据库设计如下:
这块的表设计很复杂,需要好好地理解一番;
主要分为3块,SPU的管理、SKU的管理,属性的管理;
其中属性是可以分组的,属性分组的入口在产品的三级分类tree目录;
出口则是具体的属性;但是由于是N:N的关系,所以需要一个中间关系表attr_attrgroup_relation表;
attr属性表为中间的核心表,但是其中只定义了属性的名称,却没有值,值是在具体的product_attr_value和sku_sale_attr_value表中保存;
我简单画了一个关系图(很多其他辅助字段是没有画出来的):
三、为具体的三级分类添加对应的属性分组管理
有了上面设计的数据库表结构和关系图,我们很容易就能想到,就是“根据之前完成的3级分类目录树,管理对应的属性分组信息”;
页面效果将被设计为“左右结构”——左边为Tree树,点击后右侧出现对应的属性分组信息;
1、因为之前已经演示过如何创建目录和菜单了,所以我们就快速地创建一下所需菜单即可:
创建菜单的SQL脚本如下:
CREATE DATABASE /*!32312 IF NOT EXISTS*/`zidanmall_admin` /*!40100 DEFAULT CHARACTER SET utf8mb4 */; USE `zidanmall_admin`; DROP TABLE IF EXISTS `sys_menu`; CREATE TABLE `sys_menu` ( `menu_id` bigint(20) NOT NULL AUTO_INCREMENT, `parent_id` bigint(20) DEFAULT NULL COMMENT '父菜单ID,一级菜单为0', `name` varchar(50) DEFAULT NULL COMMENT '菜单名称', `url` varchar(200) DEFAULT NULL COMMENT '菜单URL', `perms` varchar(500) DEFAULT NULL COMMENT '授权(多个用逗号分隔,如:user:list,user:create)', `type` int(11) DEFAULT NULL COMMENT '类型 0:目录 1:菜单 2:按钮', `icon` varchar(50) DEFAULT NULL COMMENT '菜单图标', `order_num` int(11) DEFAULT NULL COMMENT '排序', PRIMARY KEY (`menu_id`) ) ENGINE=InnoDB AUTO_INCREMENT=76 DEFAULT CHARSET=utf8mb4 COMMENT='菜单管理'; /*Data for the table `sys_menu` */ insert into `sys_menu`(`menu_id`,`parent_id`,`name`,`url`,`perms`,`type`,`icon`,`order_num`) values (1,0,'系统管理',NULL,NULL,0,'system',0),(2,1,'管理员列表','sys/user',NULL,1,'admin',1),(3,1,'角色管理','sys/role',NULL,1,'role',2),(4,1,'菜单管理','sys/menu',NULL,1,'menu',3),(5,1,'SQL监控','http://localhost:8080/renren-fast/druid/sql.html',NULL,1,'sql',4),(6,1,'定时任务','job/schedule',NULL,1,'job',5),(7,6,'查看',NULL,'sys:schedule:list,sys:schedule:info',2,NULL,0),(8,6,'新增',NULL,'sys:schedule:save',2,NULL,0),(9,6,'修改',NULL,'sys:schedule:update',2,NULL,0),(10,6,'删除',NULL,'sys:schedule:delete',2,NULL,0),(11,6,'暂停',NULL,'sys:schedule:pause',2,NULL,0),(12,6,'恢复',NULL,'sys:schedule:resume',2,NULL,0),(13,6,'立即执行',NULL,'sys:schedule:run',2,NULL,0),(14,6,'日志列表',NULL,'sys:schedule:log',2,NULL,0),(15,2,'查看',NULL,'sys:user:list,sys:user:info',2,NULL,0),(16,2,'新增',NULL,'sys:user:save,sys:role:select',2,NULL,0),(17,2,'修改',NULL,'sys:user:update,sys:role:select',2,NULL,0),(18,2,'删除',NULL,'sys:user:delete',2,NULL,0),(19,3,'查看',NULL,'sys:role:list,sys:role:info',2,NULL,0),(20,3,'新增',NULL,'sys:role:save,sys:menu:list',2,NULL,0),(21,3,'修改',NULL,'sys:role:update,sys:menu:list',2,NULL,0),(22,3,'删除',NULL,'sys:role:delete',2,NULL,0),(23,4,'查看',NULL,'sys:menu:list,sys:menu:info',2,NULL,0),(24,4,'新增',NULL,'sys:menu:save,sys:menu:select',2,NULL,0),(25,4,'修改',NULL,'sys:menu:update,sys:menu:select',2,NULL,0),(26,4,'删除',NULL,'sys:menu:delete',2,NULL,0),(27,1,'参数管理','sys/config','sys:config:list,sys:config:info,sys:config:save,sys:config:update,sys:config:delete',1,'config',6),(29,1,'系统日志','sys/log','sys:log:list',1,'log',7),(30,1,'文件上传','oss/oss','sys:oss:all',1,'oss',6),(31,0,'商品系统','','',0,'editor',0),(32,31,'分类维护','product/category','',1,'menu',0),(34,31,'品牌管理','product/brand','',1,'editor',0),(37,31,'平台属性','','',0,'system',0),(38,37,'属性分组','product/attrgroup','',1,'tubiao',0),(39,37,'规格参数','product/baseattr','',1,'log',0),(40,37,'销售属性','product/saleattr','',1,'zonghe',0),(41,31,'商品维护','product/spu','',0,'zonghe',0),(42,0,'优惠营销','','',0,'mudedi',0),(43,0,'库存系统','','',0,'shouye',0),(44,0,'订单系统','','',0,'config',0),(45,0,'用户系统','','',0,'admin',0),(46,0,'内容管理','','',0,'sousuo',0),(47,42,'优惠券管理','coupon/coupon','',1,'zhedie',0),(48,42,'发放记录','coupon/history','',1,'sql',0),(49,42,'专题活动','coupon/subject','',1,'tixing',0),(50,42,'秒杀活动','coupon/seckill','',1,'daohang',0),(51,42,'积分维护','coupon/bounds','',1,'geren',0),(52,42,'满减折扣','coupon/full','',1,'shoucang',0),(53,43,'仓库维护','ware/wareinfo','',1,'shouye',0),(54,43,'库存工作单','ware/task','',1,'log',0),(55,43,'商品库存','ware/sku','',1,'jiesuo',0),(56,44,'订单查询','order/order','',1,'zhedie',0),(57,44,'退货单处理','order/return','',1,'shanchu',0),(58,44,'等级规则','order/settings','',1,'system',0),(59,44,'支付流水查询','order/payment','',1,'job',0),(60,44,'退款流水查询','order/refund','',1,'mudedi',0),(61,45,'会员列表','member/member','',1,'geren',0),(62,45,'会员等级','member/level','',1,'tubiao',0),(63,45,'积分变化','member/growth','',1,'bianji',0),(64,45,'统计信息','member/statistics','',1,'sql',0),(65,46,'首页推荐','content/index','',1,'shouye',0),(66,46,'分类热门','content/category','',1,'zhedie',0),(67,46,'评论管理','content/comments','',1,'pinglun',0),(68,41,'spu管理','product/spu','',1,'config',0),(69,41,'发布商品','product/spuadd','',1,'bianji',0),(70,43,'采购单维护','','',0,'tubiao',0),(71,70,'采购需求','ware/purchaseitem','',1,'editor',0),(72,70,'采购单','ware/purchase','',1,'menu',0),(73,41,'商品管理','product/manager','',1,'zonghe',0),(74,42,'会员价格','coupon/memberprice','',1,'admin',0),(75,42,'每日秒杀','coupon/seckillsession','',1,'job',0);
导入成功后,页面菜单效果如下:
2、属性分组,我们使用“左右布局”的栅格结构
<el-row :gutter="20"> <el-col :span="6"><div class="grid-content bg-purple"></div></el-col> <el-col :span="18"><div class="grid-content bg-purple"></div></el-col> </el-row>
效果如下:
3、将之前的3级分类树的查询列表封装为一个组件component:
3.1、封装的树组件只有查询功能,其他增删改复杂操作都不需要:
3.2、封装的组件的使用:
-
3.2.1、导入组件:import Category from "../common/category";
-
3.2.2、在components中定义使用组件:components: { Category, AddOrUpdate };
-
3.2.3、在页面元素中使用标签:<category></category> 直接使用;
4、右侧属性分组部分,我们使用之前逆向工程生成的vue文件快速完成;
5、达到上面的“左右布局”栅格结构后,我们需要完成一个重要的工作
即点击左侧的树节点,右侧的属性分组列表会动态改变,查出对应的三级分类下的属性分组列表;
这里就会使用到Vue中的一个重要技术点:父子组件传值——子组件给父组件传值——通过事件机制;
即树节点被点击后,引用它的父组件attrgroup.vue,能感知到,并执行相应的操作;
5.1、当子组件被点击后,给父组件发送一个事件,携带上数据——类似于父子组件间的冒泡事件;
在子组件的 <el-tree> 组件上绑定自己的点击事件;
<el-tree :data="menus" :props="defaultProps" node-key="catId" ref="menuTree" @node-click="nodeclick"></el-tree>
5.2、通过 this.$emit("事件名", 传递的参数) 方法将事件传递给父组件;
nodeclick(data, node, component){ console.log("子组件节点被点击", data, node, component); //向父组件发送事件this.$emit("事件名", 传递的参数) this.$emit("tree-node-click", data, node, component); }
5.3、父组件在 <category> 中监听子组件的“tree-node-click”事件,并调用自己的方法:treeNodeClick()
<el-col :span="6"> <!-- 父组件监听子组件的tree-node-click事件,并执行自己的方法 --> <category @tree-node-click="treeNodeClick"></category> </el-col>
5.4、treeNodeClick()方法体如下:
//感知子组件的tree节点被点击后调用的方法: treeNodeClick(data, node, component){ console.log("attrgroup父组件感知到子组件树节点被点击", data, node, component); },
5.5、测试结果如下:
子组件向父组件传值完成;
四、获取具体3级分类对应的属性分组列表:
1、Java后端接口,service层实现:
/** * * @param params 其中除了分页参数外,还可能有查询字段key,key可能是groupId,也可能是名称的模糊查询 * @param catelogId * @return */ @Override public PageUtils queryPage(Map<String, Object> params, Long catelogId) { if (catelogId == 0){ IPage<AttrGroupEntity> page = this.page( new Query<AttrGroupEntity>().getPage(params), new QueryWrapper<AttrGroupEntity>() ); return new PageUtils(page); }else { String key = (String) params.get("key"); //select * from pms_attr_group where catelog_id = ? and (attr_group_id = ? or attr_group_name like %key%); QueryWrapper<AttrGroupEntity> wrapper = new QueryWrapper<AttrGroupEntity>().eq("catelog_id", catelogId); if (StringUtils.isNoneBlank(key)){ wrapper.and(obj -> obj.eq("attr_group_id", key).or().like("attr_group_name", key)); } IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params), wrapper); return new PageUtils(page); } }
2、前端vue实现:
//感知子组件的tree节点被点击后调用的方法: treeNodeClick(data, node, component){ console.log("attrgroup父组件感知到子组件树节点被点击", data, node, component); if(node.level == 3){ this.catId = data.catId; this.getDataList(); } }, // 获取数据列表 getDataList () { this.dataListLoading = true this.$http({ url: this.$http.adornUrl(`/product/attrgroup/list/${this.catId}`), method: 'get', params: this.$http.adornParams({ 'page': this.pageIndex, 'limit': this.pageSize, 'key': this.dataForm.key }) }).then(({data}) => { if (data && data.code === 0) { this.dataList = data.page.list this.totalPage = data.page.totalCount } else { this.dataList = [] this.totalPage = 0 } this.dataListLoading = false }) }
3、测试结果:
五、新增属性分组时,所属分类实现级联选择器:
1、级联选择器我们选择 element-ui 的级联选择器:Cascader 级联选择器
只需为 Cascader 的 options属性 指定 选项数组 即可渲染出一个级联选择器。通过props.expandTrigger可以定义展开子级菜单的触发方式。
<el-form-item label="所属分类id" prop="catelogId"> <!-- <el-input v-model="dataForm.catelogId" placeholder="所属分类id"></el-input> --> <el-cascader v-model="dataForm.catelogIds" :options="categorys" :props="props"></el-cascader> </el-form-item>
2、data内容:
data() { return { props: { value:"catId", label:"name", children:"children" }, visible: false, categorys: [], dataForm: { attrGroupId: 0, attrGroupName: "", sort: "", descript: "", icon: "", catelogIds: [], //级联选择器绑定值必须为数组[] catelogId: 0 //实际提交到后台的为数组最后一个值,即最底层 } }
3、相关方法:
methods: { getCategorys() { this.$http({ url: this.$http.adornUrl("/product/category/list/tree"), method: "get" }).then(({ data }) => { console.log("成功获取到菜单数据...", data.data); this.categorys = data.data; }); }, dataFormSubmit() { this.$refs["dataForm"].validate(valid => { if (valid) { this.$http({ url: this.$http.adornUrl( `/product/attrgroup/${ !this.dataForm.attrGroupId ? "save" : "update" }` ), method: "post", data: this.$http.adornData({ attrGroupId: this.dataForm.attrGroupId || undefined, attrGroupName: this.dataForm.attrGroupName, sort: this.dataForm.sort, descript: this.dataForm.descript, icon: this.dataForm.icon, catelogId: this.dataForm.catelogIds[this.dataForm.catelogIds.length-1] }) }).then(); } }); } } created() { this.getCategorys(); }
4、测试效果:
5、修改或查看详情时,我们需要让级联选择器回显我们的选择:
因为我们保存到数据库的所属分类知识最后一层“手机”的catId,但是级联选择器需要绑定的是一个数组:v-model = "dataForm.catelogIds";
这就需要我们的后端在获取详细信息的时候,将catelogIds参数一并返回回来:
在AttrGroupEntity中增加数据库中没有的属性:
/** * 所属分类的完整路径 */ @TableField(exist = false) private Long[] catelogPath;
Java中核心接口的Service层实现:
/** * 根据catelogId查询此分类的完整路径:Long[]数组 * @param catelogId * @return */ @Override public Long[] findCatelogPath(Long catelogId) { List<Long> paths = new ArrayList<>(); findParent(paths, catelogId); Collections.reverse(paths); return paths.toArray(new Long[paths.size()]); } private void findParent(List<Long> paths, Long catelogId){ paths.add(catelogId); CategoryEntity byId = this.getById(catelogId); if (byId.getParentCid() != 0){ findParent(paths, byId.getParentCid()); } }
6、在vue中init()的then()回调中直接绑定结果即可:
//级联选择器需要dataForm.catelogs的完整路径,数组[] this.dataForm.catelogIds = data.attrGroup.catelogPath;
测试效果:
7、优化:开启级联选择器的可搜索功能:
根据官方文档:
将 filterable 赋值为true 即可打开搜索功能,默认会匹配节点的label或所有父节点的label(由show-all-levels决定)中包含输入值的选项。你也可以用filter-method自定义搜索逻辑,接受一个函数,第一个参数是节点node,第二个参数是搜索关键词keyword,通过返回布尔值表示是否命中。
<el-cascader filterable v-model="dataForm.catelogIds" :options="categorys" :props="props"></el-cascader>
六、总结:
到这里,我们的属性分组功能就全部完成了;
前端vue的工作中常用的技术点也差不多覆盖了,后面实施过程中,就不会再去一步步地记录前端Vue的代码编写过程了;
而是注重后台的接口逻辑及架构设计;
所有的后端所需接口,我参开API文档: