效果图如下:
完成sku的规格选择,首先要获取数据,然后一个个去对应,排列出商品的规格名、规格值。
但仔细观察了API,发现没有获取某个spu具有哪些规格名、规格值的API,只有总的获取spu详细数据的API。
spu详细数据的格式如下:
{
"id":2,
....
"sku_list":[
{
"id":2,
"price":77.76,
"discount_price":null,
"online":true,
"img":"",
"title":"金属灰·七龙珠",
"spu_id":2,
"category_id":17,
"root_category_id":3,
"specs":[
{
"key_id":1,
"key":"颜色",
"value_id":45,
"value":"金属灰"
},
{
"key_id":3,
"key":"图案",
"value_id":9,
"value":"七龙珠"
},
{
"key_id":4,
"key":"尺码",
"value_id":14,
"value":"小号 S"
}
],
"code":"2$1-45#3-9#4-14",
"stock":5
},
{
"id":3,
"price":66,
"discount_price":59,
"online":true,
"img":"",
"title":"青芒色·灌篮高手",
"spu_id":2,
"category_id":17,
"root_category_id":3,
"specs":[
{
"key_id":1,
"key":"颜色",
"value_id":42,
"value":"青芒色"
},
{
"key_id":3,
"key":"图案",
"value_id":10,
"value":"灌篮高手"
},
{
"key_id":4,
"key":"尺码",
"value_id":15,
"value":"中号 M"
}
],
"code":"2$1-42#3-10#4-15",
"stock":999
},
....
],
.....
"sketch_spec_id":1,
"default_sku_id":2
}
调用接口获取spu的详细数据:
class Spu {
static async getSpuDetail(id) {
return Http.request({
url: `spu/id/${id}/detail`
})
}
}
必须通过这种格式的详细数据,获取对应spu的规格名、规格值。
规格名的获取思路是,既然每个sku的规格名都一样,那取第一个sku,获取其中的规格名,将其放在spec_key_list中;
规格值的获取思路是,遍历sku_list,将每个sku的规格值都放在spec_val_list中,由于多个sku会有相同的规格值,需要进行去重。
为了取数方便,规格值存放在二维数组中,spec_val_list[i]表示spec_key_list[i]的规格名对应有哪些规格值。
主要的代码如下(直接写在新建detail页面的onLoad函数中):
const spuDetail = await Spu.getSpuDetail(spuId)
const sku_list = spuDetail.sku_list
let spec_key_list = []
let spec_val_list = []
// 取其中一个sku的规格,它的规格名就是整个spu的规格名
// 在取规格名的同时,为规格值的数组开空间
for(let spec of sku_list[0].specs) {
spec_key_list.push(spec.key)
spec_val_list.push([])
}
// sku_list[i]表示单个sku
for(let i = 0; i < sku_list.length; i ++) {
// 遍历每个sku的规格
for(let j = 0; j < sku_list[i].specs.length; j ++) {
let spec = sku_list[i].specs[j]
// specs[j]和spec_val_list[j]对应,是一个规格名下的规格值
if(!spec_val_list[j].includes(spec.value)) {
spec_val_list[j].push(spec.value)
}
}
}
this.setData({
spec_key_list,
spec_val_list,
sku_list
})
页面显示就简单了,直接用wx:for循环即可:
<view class="container">
<block wx:for="{{spec_key_list}}" wx:for-index="kIndex">
<text>{{item}}</text>
<view class="inner-container">
<block wx:for="{{spec_val_list[kIndex]}}" wx:for-index="vIndex">
<l-button>{{item}}</l-button>
</block>
</view>
</block>
</view>
这里为了后续点击规格值的处理方便,将规格名的索引设定为kIndex,规格值索引设定为vIndex。
此外运用了LinUI的button,其中的plain属性能很好的模拟出已选中(为false)、未选中(为true)的状态,disabled属性模拟出禁用(为true)状态。
那接下来的问题就是如何根据用户的点击,改变规格值按钮的状态了。
首先需要一个二维数组spec_status来存储每个规格值的状态值,spec_status[i][j]表示第i行第j列的规格值的状态。此处的i就是规格名的索引也就是kIndex,j是规格值的索引也就是vIndex。另外,为了之后能够具体准确地获取到当前规格值,除了状态之外,还需要记录规格值id。
具体代码如下(在获取spec_val_list和spec_key_list代码的基础上添加):
const spec_status = []
...
// 在遍历规格名的同时,为spec_status开空间
// spec_status[i]代表spec_key_list[i]的规格名对应规格值的选中状态和id
for(let spec of sku_list[0].specs) {
...
spec_status.push([])
}
for(let i = 0; i < sku_list.length; i ++) {
// 遍历每个sku的规格
for(let j = 0; j < sku_list[i].specs.length; j ++) {
let spec = sku_list[i].specs[j]
// 发现首次出现的规格值,存入它的状态和id
if(!spec_val_list[j].includes(spec.value)) {
...
spec_status[j].push({
// 默认起初都是未选中
status: 0,
value_id: spec.value_id
})
}
}
}
this.setData({
spec_status,
...
})
新建一个spec.wxs,把自定义的状态值绑定到前端显示(对应plain、disabled属性),规定0为未选中状态,1为选中状态,-1为禁用状态,代码如下:
function specStatus(status) {
if(status === -1) {
return {
disabled: true,
plain: false
}
}
var ifPlain = true
if(status === 1) {
ifPlain = false
}
return {
disabled: false,
plain: ifPlain
}
}
module.exports = {
specStatus: specStatus
}
在wxml中可以引入wxs并调用该函数,需要在每个规格值按钮上监听点击事件,并且需要给监听函数传递kIndex、vIndex:
<wxs class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="/wxs/spec.wxs" module="sp"></wxs>
...
<l-button data-kid="{{kIndex}}" data-vid="{{vIndex}}"
bindtap="clickSpec" class="spec-val"
plain="{{sp.specStatus(spec_status[kIndex][vIndex].status).plain}}"
disabled="{{sp.specStatus(spec_status[kIndex][vIndex].status).disabled}}">
{{item}}
</l-button>
接下来开始编写点击的监听函数clickSpec,先做最基础的,点击后常规的状态切换,点击未选中/已选中状态切换为已选中/未选中状态。此外如果点击未选中的规格值时,该规格名下若已有其他已选中的规格值,需要将其转换为未选中状态,具体代码如下:
clickSpec(event) {
const kid = event.currentTarget.dataset.kid
const vid = event.currentTarget.dataset.vid
// 获取点击的规格值的当前状态
const status = this.data.spec_status[kid][vid].status
// 如果是禁用状态,不做任何处理
if(status === -1) {
return
} else {
// 其他状态,进行相互切换
this.data.spec_status[kid][vid].status = 1 - status
// 如果当前状态(切换前)为未选中状态,将当前规格名的其他规格值(非禁用状态)切换为未选中状态
if(status === 0) {
for(let i = 0; i < this.data.spec_status[kid].length; i ++) {
if(i !== vid && this.data.spec_status[kid][i].status != -1) {
this.data.spec_status[kid][i].status = 0
}
}
}
// 检查组合是否存在的函数
// this.checkComb(kid)
}
this.setData({
spec_status: this.data.spec_status
})
}
接下来是最关键的一步,也就是根据库中存在的组合,与当前所选的规格值对比,将所有不存在组合的规格值给禁用。这块逻辑比较复杂,打算将其封装成一个独立的函数checkComb,在clickSpec中进行调用。
先分享一下之前踩了坑的错误思路,之前想的是先获取所有被选中的规格值,再去依次遍历所有未被选中的规格名的规格值。遍历时依次取当前规格值和已选中的规格值组合,再对比存在的所有组合,如果没有这个组合,就禁用。
但这个思路有个错误的地方在于,哪怕规格名有规格值被选中,依然可以切换选中的规格值。那这个逻辑就无法判断当前已选中规格值的规格名中,有哪些规格值需要禁用,只能判断未选中的规格名中,哪些规格值需要禁用。
整个思路需要调整一下,不应该关注于是否已被选中,反复操作了线上demo后发现,当前点击的规格值所在的规格名,其实永远都不会被切换成禁用状态,禁用的是非本次点击的规格值所在的规格名。
也就是说,关键在于当前点击的规格名到底是什么,用当前点击的被选中的规格值和其他规格名的规格值依次组合(无论此次点击是选中还是未选中,这个逻辑都通用),再配合上已选中的其他规格值,查看是否包含在库中已有的组合中,如果不存在就禁用该规格值。
听起来有些绕,拿具体的例子来说,假设有以下规格:
颜色:金属灰、青芒色、橘黄色
图案:七龙珠、圣斗士、灌篮高手
尺码:大 号、中 号、小 号
存在的组合有:金属灰-七龙珠-小号、青芒色-灌篮高手-中号、青芒色-圣斗士-大号、橘黄色-七龙珠-小号
假如第一次点击青芒色,图案中能和已选中的青芒色,形成存在组合的有青芒色-灌篮高手、青芒色-圣斗士,因此七龙珠处于禁用状态;尺码中,和青芒色形成存在组合的,有青芒色-大号、青芒色-中号,小号禁用。
第二次点击圣斗士,已选中的组合是青芒色-圣斗士,因为点击图案可能会令颜色规格值的状态发生变化,因此依然需要遍历颜色,此时将青芒色改为待定,选中组合视为只有圣斗士,发现只有青芒色-圣斗士是存在的组合,金属灰、橘黄色禁用;遍历尺码,尺码未被选中,那已选中的组合就是正常的青芒色-圣斗士了,能形成组合的只有青芒色-圣斗士-大号,小号、中号禁用。
最后第三次点击大号,遍历颜色,能和圣斗士-大号(青芒色改为待定)形成组合的只有青芒色-圣斗士-大号,将七龙珠、灌篮高手禁用;遍历图案,能和青芒色-大号(圣斗士改为待定)形成组合的只有青芒色-圣斗士-大号,将小号、中号禁用。
注意,判断某个组合是否存在,是去判断这个组合是否是已存在组合的子集,而不是指两个组合完全一样,需要先写一个判断某个数组是否是另一个数组子集的函数:
_isSubset(subArr, arr) {
// 去掉undefined元素
let arrC = []
for (const item of subArr) {
if(item) {
arrC.push(item)
}
}
return arrC.every(i => arr.includes(i))
}
另外,还要一个存有所有sku规格组合的all_comb数组,这是一个二维数组,其中每个元素是存有每个sku规格值id组合的数组。为了便于确定当前规格组合是哪个sku,除了存储规格值组合数组之外,还需要存储该组合对应的skuID。即all_comb[i].id为第i个sku的ID,all_comb[i].specs为第i个sku的规格值id组合数组:
const spuId = options.id
const spuDetail = await Spu.getSpuDetail(spuId)
const sku_list = spuDetail.sku_list
const all_comb = []
...
for(let i = 0; i < sku_list.length; i ++) {
all_comb.push({
id: 0,
specs: []
})
// 遍历每个sku的规格
for(let j = 0; j < sku_list[i].specs.length; j ++) {
let spec = sku_list[i].specs[j]
all_comb[i].id = sku_list[i].id
all_comb[i].specs.push(spec.value_id)
...
}
}
this.setData({
all_comb,
...
})
整体的思路都有了,还剩下一个细节,就是反选的问题,把已选中的规格值取消后,又该如何变化呢?
其实是一样的思路,依然是不用变更此次点击的规格值所在规格名的状态,比如在选中青芒色-圣斗士-大号后,
反选点击青芒色,颜色的规格值状态是不会发生任何改变的,该禁用的还是禁用,还是应该去遍历其他的规格名。
拿青芒色-圣斗士-大号全选中状态举例来说,反选青芒色后,已选中的组合是圣斗士-大号,遍历图案时就只剩大号了(图案和先前一样处于待定状态),能和大号形成组合的只有圣斗士,其余仍处于禁用状态;遍历尺码只剩圣斗士(尺码处于待定状态),能和圣斗士形成组合的也只有大号,其余禁用。
反选圣斗士后,已选中的只有大号,遍历颜色时和大号能形成组合的只有青芒色;遍历尺码时,尺码变成了待定,已选中的组合就是空的,小号、中号、大号都有存在的组合,因此将小号、中号的禁用状态解除。
最后反选大号,被选中的组合为空,所有被禁用的格子都被放开了。
不难发现其实反选和正选的逻辑几乎是完全一样的,只不过正选是将不能形成组合的给禁用,反选是将可以形成组合的给解除禁用状态。
理清楚这些,写出checkComb的整体代码就没那么困难了:
// 传入当前点击的规格名id
checkComb(kid) {
// 获取当前已选中的规格值
let selectedV = []
for (let i = 0; i < this.data.spec_status.length; i++) {
for (let j = 0; j < this.data.spec_status[i].length; j++) {
if(this.data.spec_status[i][j].status === 1) {
selectedV[i] = this.data.spec_status[i][j].value_id
}
}
}
// 哪怕规格值均未选中,也需要继续判断,需要复原被禁用的规格值
// 遍历规格名,组合起来,与all_comb对比,不存在的组合的规格值禁用
for (let i = 0; i < this.data.spec_status.length; i++) {
// 筛选出非本次点击的规格名
if(i !== kid) {
for (let j = 0; j < this.data.spec_status[i].length; j++) {
// 存一份被选中的规格值,用于后续的复原
const oV = selectedV[i]
// 哪怕当前规格也被选中了,那也依次替换进行组合
selectedV[i] = this.data.spec_status[i][j].value_id
// 默认不是一个组合
let isComb = false
for (let comb of this.data.all_comb) {
// 如果组合是一个子集,那就是一个组合
if(this._isSubset(selectedV, comb.specs)) {
isComb = true
// 如果被禁用了,复原
if(this.data.spec_status[i][j].status === -1) {
this.data.spec_status[i][j].status = 0
}
break;
}
}
// 如果不是一个组合,那么禁用该规格值
if(!isComb) {
this.data.spec_status[i][j].status = -1
}
// 复原selectedV数组
selectedV[i] = oV
}
}
}
this.setData({
spec_status: this.data.spec_status
})
}
最困难最关键的一步已经完成,后续只剩一些显示上的优化了。
添加功能:所有规格名都被选中规格值后,显示当前sku的信息。
之前说过,通过all_comb可以获得当前规格组合的skuId,又能通过sku_list获取到sku中的详细信息,那思路就简单了:一旦判断出规格值全被选中,判断该规格值是all_comb中哪一个组合的子集,获取该组合的skuId,再通过skuId获取对应的sku,并绑定数据。
单独封装一个函数,接收参数为sku的id,将该id对应的sku信息进行数据绑定:
setSku(skuId) {
for (const sku of this.data.sku_list) {
if(sku.id === skuId) {
this.setData({
sku
}) }}
}
在checkComb中添加以下内容,进行sku的绑定:
checkComb(kid) {
// 获取当前已选中的规格值
let selectedV = []
// 计算当前已选了多少个规格值
let specCount = 0
for (let i = 0; i < this.data.spec_status.length; i++) {
for (let j = 0; j < this.data.spec_status[i].length; j++) {
if(this.data.spec_status[i][j].status === 1) {
selectedV[i] = this.data.spec_status[i][j].value_id
specCount++
}
}
}
// 如果规格均选中了就可以确定sku了
if(specCount === this.data.spec_status.length) {
for (let comb of this.data.all_comb) {
// 如果规格均选中,又是一个组合,那该组合就是所选的规格
if(this._isSubset(selectedV, comb.specs)) {
this.setSku(comb.id)
}
}
}
...
}
然后在wxml中绑定即可:
<image class="img" class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="{{sku.img}}"></image>
<text class="title">{{sku.title}}</text>
...
最后添加功能:有默认sku的spu,在进入规格选择页面时选定默认的sku。思路也很简单,判断当前spu中是否存在default_sku_id,如果有,使用该default_sku_id进行sku绑定。
有些细节需要注意,选定默认sku时,该sku的规格值按钮状态需要改为已选中,且无法和已选中规格值组合的规格值按钮需处于禁用状态。
更改状态只需在初始化时判断该sku是否为默认的sku,如果是,则将其规格值的状态设定为已选中状态。禁用规格只需依次调用checkComb,模拟点击的过程即可:
for(let i = 0; i < sku_list.length; i ++) {
for(let j = 0; j < sku_list[i].specs.length; j ++) {
...
if(!spec_val_list[j].includes(spec.value)) {
let status
if(sku_list[i].id === spuDetail.default_sku_id) {
status = 1
} else {
status = 0
}
spec_status[j].push({
status,
value_id: spec.value_id
})
}
}
}
....
// 如果有默认sku,绑定默认sku(需要在绑定sku_list后绑定)
// 这里甚至都不需要手动绑定sku,在checkComb中会自己绑定
if(spuDetail.default_sku_id) {
for (let i = 0; i < spec_key_list.length; i++) {
this.checkComb(i)
}
}
暂时完毕,后续看课程进行对比。