[TOC]
1 商品数据分析
1.1 SPU与SKU
乐购商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU和SKU,了解一下:
SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集
SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品
以图为例来看:
- 本页的 华为Mate10 就是一个商品集(SPU)
- 因为颜色、内存等不同,而细分出不同的Mate10,如亮黑色128G版。(SKU)
可以看出:
- SPU是一个抽象的商品集概念,为了方便后台的管理。
- SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU
1.2 表结构分析
spu_ 表 (SPU表)
字段名称 | 字段含义 | 字段类型 | 字段长度 | 备注 |
---|---|---|---|---|
id_ | 主键 | BIGINT | ||
title_ | 标题 | VARCHAR | ||
subtitle | 子标题 | VARCHAR | ||
brandid | 品牌ID | INT | ||
cid1_ | 一级分类 | INT | ||
cid2_ | 二级分类 | INT | ||
cid3_ | 三级分类 | INT | ||
saleable_ | 是否上架 | BOOLEAN | ||
valid_ | 是否有效 | BOOLEAN | 逻辑删除用 | |
createtime | 创建时间 | DATETIME | ||
last_updatetime | 最后修改时间 | VARCHAR |
spudetail 表(spu详情表)
字段名称 | 字段含义 | 字段类型 | 字段长度 | 备注 |
---|---|---|---|---|
id | 主键 | BIGINT | ||
description_ | 商品描述 | VARCHAR | ||
genericspec | 通用规格参数 | VARCHAR | ||
specialspec | sku规格参数 | VARCHAR | ||
packinglist | 包装列表 | VARCHAR | ||
afterservice | 售后服务 | VARCHAR |
genericspec
{"品牌":"华为","型号":"G9青春版(全网通版)","上市年份":2016,"机身重量(g)":143,"机身材质工艺":"其它","操作系统":"Android","CPU品牌":"骁龙(Snapdragon)","CPU型号":"骁龙617(msm8952)","CPU核数":"八核","CPU频率":1.5,"主屏幕尺寸(英寸)":5.2,"分辨率":"1920*1080(FHD)","前置摄像头":800,"后置摄像头":1300,"电池容量(mAh)":3000}
specialspec
{"机身颜色":["白色","金色","玫瑰金"],"内存":["3GB"],"机身存储":["16GB"]}
sku_ 表 (sku表)
字段名称 | 字段含义 | 字段类型 | 字段长度 | 备注 |
---|---|---|---|---|
id_ | 主键 | BIGINT | ||
spuid | 外键 | VARCHAR | spu编号 | |
title_ | 标题 | VARCHAR | ||
images_ | 图片 | VARCHAR | ||
price_ | 单价 | NUMBER(10,2) | ||
indexes_ | sku规格的下标 | VARCHAR | ||
ownspec | sku规格参数 | VARCHAR | ||
enable_ | 是否有效 | BOOLEAN | 逻辑删除用 | |
createtime | 创建时间 | DATETIME | ||
last_updatetime | 最后修改时间 | DATETIME | ||
stock_ | 库存 | VARCHAR |
ownspec
{"机身颜色":"金","内存":"4GB","机身存储":"32GB"}
通过上面的数据分析,得出这3个表的关系图如下:
2 商品管理
2.1 需求分析
实现商品的新增与修改功能。
(1)第1个步骤,先选择添加的商品所属分类
(2)第2个步骤,填写SPU基本信息
(3)第3个步骤,填写商品描述
(4)第4个步骤,填写通用规格参数
(5)第5个步骤,填写spu参数,这个参数会通过笛卡尔积算法,生成sku列表
(6)第6个步骤,生成的SKU列表
根据sku属性,通过笛卡尔积算法生成SKU列表
2.2 实现思路
前端传递给后端的数据格式 是一个spu对象和sku列表组成的对象,如下图:
上图JSON数据如下:
{
"id": 2,
"brandId": 8557,
"cid1": 74,
"cid2": 75,
"cid3": 76,
"title": "华为 G9 青春版测试 ",
"subTitle": "骁龙芯片!3GB运行内存!索尼1300万摄像头!<a href='https://sale.jd.com/act/DhKrOjXnFcGL.html' target='_blank'>华为新品全面上线,更多优惠猛戳》》</a>",
"saleable": true,
"valid": true,
"createTime": "2018-04-21",
"lastUpdateTime": "2018-06-18",
"spuDetail": {
"id": 2,
"description": "<p><img src=\"//img20.360buyimg.com/vc/jfs/t5893/141/6838703316/1369626/15c9d88f/596c753aN075ee827.jpg\"></p>",
"specialSpec": "{\"机身颜色\":[\"白色\",\"金色\",\"玫瑰金\"],\"内存\":[\"3GB\"],\"机身存储\":[\"16GB\"]}",
"genericSpec": "{\"品牌\":\"华为\",\"型号\":\"G9青春版(全网通版)\",\"上市年份\":2016,\"机身重量(g)\":143,\"机身材质工艺\":\"其它\",\"操作系统\":\"Android\",\"CPU品牌\":\"骁龙(Snapdragon)\",\"CPU型号\":\"骁龙617(msm8952)\",\"CPU核数\":\"八核\",\"CPU频率\":1.5,\"主屏幕尺寸(英寸)\":5.2,\"分辨率\":\"1920*1080(FHD)\",\"前置摄像头\":800,\"后置摄像头\":1300,\"电池容量(mAh)\":3000}",
"packingList": "手机(电池内置)*1,中式充电器*1,数据线*1,半入耳式线控耳机*1,华为手机凭证*1,快速指南*1,取卡针*1,屏幕保护膜(出厂已贴)*1",
"afterService": "本产品全国联保,享受三包服务,质保期为:一年质保"
},
"skus": [
{
"id": 27359021806,
"spuId": 2,
"title": "华为 G9 青春版 白色 移动联通电信4G手机 双卡双待",
"images": "http://image.leyou.com/images/9/15/1524297313793.jpg",
"price": 84900,
"ownSpec": "{\"机身颜色\":\"白色\",\"内存\":\"3GB\",\"机身存储\":\"16GB\"}",
"indexes": "0_0_0",
"enable": true,
"createTime": "2018-04-21",
"lastUpdateTime": "2018-04-21",
"stock": 99999
},
{
"id": 27359021807,
"spuId": 2,
"title": "华为 G9 青春版 金色 移动联通电信4G手机 双卡双待",
"images": "http://image.leyou.com/images/9/5/1524297314398.jpg",
"price": 84900,
"ownSpec": "{\"机身颜色\":\"金色\",\"内存\":\"3GB\",\"机身存储\":\"16GB\"}",
"indexes": "1_0_0",
"enable": true,
"createTime": "2018-04-21",
"lastUpdateTime": "2018-04-21",
"stock": 99999
},
{
"id": 27359021808,
"spuId": 2,
"title": "华为 G9 青春版 玫瑰金 移动联通电信4G手机 双卡双待",
"images": "http://image.leyou.com/images/15/15/1524297314800.jpg",
"price": 84900,
"ownSpec": "{\"机身颜色\":\"玫瑰金\",\"内存\":\"3GB\",\"机身存储\":\"16GB\"}",
"indexes": "2_0_0",
"enable": true,
"createTime": "2018-04-21",
"lastUpdateTime": "2018-04-21",
"stock": 99999
}
],
"brandName": null,
"categoryName": null
}
2.3 后台代码
2.3.1 实体类
legou-item/legou-item-instance/src/main/java/com/lxs/legou/item/po/Spu.java
package com.lxs.legou.item.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.lxs.legou.core.po.BaseEntity;
import lombok.Data;
import java.util.Date;
import java.util.List;
@Data
@TableName("spu_")
public class Spu extends BaseEntity {
@TableField("brand_id_")
private Long brandId;
@TableField("cid1_")
private Long cid1;// 1级类目
@TableField("cid2_")
private Long cid2;// 2级类目
@TableField("cid3_")
private Long cid3;// 3级类目
@TableField("title_")
private String title;// 标题
@TableField("sub_title_")
private String subTitle;// 子标题
@TableField("saleable_")
private Boolean saleable;// 是否上架
@TableField("valid_")
private Boolean valid;// 是否有效,逻辑删除用
@TableField("create_time_")
private Date createTime;// 创建时间
@TableField("last_update_time_")
private Date lastUpdateTime;// 最后修改时间
@TableField(exist = false)
private SpuDetail spuDetail; //Spu详情对象,描述,规格参数,SKU参数等...
@TableField(exist = false)
private List<Sku> skus; //spu对应的sku集合
@TableField(exist = false)
private String brandName; //品牌名称,查询是显示
@TableField(exist = false)
private String categoryName;//分类名称,查询是显示
}
legou-item/legou-item-instance/src/main/java/com/lxs/legou/item/po/SpuDetail.java
package com.lxs.legou.item.po;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.lxs.legou.core.po.BaseEntity;
import lombok.Data;
@Data
@TableName("spu_detail_")
public class SpuDetail extends BaseEntity {
/**
* 实体编号(唯一标识)
*/
@TableId(value = "id_", type = IdType.INPUT)
protected Long id;
@TableField("description_")
private String description;// 商品描述
@TableField("special_spec_")
private String specialSpec;// 商品特殊规格的名称及可选值模板
@TableField("generic_spec_")
private String genericSpec;// 商品的全局规格属性
@TableField("packing_list_")
private String packingList;// 包装清单
@TableField("after_service_")
private String afterService;// 售后服务
}
legou-item/legou-item-instance/src/main/java/com/lxs/legou/item/po/Sku.java
package com.lxs.legou.item.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.lxs.legou.core.po.BaseEntity;
import lombok.Data;
import java.util.Date;
@Data
@TableName("sku_")
public class Sku extends BaseEntity {
@TableField("spu_id_")
private Long spuId;
@TableField("title_")
private String title;
@TableField("images_")
private String images;
@TableField("price_")
private Long price;
@TableField("own_spec_")
private String ownSpec;// 商品特殊规格的键值对
@TableField("indexes_")
private String indexes;// 商品特殊规格的下标
@TableField("enable_")
private Boolean enable;// 是否有效,逻辑删除用
@TableField("create_time_")
private Date createTime;// 创建时间
@TableField("last_update_time_")
private Date lastUpdateTime;// 最后修改时间
@TableField("stock_")
private Integer stock;// 库存
}
2.3.2 持久层
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/dao/SpuDao.java
package com.lxs.legou.item.dao;
import com.lxs.legou.core.dao.ICrudDao;
import com.lxs.legou.item.po.Spu;
/**
* @Title: Spu DAO类
*/
public interface SpuDao extends ICrudDao<Spu> {
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/dao/SpuDetailDao.java
package com.lxs.legou.item.dao;
import com.lxs.legou.core.dao.ICrudDao;
import com.lxs.legou.item.po.SpuDetail;
/**
* @Title: Sku DAO类
*/
public interface SpuDetailDao extends ICrudDao<SpuDetail> {
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/dao/SkuDao.java
package com.lxs.legou.item.dao;
import com.lxs.legou.core.dao.ICrudDao;
import com.lxs.legou.item.po.Sku;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* @Title: Sku DAO类
*/
public interface SkuDao extends ICrudDao<Sku> {
@Select("select * from sku_ where spu_id_ = #{skuId}")
public List<Sku> findBySkuId(Integer skuId);
}
映射文件
legou-item/legou-item-service/src/main/resources/mybatis/item/SpuDao.xml:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lxs.legou.item.dao.SpuDao">
<select id="selectByPage" resultType="Spu">
select
a.*,
b.name_ as brandName,
c.title_ as categoryName
from
spu_ a
left join
brand_ b on a.brand_id_ = b.id_
left join
category_ c on a.cid3_ = c.id_
<where>
<if test="title != null and title != ''">
and title_ like '%${title}%'
</if>
<if test="cid3 != null">
and cid3_ = #{cid3}
</if>
<if test="brandId != null">
and brand_id_ = #{brandId}
</if>
</where>
</select>
<select id="selectById" resultMap="spuMap">
select
*
from
spu_
where
id_ = #{id}
</select>
<resultMap id="spuMap" type="spu">
<id column="id_" property="id"></id>
<association property="spuDetail" javaType="SpuDetail" select="com.lxs.legou.item.dao.SpuDetailDao.selectById" column="id_">
<id column="id_" property="id"></id>
</association>
<collection property="skus" javaType="java.util.List" ofType="sku" select="com.lxs.legou.item.dao.SkuDao.findBySkuId" column="id_">
<id column="id_" property="id"></id>
</collection>
</resultMap>
</mapper>
2.3.3 业务层
接口
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/service/ISpuService.java
package com.lxs.legou.item.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.item.po.Spu;
public interface ISpuService extends ICrudService<Spu> {
/**
* 保存spu,包括如下表的数据
* spu
* spuDetail
* skus
* @param spu
*/
public void saveSpu(Spu spu);
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/service/ISkuService.java
package com.lxs.legou.item.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.item.po.Sku;
public interface ISkuService extends ICrudService<Sku> {
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/service/ISpuDetailService.java
package com.lxs.legou.item.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.item.po.SpuDetail;
public interface ISpuDetailService extends ICrudService<SpuDetail> {
}
实现类
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/service/impl/SpuServiceImpl.java
package com.lxs.legou.item.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.lxs.legou.core.service.impl.CrudServiceImpl;
import com.lxs.legou.item.po.Sku;
import com.lxs.legou.item.po.Spu;
import com.lxs.legou.item.service.ISkuService;
import com.lxs.legou.item.service.ISpuDetailService;
import com.lxs.legou.item.service.ISpuService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SpuServiceImpl extends CrudServiceImpl<Spu> implements ISpuService {
@Autowired
private ISpuDetailService spuDetailService;
@Autowired
private ISkuService skuService;
@Override
@Transactional
public void saveSpu(Spu spu) {
//保存spu
this.saveOrUpdate(spu);
//保存spuDetail
if (null == spu.getSpuDetail().getId()) {
spu.getSpuDetail().setId(spu.getId());
spuDetailService.save(spu.getSpuDetail());
} else {
spuDetailService.updateById(spu.getSpuDetail());
}
//保存sku
//删除spu的所有sku
skuService.remove(Wrappers.<Sku>query().eq("spu_id_", spu.getId()));
//添加新的sku
for (Sku sku : spu.getSkus()) {
sku.setSpuId(spu.getId());
skuService.save(sku);
}
}
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/service/impl/SkuServiceImpl.java
package com.lxs.legou.item.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.lxs.legou.core.service.impl.CrudServiceImpl;
import com.lxs.legou.item.po.Sku;
import com.lxs.legou.item.service.ISkuService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SkuServiceImpl extends CrudServiceImpl<Sku> implements ISkuService {
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/service/impl/SpuDetailServiceImpl.java
package com.lxs.legou.item.service.impl;
import com.lxs.legou.core.service.impl.CrudServiceImpl;
import com.lxs.legou.item.po.SpuDetail;
import com.lxs.legou.item.service.ISpuDetailService;
import org.springframework.stereotype.Service;
@Service
public class SpuDetailServiceImpl extends CrudServiceImpl<SpuDetail> implements ISpuDetailService {
}
2.3.4 控制层
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/controller/SpuController.java:
package com.lxs.legou.item.controller;
import com.lxs.legou.core.controller.BaseController;
import com.lxs.legou.core.po.ResponseBean;
import com.lxs.legou.item.po.Spu;
import com.lxs.legou.item.service.ISpuService;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* @Title:
*/
@RestController
@RequestMapping(value = "/spu")
public class SpuController extends BaseController<ISpuService, Spu> {
@ApiOperation(value="保存商品信息", notes="保存商品信息")
@PostMapping("/save-spu")
public ResponseBean saveSpu(@RequestBody Spu spu) throws Exception {
ResponseBean rm = new ResponseBean();
try {
service.saveSpu(spu);
} catch (Exception e) {
e.printStackTrace();
rm.setSuccess(false);
rm.setMsg("保存失败");
}
return rm;
}
@ApiOperation(value="查询所有", notes="查询所有spu")
@GetMapping("/list-all")
public List<Spu> selectAll() {
return service.list(new Spu());
}
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/controller/SpuDetailController.java
package com.lxs.legou.item.controller;
import com.lxs.legou.core.controller.BaseController;
import com.lxs.legou.item.po.SpuDetail;
import com.lxs.legou.item.service.ISpuDetailService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Title:
*/
@RestController
@RequestMapping(value = "/spu-detail")
public class SpuDetailController extends BaseController<ISpuDetailService, SpuDetail> {
}
legou-item/legou-item-service/src/main/java/com/lxs/legou/item/controller/SkuController.java:
package com.lxs.legou.item.controller;
import com.lxs.legou.core.controller.BaseController;
import com.lxs.legou.item.po.Sku;
import com.lxs.legou.item.service.ISkuService;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @Title:
*/
@RestController
@RequestMapping(value = "/sku")
public class SkuController extends BaseController<ISkuService, Sku> {
}
2.3.5 测试
使用Postman访问http://localhost:9005/item/spu/edit/2
2.4 前端组件
商品管理的前端组件比较复杂,还是遵循我们的开发原则,前端vue代码了解,实现思路语法即可,不需要大家从0道1编写,真正要写时,根据文档和vue相关帮助能写出来即可,有兴趣的同学可以自行根据文档编写响应的组件,这里我们只讲解前端实现思路,以帮助我们更好的跟后台对接
2.4.1 列表组件
src/view/item/spu/list.vue:
完整代码:
<template>
<div>
<Row>
<Form ref="formData" :model="formData" :label-width="80">
<Row style="margin-top: 10px;">
<Col span="6">
<FormItem label="标题" prop="name">
<Input v-model="formData.title" placeholder="标题"></Input>
</FormItem>
</Col>
<Col span="6">
<FormItem label="分类" prop="cid3">
<select-category2 v-model="formData.cid3"></select-category2>
</FormItem>
</Col>
<Col span="6">
<FormItem label="品牌" prop="brandId">
<select-brand v-model="formData.brandId"></select-brand>
</FormItem>
</Col>
<Col span="6">
<Divider type="vertical"/>
<Button type="primary" @click="add">添加</Button>
<Button type="primary" @click="removeBatch" style="margin-left: 8px">删除</Button>
<Button type="primary" @click="query" style="margin-left: 8px">查询</Button>
</Col>
</Row>
</Form>
</Row>
<div>
<Table stripe ref="selection" :columns="columns" :data="rows"></Table>
</div>
<div class="paging">
<Page :total="total" :page-size="pageSize" show-sizer show-elevator show-total
@on-change="changePage" @on-page-size-change="changePageSize"></Page>
</div>
</div>
</template>
<style scoped>
.paging {
float: right;
margin-top: 10px;
}
</style>
<script>
import {baseList} from '@/libs/crud/base-list'
import selectCategory2 from '_c/select/selectCategory2.vue'
import selectBrand from '_c/select/selectBrand.vue'
export default {
mixins: [baseList],
components: {selectCategory2, selectBrand},
data() {
return {
formData: {
title: '',
cid3: null,
brandId: null
},
columns: [
{
type: 'selection',
width: 60,
align: 'center'
},
{
title: '商品标题',
key: 'title'
},
{
title: '分类',
key: 'categoryName'
},
{
title: '品牌',
key: 'brandName'
},
{
title: '操作',
key: 'action',
width: 150,
align: 'center',
render: (h, params) => {
return h('div', [
h('Button', {
props: {
type: 'primary',
size: 'small'
},
style: {
marginRight: '5px'
},
on: {
click: () => {
this.edit(params.row.id)
}
}
}, '修改'),
h('Button', {
props: {
type: 'primary',
size: 'small'
},
on: {
click: () => {
this.remove(params.row.id, params.index)
}
}
}, '删除')
])
}
}
]
}
}
}
</script>
2.4.2 添加修改组件
src/view/item/spu/edit.vue
<template>
<div>
<Steps :current="step">
<Step title="基本信息"></Step>
<Step title="商品描述"></Step>
<Step title="规格参数"></Step>
<Step title="SKU属性"></Step>
<Step title="SKU列表"></Step>
</Steps>
<Divider/>
<!--商品基本信息-->
<Form ref="form" :model="spu" :rules="ruleValidate" :label-width="80" v-show="step == 0">
<input type="hidden" v-model="spu.id"/>
<Row>
<Col span="12">
<FormItem label="分类" prop="cid3">
<select-category2 v-model="spu.cid3"></select-category2>
</FormItem>
</Col>
<Col span="12">
<FormItem label="品牌" prop="brandId">
<select-brand v-model="spu.brandId"></select-brand>
</FormItem>
</Col>
</Row>
<FormItem label="商品标题" prop="title">
<Input v-model="spu.title"></Input>
</FormItem>
<FormItem label="商品买点" prop="subTitle">
<Input type="textarea" :rows="3" v-model="spu.subTitle"></Input>
</FormItem>
<FormItem label="包装清单" prop="packingList">
<Input type="textarea" :rows="3" v-model="spu.spuDetail.packingList"></Input>
</FormItem>
<FormItem label="售后服务" prop="afterService">
<Input type="textarea" :rows="3" v-model="spu.spuDetail.afterService"></Input>
</FormItem>
</Form>
<!--商品描述-->
<Editor v-model="spu.spuDetail.description" v-show="step==1"/>
<!--规格参数-->
<Form ref="form2" :model="spu" :label-width="200" v-show="step == 2">
<Row v-for="(value, name, index) in genericSpec" :key="index" style="margin-top: 5px;">
<FormItem :label="name">
<Input v-model="genericSpec[name]"></Input>
</FormItem>
</Row>
</Form>
<!--sku属性-->
<div v-show="step == 3">
<Card v-for="(svalue, skey, sindex) in specialSpec" :key="sindex">
<p slot="title">
{
{skey}}
</p>
<Input v-for="(vvalue, vindex) in svalue" :key="vindex" v-model="specialSpec[skey][vindex]" style="margin-top: 5px; margin-bottom: 5px">
<Icon type="md-remove" slot="suffix" @click="specialSpec[skey].splice(vindex, 1)"/>
</Input>
<Button @click="addSpecialSpec(skey)">添加</Button>
</Card>
</div>
<!--sku列表-->
<div v-show="step == 4">
<Row style="border-bottom: solid #c3c3c3 1px; height: 40px; line-height: 40px;">
<Col v-for="(value, key, index) in specialSpec" :key="index" span="4">{
{key}}</Col>
<Col span="6">价格</Col>
<Col span="6">库存</Col>
</Row>
<Row style="border-bottom: solid #c3c3c3 1px; height: 80px; line-height: 80px" v-for="(sku, index) in spu.skus" :key="index">
<Col v-for="(value, key, index) in JSON.parse(sku.ownSpec)" :key="index" span="4">{
{value}}</Col>
<Col span="6">
<Input v-model="sku.price" style="width:200px"></Input>
</Col>
<Col span="6">
<Input v-model="sku.stock" style="width:200px"></Input>
</Col>
</Row>
<Button type="primary" @click="saveSpu" style="margin-top: 10px;float: right">保存商品信息</Button>
</div>
<Divider/>
<Button type="primary" @click="prev">上一步</Button>
<Divider type="vertical"/>
<Button type="primary" @click="next">下一步</Button>
</div>
</template>
<script>
import instance from '@/libs/api/index'
import Qs from 'qs'
import Editor from '_c/form/Editor.vue'
import selectCategory2 from '_c/select/selectCategory2.vue'
import selectBrand from '_c/select/selectBrand.vue'
import {calcDescartes} from '@/libs/util'
export default {
components: {Editor, selectCategory2, selectBrand},
data() {
return {
step: 0,
spu: {
id: null,
title: '',
subTitle: '',
cid3: null,
brandId: null,
spuDetail: {
packingList: '',
afterService: '',
description: '',
specialSpec: '{}',
genericSpec: '{}'
},
skus: []
}, //spu,格式{title:'',brandId:'', subDetail: {description:'', specialSpec:''...}, skus:[...] ...}
genericSpec: {}, //规格参数,格式:{"品牌": "华为"...} (这个变量,因为spu中的genericSpec为字符串)
specialSpec: {}, //sku属性,格式: {"机身颜色": ["白色","黑色", "金色"], "内存":["16G","326"],机身存储:[]}
ruleValidate: {
cid3: [
{required: true, message: '类别不能为空', trigger: 'change', type: 'number'},
],
brandId: [
{required: true, message: '商品不能为空', trigger: 'change', type: 'number'},
],
title: [
{required: true, message: '商品标题不能为空', trigger: 'blur'},
]
}
}
},
methods: {
//保存规格参数
saveSpu() {
instance.post(`/item/spu/save-spu`, this.spu, {
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
}).then(response => {
this.$Message.success(response.data.msg)
this.$router.push({name: `list_item_spu`})
}).catch(error => {
console.log(error)
})
},
//添加sku属性
addSpecialSpec(key) {
if (!this.specialSpec[key]) {
this.specialSpec[key] = new Array()
}
this.specialSpec[key].push('')
},
next() {
if (this.step == 4) {
this.step = 0;
} else {
this.step += 1;
}
},
prev() {
if (this.step == 0) {
this.step = 4;
} else {
this.step -= 1;
}
},
// 根据ID加载数据
get(id) {
instance.get(`/item/spu/edit/${id}`).then(response => {
this.spu = Object.assign(response.data);
this.genericSpec = JSON.parse(this.spu.spuDetail.genericSpec); //规格参数,计算属性不能双向绑定
this.specialSpec = JSON.parse(this.spu.spuDetail.specialSpec); //sku属性
}).catch(error => {
console.log(error)
})
},
//根据商品分类id查询规格参数
getSpec(cid) {
instance.post(`/item/param/list`, Qs.stringify({"cid": this.spu.cid3})).then(response => {
/**************************规格参数****************************/
let generic = Object.create(null);
//规格参数的key
let genericKeys = response.data.filter((item) => item.generic === true) .map((item) => item.name)
//修改时,取得第一次查询的规格参数,get后的spu中取出响应的属性
genericKeys.forEach(item => {
generic[item] = this.genericSpec[item]
});
this.genericSpec = generic
/**************************sku属性****************************/
let sku = Object.create(null);
//sku属性的key
let skuKeys = response.data.filter((item) => item.generic === false) .map((item) => item.name)
//修改时,取得第一次查询的规格参数,从get后的spu中取出响应的属性
skuKeys.forEach(item => {
sku[item] = this.specialSpec[item]
});
this.specialSpec = sku;
}).catch(error => {
console.log(error)
})
}
},
created() {
let id = this.$route.query.id;
if (id) {
this.get(id)
}
},
computed: {
getCid() {
return this.spu.cid3;
}
},
watch: {
//规格参数改变,改变spu.spDetail.genericSpec
genericSpec: {
handler(newValue, oldValue) {
this.spu.spuDetail.genericSpec = JSON.stringify(newValue)
},
immediate: false, //第一次回调时取消侦听
deep: true //发现对象内部值的变化
},
//sku属性改变时,改变spu.spuDetail.genericSpec和spu.skus
specialSpec: {
handler(newValue, oldValue) {
//修改时,在没有更改sku属性时,不进行sku计算
if (this.spu.spuDetail.specialSpec == JSON.stringify(newValue)) {
return
}
this.spu.spuDetail.specialSpec = JSON.stringify(newValue)
//求sku的笛卡尔积
let values = Object.values(this.specialSpec) //[["白色", 黑色, 玫瑰金], [3G, 8G], undefined],没有添加完整是有可能是undefined
let keys = Object.keys(this.specialSpec)
values = values.filter((value) => value); //去掉undefined
let descartes = calcDescartes(values)
let skus = new Array()
descartes.forEach((ditem, dindex) => {
let sku = {}
let ownSpec = {} //sku的当前sku属性
if (ditem instanceof Array) { //笛卡尔积[["白色", 3G, 16G], [黑色, 3G, 16G]]
ditem.forEach((vitem, vindex) => {
ownSpec[keys[vindex]] = vitem
});
} else { //笛卡尔积["V9", "V10", "V20", ...]
ownSpec[keys[0]] = ditem
}
sku.ownSpec = JSON.stringify(ownSpec)
sku.title = this.spu.title
sku.spuId = this.spu.id
skus.push(sku)
})
this.spu.skus = skus
},
immediate: false,
deep: true
},
//选择分类,改变规格参数
getCid(newValue, oldValue) {
this.getSpec(newValue);
}
}
}
</script>
代码逻辑比较复杂分开讲解下:
2.4.2.1 基本布局
组件分为一个Steps,有5个Step,每一个Step对应一个组件(基本信息组件,商品描述,规格参数,SKU属性,SKU列表)
2.4.2.2 商品描述
<!--商品描述-->
<Editor v-model="spu.spuDetail.description" v-show="step==1"/>
商品描述采用quill-editor富文本编辑器,这里进行了自定义扩展,扩展富文本编辑器的图片上传等功能
src/components/form/Editor.vue:
<template>
<div>
<!-- iview图片上传-->
<Upload
class="avatar-uploader"
name="file"
:action="serverUrl"
:headers="header"
:on-success="uploadSuccess"
:on-error="uploadError"
:on-format-error="handleFormatError"
:before-upload="beforeUpload"
multiple
:show-upload-list="false"
:format="['jpg','jpeg','png']"
:max-size="maxSize"
>
</Upload>
<quill-editor
class="editor"
v-model="content"
ref="myQuillEditor"
:options="editorOption"
@blur="onEditorBlur($event)" @focus="onEditorFocus($event)"
@change="onEditorChange($event)">
</quill-editor>
</div>
</template>
<script>
// 工具栏配置
const toolbarOptions = [
["bold", "italic", "underline", "strike"], // 加粗 斜体 下划线 删除线
["blockquote", "code-block"], // 引用 代码块
[{ header: 1 }, { header: 2 }], // 1、2 级标题
[{ list: "ordered" }, { list: "bullet" }], // 有序、无序列表
[{ script: "sub" }, { script: "super" }], // 上标/下标
[{ indent: "-1" }, { indent: "+1" }], // 缩进
// [{'direction': 'rtl'}], // 文本方向
[{ size: ["small", false, "large", "huge"] }], // 字体大小
[{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
[{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
[{ font: [] }], // 字体种类
[{ align: [] }], // 对齐方式
["clean"], // 清除文本格式
["link", "image", "video"] // 链接、图片、视频
];
import { quillEditor } from "vue-quill-editor";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";
import "quill/dist/quill.bubble.css";
export default {
props: {
/*编辑器的内容*/
value: {
type: String
},
/*图片大小*/
maxSize: {
type: Number,
default: 4000 //kb
}
},
components: {
quillEditor
},
data() {
return {
content: this.value,
quillUpdateImg: false, // 根据图片上传状态来确定是否显示loading动画,刚开始是false,不显示
editorOption: {
placeholder: "请输入内容",//输入框提示文字
theme: "snow", // or 'bubble'
modules: {
toolbar: {
container: toolbarOptions,
// container: "#toolbar",
handlers: {
image: function(value) {
if (value) {
// 触发input框选择图片文件
document.querySelector(".avatar-uploader input").click();
} else {
this.quill.format("image", false);
}
},
// link: function(value) {
// if (value) {
// var href = prompt('请输入url');
// this.quill.format("link", href);
// } else {
// this.quill.format("link", false);
// }
// },
}
}
}
},
serverUrl: 'http://localhost:9004/uploadFile', // 这里写你要上传的图片服务器地址
header: { //请求头,请按个人接口设置
//Accept: 'application/json',
//token: `token`,
}
};
},
watch: {
// Watch content change
value(newVal) {
this.content = newVal
},
},
methods: {
onEditorBlur() {
//失去焦点事件
},
onEditorFocus() {
//获得焦点事件
},
onEditorChange() {
//内容改变事件
this.$emit("input", this.content);
},
// 富文本图片上传前
beforeUpload() {
// 显示loading动画
this.quillUpdateImg = true;
},
uploadSuccess(res, file) {
// res为图片服务器返回的数据
// 获取富文本组件实例
let quill = this.$refs.myQuillEditor.quill;
// 如果上传成功
// if (res.errorCode === 0) {
// // 获取光标所在位置
// let length = quill.getSelection().index;
// // 插入图片 res.url为服务器返回的图片地址
// console.log(res.result.url)
// quill.insertEmbed(length, "image", res.result.url);
// // 调整光标到最后
// quill.setSelection(length + 1);
// } else {
// this.$Message.error("图片插入失败");
// }
if (res) {
// 获取光标所在位置
let length = quill.getSelection().index;
// 插入图片 res.url为服务器返回的图片地址
quill.insertEmbed(length, "image", `http://192.168.220.110:8080/${res}`);
// 调整光标到最后
quill.setSelection(length + 1);
} else {
this.$Message.error("图片插入失败");
}
// loading动画消失
this.quillUpdateImg = false;
},
// 富文本图片上传失败
uploadError() {
// loading动画消失
this.quillUpdateImg = false;
this.$Message.error("图片插入失败");
},
handleFormatError() {
this.$Message.error('上传文件格式不正确');
},
handleMaxSize() {
this.$Message.error('上传文件过大');
},
}
};
</script>
<style>
.avatar-uploader {
display: none;
}
.editor {
line-height: normal !important;
}
.editor .ql-container {
height: 350px; /*设置输入框高度*/
}
.ql-editor.ql-blank::before {
/* content: '请输入内容' !important; */
font-style: normal;
color: rgba(0,0,0,0.3);
}
.ql-snow .ql-tooltip[data-mode=link]::before {
content: "请输入链接地址:";
}
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
border-right: 0px;
content: '保存';
padding-right: 0px;
}
.ql-snow .ql-tooltip[data-mode=video]::before {
content: "请输入视频地址:";
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: '14px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
content: '10px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
content: '18px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
content: '32px';
}
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
content: '文本';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
content: '标题1';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
content: '标题2';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
content: '标题3';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
content: '标题4';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
content: '标题5';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
content: '标题6';
}
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
content: '标准字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
content: '衬线字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
content: '等宽字体';
}
</style>
图片上传服务器
serverUrl: 'http://localhost:9004/uploadFile', // 这里写你要上传的图片服务器地址
图片回显
quill.insertEmbed(length, "image", `http://192.168.220.110:8080/${res}`);
2.4.3 通用规格
2.4.4 SKU特有规格
2.4.5 SKU列表
2.4.6 加载数据
created() {
let id = this.$route.query.id;
if (id) { //id不等于null加载数据
this.get(id)
}
},
// 根据ID加载数据
get(id) {
instance.get(`/item/spu/edit/${id}`).then(response => {
this.spu = Object.assign(response.data);
this.genericSpec = JSON.parse(this.spu.spuDetail.genericSpec); //规格参数计算属性不能双向绑定
this.specialSpec = JSON.parse(this.spu.spuDetail.specialSpec); //sku属性
}).catch(error => {
console.log(error)
})
},
2.4.7 规格参数处理
逻辑
三级分类数据显示
spu.cid3的计算属性
计算属性的监听器
根据cid3的变化查询对应的规格的方法
2.4.8 计算sku列表
这里我们在specialSpec监听其中,监听其变化,如果spu特有规格变化,使用笛卡尔积算法得到相应的sku列表,举例如下
如果sku特有规格
specialSpec = {"机身颜色:["白色", "黑色"], 内存:["3G","16G"]}
specialSpec.values= [["白色", "黑色"], ["3G","16G"]]
得到的sku列表结果为4个sku如下:
[
["白色", "3G"],
["白色", "16G"],
["黑色", "3G"],
["黑色", "16G"]
]
具体代码如下
//sku属性改变时,改变spu.spuDetail.genericSpec和spu.skus
specialSpec: {
handler(newValue, oldValue) {
//修改时,在没有更改sku属性时,不进行sku计算
if (this.spu.spuDetail.specialSpec == JSON.stringify(newValue)) {
return
}
this.spu.spuDetail.specialSpec = JSON.stringify(newValue)
//求sku的笛卡尔积
let values = Object.values(this.specialSpec) //[["白色", 黑色], [3G, 8G], undefined],没有添加完整是有可能是undefined
let keys = Object.keys(this.specialSpec)
values = values.filter((value) => value); //去掉undefined
let descartes = calcDescartes(values) //笛卡尔积[["白色", 3G], [黑色, 3G],["白色",8G],["黑色", 8G]]
let skus = new Array()
descartes.forEach((ditem, dindex) => {
let sku = {}
let ownSpec = {} //sku的当前sku属性
if (ditem instanceof Array) {
ditem.forEach((vitem, vindex) => {
ownSpec[keys[vindex]] = vitem
});
} else { //笛卡尔积["V9", "V10", "V20", ...]
ownSpec[keys[0]] = ditem
}
sku.ownSpec = JSON.stringify(ownSpec)
sku.title = this.spu.title
sku.spuId = this.spu.id
skus.push(sku)
})
this.spu.skus = skus
},
immediate: false,
deep: true
},
2.4.9 商品保存
上述数据都处理好后,我们提交spu对象给后端商品微服务,后端商品微服务使用@RequestBody接收spu进行保存
后端代码
前端保存代码
提交的spu数据结构