目录
商品规格管理
一、商品规格数据结构
乐优商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU和SKU。
1.1 SPU和SKU
SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集
SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品
- 本页的 vivo x21就是一个商品集(SPU)
- 因为颜色、内存等不同,而细分出不同的x21,如亮冰钻黑6+128G版。(SKU)
可以看出:
- SPU是一个抽象的商品集概念,为了方便后台的管理。
- SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU
1.2 数据库设计分析
1.2.1 规格参数分析
当仔细查看每一种规格后就会发现,虽然商品规格千变万化,但是同一类商品(手机)的规格是统一的,如下图所示:
vivo的规格
oppo的规格
所以,商品的规格参数应该是与分类绑定的,每个分类都有统一的规格参数模板,dan'shi 不同商品的参数值可能不同。
如下图所示:
1.2.2 SKU特有属性
SPU中会有一些特殊属性,用来区分不同的SKU,我们称为SKU特有属性。如vivo x21的颜色、内存属性。不同种类的商品,一个手机,一个衣服,其SKU属性不相同。同一种类的商品,比如都是衣服,SKU属性基本是一样的,都是颜色、尺码等。
这样说起来,似乎SKU的特有属性也是与分类相关的?事实上,仔细观察会发现,SKU的特有属性是商品规格参数的一部分:
也就是说,没必要单独对SKU的特有属性进行设计,它可以看做是规格参数中的一部分。这样规格参数中的属性可以标记成两部分:
- 所有sku共享的规格属性(称为全局属性)
- 每个sku不同的规格属性(称为特有属性)
1.2.3 搜索属性
打开搜索页,查看过滤条件:
过滤条件中的屏幕尺寸、运行内存、机身内存、CPU核数等,在规格参数中都能找到:
也就是说,规格参数中的数据,将来会有一部分作为搜索条件来使用。所以在设计时,将这部分属性标记出来,将来做搜索的时候,作为过滤条件。要注意的是,无论是SPU的全局属性,还是SKU的特有属性,都有可能作为搜索过滤条件的,并不冲突,而是有一个交集:
1.3 规格参数表
1.3.1 表结构
CREATE TABLE `tb_specification` (
`category_id` bigint(20) NOT NULL COMMENT '规格模板所属商品分类id',
`specifications` varchar(3000) NOT NULL DEFAULT '' COMMENT '规格参数模板,json格式',
PRIMARY KEY (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品规格参数模板,json格式。';
specificatons:规格参数模板,json格式
如果按照传统数据库设计,这里至少需要3张表:
-
group:代表组,与商品分类关联
-
param_key:属性名,与组关联,一对多
-
param_value:属性备选值,与属性名关联,一对多
这样程序的复杂度大大增加,但是提高了数据的复用性。
所以解决方案是,采用json来保存整个规格参数模板,不需要额外的表,一个字符串就够了。
1.3.2 JSON结构分析
整体
- 因为规格参数分为很多组,所以json最外层是一个数组。
- 数组中是对象类型,每个对象代表一个组的数据,对象的属性包括:
group:组的名称
params:该组的所有属性
params
以主芯片
这一组为例:
-
group:注明,这里是主芯片
-
params:该组的所有规格属性,因为不止一个,所以是一个数组。这里包含四个规格属性:CPU品牌,CPU型号,CPU频率,CPU核数。每个规格属性都是一个对象,包含以下信息:
-
k:属性名称
-
searchable:是否作为搜索字段,将来在搜索页面使用,boolean类型
-
global:是否是SPU全局属性,boolean类型。true为全局属性,false为SKU的特有属性
-
options:属性值的可选项,数组结构。起约束作用,不允许填写可选项以外的值,比如CPU核数,有人添10000核岂不是很扯淡
-
numerical:是否为数值,boolean类型,true则为数值,false则不是。为空也代表非数值
-
unit:单位,如:克,毫米。如果是数值类型,那么就需要有单位,否则可以不填。
-
以上属性都是全局属性,下面是特殊属性:
总结下:
-
规格参数分组,每组有多个参数
-
参数的
k
代表属性名称,没有值,具体的SPU才能确定值 -
参数会有不同的属性:是否可搜索,是否是全局、是否是数值,这些都用boolean值进行标记:
-
SPU下的多个SKU共享的参数称为全局属性,用
global=true
标记 -
SPU下的多个SKU特有的参数称为特有属性,用
global=false
标记 -
如果参数是数值类型,用
numerical
标记,并且指定单位unit
-
如果参数可搜索,用
searchable
标记
-
二、商品规格参数管理
2.1 页面分析
因为规格是跟商品分类绑定的,因此首先会展现商品分类树,并且提示你要选择商品分类,才能看到规格参数的模板。
可以看出页面分成3个部分:
-
v-card-title
:标题部分,这里是提示信息,告诉用户要先选择分类,才能看到模板 -
v-tree
:这里用到的是我们之前讲过的树组件,展示商品分类树,不过现在是假数据,我们只要把treeData
属性删除,它就会走url
属性指定的路径去查询真实的商品分类树了。<v-tree url="/item/category/list" :isEdit="false" @handleClick="handleClick" />
-
v-dialog
:Vuetify提供的对话框组件,v-model绑定的dialog属性是boolean类型:-
true则显示弹窗
-
false则隐藏弹窗
-
再看一下Vue实例中data定义了哪些属性,对页面会产生怎样的影响:
-
specifications:选中一个商品分类后,需要查询后台获取规格参数信息,保存在这个对象中,Vue会完成页面渲染。
-
oldSpec:当前页兼具了规格的增、改、查等功能,这个对象记录被修改前的规格参数,以防用户撤销修改,用来恢复数据。
-
dialog:是否显示对话框的标记。true则显示,false则不显示
-
currentNode:记录当前选中的商品分类节点
-
units:数值类型的可选单位
2.2 规格参数查询
点击树叶子节点后要显示规格参数,因此查询功能应该编写在点击事件中。
2.2.1 树节点的点击事件
handleClick(node) {
// 把当前点击IDE节点记录下来
this.currentNode = node;
// 判断点击的节点是否是父节点(只有点击到叶子节点才会弹窗)
if (!node.isParent) {
// 如果是叶子节点,那么就发起ajax请求,去后台查询商品规格数据。
this.$http.get("/item/spec/" + node.id)
.then(resp => {
console.log(resp.data)
// 查询成功后,把响应结果赋值给specifications属性,Vue会进行自动渲染。
this.specifications = resp.data;
// 记录下此时的规格数据,当页面撤销修改时,用来恢复原始数据
this.oldSpec = resp.data;
// 打开弹窗
this.dialog = true;
// 标记此时要进行修改操作
this.isInsert = false;
})
.catch(() => {
// 如果没有查询成功,那么询问是否添加规格
this.$message.confirm('该分类还没有规格参数,是否添加?')
.then(() => {
// 如果要添加,则将specifications初始化为空
this.specifications = [{
group: '',
params: []
}];
// 打开弹窗
this.dialog = true;
// 标记为新增
this.isInsert = true;
})
})
}
}
重点是编写后台接口,返回数据即可。
2.2.2 后端接口
Pojo
package com.leyou.item.pojo;
import javax.persistence.Id;
import javax.persistence.Table;
@Table(name = "tb_specification")
public class Specification {
@Id
private Long categoryId;
private String specifications;
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
public String getSpecifications() {
return specifications;
}
public void setSpecifications(String specifications) {
this.specifications = specifications;
}
@Override
public String toString() {
return "Specification{" +
"categoryId=" + categoryId +
", specifications='" + specifications + '\'' +
'}';
}
}
Mapper
package com.leyou.item.mapper;
import com.leyou.item.pojo.Specification;
import tk.mybatis.mapper.common.Mapper;
/**
* @author li
*/
@org.apache.ibatis.annotations.Mapper
public interface SpecificationMapper extends Mapper<Specification> {
}
Controller
先分析下需要的东西,在页面的ajax请求中可以看出:
-
请求方式:查询,肯定是get
-
请求路径:/spec/{cid} ,这里通过路径占位符传递商品分类的id
-
请求参数:商品分类id
-
返回结果:页面是直接把
resp.data
赋值给了specifications:
那么返回的应该是规格参数的字符串
package com.leyou.item.controller;
import com.leyou.item.pojo.Specification;
import com.leyou.item.service.SpecificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* @author li
*/
@RestController
@RequestMapping("spec")
public class SpecificationController {
@Autowired
private SpecificationService specificationService;
/**
* 查询商品分类对应的规格参数模板
* @param id
* @return
*/
@GetMapping("{id}")
public ResponseEntity<String> querySpecificationByCategoryId(@PathVariable("id") Long id){
Specification spec = this.specificationService.queryById(id);
if (spec == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(spec.getSpecifications());
}
}
Service
接口
package com.leyou.item.service;
import com.leyou.item.pojo.Specification;
/**
* @Author: 98050
* Time: 2018-08-14 15:26
* Feature:
*/
public interface SpecificationService {
/**
* 根据category id查询规格参数模板
* @param id
* @return
*/
Specification queryById(Long id);
}
实现类
package com.leyou.item.service.serviceimpl;
import com.leyou.item.mapper.SpecificationMapper;
import com.leyou.item.pojo.Specification;
import com.leyou.item.service.SpecificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* @Author: 98050
* Time: 2018-08-14 15:26
* Feature:
*/
@Service
public class SpecificationServiceImpl implements SpecificationService {
@Autowired
private SpecificationMapper specificationMapper;
@Override
public Specification queryById(Long id) {
return this.specificationMapper.selectByPrimaryKey(id);
}
}
测试访问
数据库中由3条完整模板信息
先访问:http://api.leyou.com/api/item/spec/76
页面访问:
规格参数的增加、删除和修改全部在这个对话框中完成,所以删除和修改就可以合并。
注:每一个分组的全部数据后期都以JSON的方式存入
2.3 规格参数增加
2.3.1 前端代码
//添加分组
addGroup() {
this.specifications.push({
group: '',
params: []
})
},
// 添加新属性
addParam(i) {
this.specifications[i].params.push({
k: "",
searchable: false,
global: true,
numerical:false,
unit:"",
options: []
})
},
// 为属性添加默认值
addOption(i, j) {
this.specifications[i].params[j].options.push("")
},
上面这三个函数主要是用来添加分组、属性、属性默认值,演示如下所示:
重点就是saveTemplate函数了,包含对规格模板的增加、修改和删除等功能。
// 保存、修改、删除模板
saveTemplate() {
this.dialog = true;
//模板删除
if (this.specifications.length === 0){
//console.log("删除:"+this.currentNode.id);
this.$http.delete("/item/spec/"+this.currentNode.id).then(() => {
this.dialog = false;
this.$message.success("删除成功!");
this.oldSpec = [];
}).catch(() => {
this.$message.error("删除失败");
});
}else {
this.$http({
method: this.oldSpec.length === 0 ? 'post' : 'put',
url: '/item/spec',
data: this.$qs.stringify({
categoryId: this.currentNode.id,
specifications: JSON.stringify(this.specifications)
})
})
.then(() => {
this.dialog = false;
this.$message.success("保存成功!")
this.oldSpec = [];
})
.catch(() => {
this.$message.error("保存失败!")
});
}
}
注:
如何判断是否进行删除?通过specifications的长度来判断是否进行修改,长度为0表示模板内容为空。
如何判断修改还是新增? 通过oldSpec的长度来判断,因为在点击叶子节点时会查询对应的规格模板,查询到的话就将数据保存一份到oldSpec中,用来进行修改;所以当oldSpec的长度为0时,那么说明没有查到模板,即为新增。(oldSpec是个数组)
2.3.2 后台接口
Controller
/**
* 保存一个规格参数模板
* @param specification
* @return
*/
@PostMapping
public ResponseEntity<Void> saveSpecification(Specification specification){
this.specificationService.saveSpecification(specification);
return ResponseEntity.status(HttpStatus.OK).build();
}
Mapper
package com.leyou.item.mapper;
import com.leyou.item.pojo.Specification;
import tk.mybatis.mapper.common.Mapper;
/**
* @author li
*/
@org.apache.ibatis.annotations.Mapper
public interface SpecificationMapper extends Mapper<Specification> {
}
Service
接口
/**
* 添加规格参数模板
* @param specification
*/
void saveSpecification(Specification specification);
实现类
@Override
public void saveSpecification(Specification specification) {
this.specificationMapper.insert(specification);
}
2.4 规格参数修改
Controller
/**
* 修改一个规格参数模板
* @param specification
* @return
*/
@PutMapping
public ResponseEntity<Void> updateSpecification(Specification specification){
this.specificationService.updateSpecification(specification);
return ResponseEntity.status(HttpStatus.OK).build();
}
Mapper
package com.leyou.item.mapper;
import com.leyou.item.pojo.Specification;
import tk.mybatis.mapper.common.Mapper;
/**
* @author li
*/
@org.apache.ibatis.annotations.Mapper
public interface SpecificationMapper extends Mapper<Specification> {
}
Service
接口
/**
* 修改规格参数模板
* @param specification
*/
void updateSpecification(Specification specification);
实现类
@Override
public void updateSpecification(Specification specification) {
this.specificationMapper.updateByPrimaryKeySelective(specification);
}
2.5 规格参数删除
Controller
@DeleteMapping("{id}")
public ResponseEntity<Void> deleteSpecification(@PathVariable("id") Long id){
Specification specification = new Specification();
specification.setCategoryId(id);
this.specificationService.deleteSpecification(specification);
return ResponseEntity.status(HttpStatus.OK).build();
}
Mapper
package com.leyou.item.mapper;
import com.leyou.item.pojo.Specification;
import tk.mybatis.mapper.common.Mapper;
/**
* @author li
*/
@org.apache.ibatis.annotations.Mapper
public interface SpecificationMapper extends Mapper<Specification> {
}
Service
接口
/**
* 删除规格参数模板
* @param specification
*/
void deleteSpecification(Specification specification);
实现类
@Override
public void deleteSpecification(Specification specification) {
this.specificationMapper.deleteByPrimaryKey(specification);
}
2.6 总结
对规格模板的操作都是放在一个对话框中进行的,因为specifications中保存的是全部的分组信息,每个分组的信息都是JSON格式。新增和修改就是直接把specifications中的内容传到后台进行相应的操作,删除则是通过判断specifications的长度来决定的,当长度为0时,说明用户已经把所有分组全部删除,所以相应的在数据库中要清除对应id的数据。删除是通过给后台传入id进行的,那么这个id从何而来,通过this.currentNode.id来获取。
2.7 测试