主题
完成添加SKU的业务(Day7 2/2)
本页对应 day7,共 4 段视频,约 1 小时 57 分。
学习目标
- 能基于父组件传入的三级分类 ID、SPU ID、TM ID,并结合表单
v-model,完成新增 SKU 基础参数的收集。 - 能收集平台属性与销售属性的属性 ID、属性值 ID,并使用
reduce将参数整理为接口要求的数组对象格式。 - 能处理 SKU 图片默认选中与排他切换:补充
isDefault标记、通过表格实例与nextTick勾选首行、同步默认图片地址。 - 能完成新增 SKU 请求:提交整理后的参数,处理成功/失败提示,成功后通知父组件切换场景并重置表单数据。
- 能完成 SKU 列表查看:按 SPU ID 请求数据,使用弹窗表格渲染名称、价格、重量、图片,并通过
loading与清空旧数据优化交互。 - 能完成已有 SPU 删除:携带 SPU ID 发起删除请求,处理成功/失败提示,并在删除成功后重新拉取 SPU 列表。
知识点
静态结构与数据渲染
为什么需要:添加 SKU 的页面先要有稳定的静态结构,再把接口数据、用户输入和选择状态挂上去。否则表单看似能填,最后却收集不到参数。
怎么想:静态负责“长什么样”,数据渲染负责“值从哪里来、改到哪里去”。SKU 表单中的名称、价格、重量、描述用双向绑定;平台属性、销售属性、图片墙属于动态数据,要按数组渲染。
怎么做:先搭好添加 SKU 场景的表单结构,再定义 SKUParams 作为收集容器,把输入框与它绑定。图片墙、属性列表用数据循环渲染。
易错点:只写死静态结构,忘记 v-model;直接操作 DOM 收集值;字段名大小写与请求体不一致。
三个父级参数先接住
为什么需要:请求体里 category3Id、spuId、tmId 不是用户临时输入的,而是父组件点击“添加 SKU”时传进来的。
怎么想:这是“已有 SPU 下新增 SKU”,三级分类、所属 SPU、品牌都已知,子组件直接接收即可,不需要再让用户选。
怎么做:在子组件中拿到父组件传入的 SPU 数据,赋值给收集对象:
js
SKUParams.category3Id = SPU.category3Id
SKUParams.spuId = SPU.id
SKUParams.tmId = SPU.tmId易错点:误以为这三个要重新请求;把 spuId 写成 id;父组件传了但子组件没接。
基础字段用 v-model 收集
为什么需要:skuName、price、weight、skuDesc 是用户直接填写的,必须实时进入收集对象。
怎么想:这类字段最简单,表单控件和 SKUParams 对应字段双向绑定即可。
怎么做:名称绑 skuName,价格绑 price,重量绑 weight,描述绑 skuDesc。调试时打开开发者工具,修改输入后观察 SKUParams 是否同步变化。
易错点:绑错层级,例如漏了 SKUParams;价格、重量当字符串处理时没有注意接口要求;描述字段名写成 desc。
平台属性:先挂到属性对象,再统一整理
为什么需要:平台属性数量不固定,不能为每个属性单独定义变量。最终请求体却要求 skuAttrValueList 是 { attrId, valueId } 数组。
怎么想:选中某个属性值时,先把“属性 ID 和值 ID”临时挂到当前属性对象上;保存时再遍历所有属性,把有值的切出来整理成数组。
怎么做:用动态字段承接选中值,例如形如 attrId:valueId 的字符串;保存时用 reduce 整理:
js
const arr1 = attrs.reduce((prev, next) => {
if (next.attrIdAndValueId) {
const [attrId, valueId] = next.attrIdAndValueId.split(':')
prev.push({ attrId, valueId })
}
return prev
}, [])易错点:reduce 忘记 return prev;没有选中的属性也强行切分;字段名不是请求体要求的 attrId、valueId;先清空参数再收集导致白收集。
销售属性:套路相同,字段名要看清
为什么需要:销售属性最终要进入 skuSaleAttrValueList,格式是 { saleAttrId, saleAttrValueId }。
怎么想:和平台属性一样,先临时收集,保存时再整理。但销售属性数据源里的 ID 字段常是 baseSaleAttrId,请求体字段是 saleAttrId,不能混。
怎么做:同样用动态字段承接选中值,保存时 split(':') 切出两个 ID,push 到累加数组:
js
const arr2 = saleAttrs.reduce((prev, next) => {
if (next.baseSaleAttrIdAndValueId) {
const [saleAttrId, saleAttrValueId] = next.baseSaleAttrIdAndValueId.split(':')
prev.push({ saleAttrId, saleAttrValueId })
}
return prev
}, [])易错点:把 baseSaleAttrId 直接当请求体字段;销售属性字段拼错导致收集不到;reduce 初始值不是数组。
图片墙排他、默认图和默认地址
为什么需要:SKU 必须有一个默认图片,图片墙中只能有一张处于“默认/选中”状态。
怎么想:这是排他思想:先把所有图片设为非默认,再把当前图片设为默认;第一张图初始化时作为默认图。表格复选框还要通过表格实例方法勾选第一行。
怎么做:数据回来后遍历图片,全部设 isDefault = false,有图片时把第 0 项设为 true;用 nextTick 等渲染完成后再调用 toggleRowSelection(images[0], true)。点击某张图时,先清掉其他默认状态,再设置当前图,并把它的地址收集到 skuDefaultImg。
易错点:数据刚回来就操作表格,DOM 还没渲染,勾不上;点击时忘记取消其他勾选;只设置样式没收集 skuDefaultImg;图片数组为空时直接取第 0 项。
整理参数并发请求
为什么需要:页面收集到的是临时格式,接口要的是整理后的数组和字段。
怎么想:保存时先整理 skuAttrValueList、skuSaleAttrValueList,再赋给 SKUParams,最后发请求。
请求体字段如下:
json
{
"category3Id":"",//三级分类的ID
"spuId":"",//SPU的ID
"tmId":"",//SPU归属于哪一个品牌
"skuName": "",//SKU名字
"price":"",//SKU价格
"weight": "",//SKU重量
"skuDesc": "",//SKU商品的描述
"skuDefaultImg": "", //默认图片的地址
"skuAttrValueList": [ //收集平台属性的数据
{
"attrId":"",//平台属性的ID
"valueId":"",//平台属性值的ID
}
],
"skuSaleAttrValueList": [//收集销售属性
{
"saleAttrId":"",//销售属性的ID
"saleAttrValueId":"",//属性值的ID
}
],
}怎么做:把整理好的 arr1、arr2 分别赋给 SKUParams.skuAttrValueList、SKUParams.skuSaleAttrValueList,调用添加 SKU 接口。成功后提示并通知父组件切换场景;失败也要提示。
易错点:直接提交临时字符串;忘记给 skuDefaultImg 赋值;保存前没有清空上一次参数;请求成功后忘记通知父组件。
课堂画面




代码示例
1. 收集新增 SKU 参数:平台属性、销售属性、默认图片
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>收集新增SKU参数</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
</head>
<body>
<div id="app">
<el-form :model="skuParams" label-width="100px" style="width: 760px">
<el-form-item label="SKU名称">
<el-input v-model="skuParams.skuName" />
</el-form-item>
<el-form-item label="价格">
<el-input v-model="skuParams.price" />
</el-form-item>
<el-form-item label="重量">
<el-input v-model="skuParams.weight" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="skuParams.skuDesc" />
</el-form-item>
<el-form-item label="平台属性">
<div
v-for="attr in attrList"
:key="attr.id"
style="display:flex;align-items:center;gap:8px;margin-bottom:8px"
>
<span style="width:100px">{{ attr.attrName }}</span>
<el-select
v-model="attr.attrIdAndValueId"
placeholder="请选择"
style="width:220px"
>
<el-option
v-for="val in attr.attrValueList"
:key="val.id"
:label="val.valueName"
:value="attr.id + ':' + val.id"
/>
</el-select>
</div>
</el-form-item>
<el-form-item label="销售属性">
<div
v-for="saleAttr in saleAttrList"
:key="saleAttr.id"
style="display:flex;align-items:center;gap:8px;margin-bottom:8px"
>
<span style="width:100px">{{ saleAttr.name }}</span>
<el-select
v-model="saleAttr.attrIdAndValueId"
placeholder="请选择"
style="width:220px"
>
<el-option
v-for="val in saleAttr.attrValueList"
:key="val.id"
:label="val.valueName"
:value="saleAttr.id + ':' + val.id"
/>
</el-select>
</div>
</el-form-item>
<el-form-item label="图片列表">
<el-table ref="tableRef" :data="imageList" border style="width:100%">
<el-table-column type="selection" width="55" />
<el-table-column label="图片">
<template #default="{ row }">
<img
:src="row.imageUrl"
style="width:80px;height:80px;object-fit:cover"
/>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" size="small" @click="setDefault(row)">
设为默认
</el-button>
<span v-if="row.isDefault" style="color:#409eff;margin-left:8px">
默认图片
</span>
<span v-else style="color:#999;margin-left:8px">设置</span>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-form>
<pre>{{ JSON.stringify(skuParams, null, 2) }}</pre>
</div>
<script>
const { createApp, reactive, ref, nextTick, onMounted } = Vue;
createApp({
setup() {
const skuParams = reactive({
category3Id: "61",
spuId: "6000",
tmId: "1",
skuName: "",
price: "",
weight: "",
skuDesc: "",
skuDefaultImg: "",
skuAttrValueList: [],
skuSaleAttrValueList: []
});
const attrList = reactive([
{
id: 1,
attrName: "手机系统",
attrValueList: [
{ id: 11, valueName: "Android" },
{ id: 12, valueName: "iOS" }
],
attrIdAndValueId: ""
},
{
id: 2,
attrName: "屏幕尺寸",
attrValueList: [
{ id: 21, valueName: "6.1" },
{ id: 22, valueName: "6.7" }
],
attrIdAndValueId: ""
}
]);
const saleAttrList = reactive([
{
id: 1,
name: "颜色",
attrValueList: [
{ id: 101, valueName: "黑色" },
{ id: 102, valueName: "白色" }
],
attrIdAndValueId: ""
},
{
id: 2,
name: "版本",
attrValueList: [
{ id: 201, valueName: "电信" },
{ id: 202, valueName: "联通" }
],
attrIdAndValueId: ""
}
]);
const imageList = reactive([
{
id: 1,
imageUrl: "https://via.placeholder.com/100?text=1",
isDefault: true
},
{
id: 2,
imageUrl: "https://via.placeholder.com/100?text=2",
isDefault: false
},
{
id: 3,
imageUrl: "https://via.placeholder.com/100?text=3",
isDefault: false
}
]);
const tableRef = ref();
// 默认第一张图片作为默认图片
skuParams.skuDefaultImg = imageList[0]?.imageUrl || "";
const setDefault = (row) => {
imageList.forEach((item) => {
item.isDefault = false;
});
row.isDefault = true;
skuParams.skuDefaultImg = row.imageUrl;
tableRef.value?.clearSelection();
tableRef.value?.toggleRowSelection(row, true);
};
onMounted(() => {
nextTick(() => {
if (imageList.length) {
tableRef.value?.toggleRowSelection(imageList[0], true);
}
});
});
return {
skuParams,
attrList,
saleAttrList,
imageList,
tableRef,
setDefault
};
}
})
.use(ElementPlus)
.mount("#app");
</script>
</body>
</html>要点:
category3Id、spuId、tmId来自父组件传入的 SPU 参数,示例中直接放在skuParams中。- 平台属性、销售属性通过
v-model收集成"attrId:valueId"字符串,后续保存时再切分整理。 - 默认图片使用排他思想:先把所有
isDefault置为false,再把当前行置为true,同时收集skuDefaultImg。 el-table的toggleRowSelection要等表格渲染完成,转写稿中使用nextTick,否则可能拿不到复选框。- 易错点:只改
isDefault不改表格勾选状态,会出现“文字变了但复选框没勾上”;初始默认图片也要同步收集地址。
2. 保存时用 reduce 整理平台属性、销售属性并发请求
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>保存SKU参数整理</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
</head>
<body>
<div id="app" style="width: 760px">
<el-button type="primary" @click="save">保存</el-button>
<pre>{{ result }}</pre>
</div>
<script>
const { createApp, reactive, ref } = Vue;
createApp({
setup() {
const skuParams = reactive({
category3Id: "61",
spuId: "6000",
tmId: "1",
skuName: "S11",
price: "12345",
weight: "11",
skuDesc: "YYDS",
skuDefaultImg: "https://via.placeholder.com/100?text=1",
skuAttrValueList: [],
skuSaleAttrValueList: []
});
const attrList = reactive([
{ id: 1, attrName: "手机系统", attrIdAndValueId: "1:11" },
{ id: 2, attrName: "屏幕尺寸", attrIdAndValueId: "2:21" },
{ id: 3, attrName: "CPU", attrIdAndValueId: "" }
]);
const saleAttrList = reactive([
{ id: 1, name: "颜色", attrIdAndValueId: "1:101" },
{ id: 2, name: "版本", attrIdAndValueId: "2:201" },
{ id: 3, name: "尺码", attrIdAndValueId: "" }
]);
const result = ref("");
const save = async () => {
// 整理平台属性
const arr1 = attrList.reduce((prev, next) => {
if (next.attrIdAndValueId) {
const [attrId, valueId] = next.attrIdAndValueId.split(":");
prev.push({ attrId, valueId });
}
return prev;
}, []);
// 整理销售属性
const arr2 = saleAttrList.reduce((prev, next) => {
if (next.attrIdAndValueId) {
const [saleAttrId, saleAttrValueId] =
next.attrIdAndValueId.split(":");
prev.push({ saleAttrId, saleAttrValueId });
}
return prev;
}, []);
skuParams.skuAttrValueList = arr1;
skuParams.skuSaleAttrValueList = arr2;
// 模拟发请求,实际替换为转写稿中的 REQ.addSku(skuParams)
result.value = JSON.stringify(skuParams, null, 2);
};
return { skuParams, attrList, saleAttrList, save, result };
}
})
.use(ElementPlus)
.mount("#app");
</script>
</body>
</html>要点:
reduce的第二个参数必须给初始值[],否则第一项会被当成prev,类型容易错。- 回调里必须
return prev,否则下一轮prev会变成undefined。 - 只有选中了属性才
split(":"),没有选中的项直接跳过。 - 切分后解构出
attrId、valueId,再push成对象,最后赋给skuParams.skuAttrValueList。 - 易错点:整理完忘记赋值回
skuParams;split后没判断空字符串;销售属性字段名要和接口一致,示例为saleAttrId、saleAttrValueId。
3. 查看 SKU 列表与删除 SPU:loading、清空旧数据、删除后刷新
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>查看SKU列表与删除SPU</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
</head>
<body>
<div id="app" style="padding: 20px">
<el-button type="primary" @click="openSkuList">
查看SKU列表
</el-button>
<el-button type="danger" @click="deleteSpu(6000)">
删除系统SPU
</el-button>
<el-button type="danger" @click="deleteSpu(1)">
删除普通SPU
</el-button>
<el-dialog v-model="dialogVisible" title="SKU列表" width="700px">
<el-table :data="skuList" v-loading="loading" border>
<el-table-column prop="skuName" label="SKU名称" />
<el-table-column prop="price" label="价格" />
<el-table-column prop="weight" label="重量" />
<el-table-column label="图片">
<template #default="{ row }">
<img
:src="row.skuDefaultImg"
style="width:100px;height:100px;object-fit:cover"
/>
</template>
</el-table-column>
</el-table>
</el-dialog>
<pre>{{ message }}</pre>
</div>
<script>
const { createApp, ref } = Vue;
createApp({
setup() {
const dialogVisible = ref(false);
const loading = ref(false);
const skuList = ref([]);
const message = ref("");
const mockSkuList = [
{
id: 1,
skuName: "S11 黑色 电信",
price: "12345",
weight: "11",
skuDefaultImg: "https://via.placeholder.com/100?text=1"
},
{
id: 2,
skuName: "S11 白色 联通",
price: "12345",
weight: "11",
sk常见错误
平台属性/销售属性收集成字符串,提交时格式不对
- 现象:勾选属性后
skuAttrValueList里是"1:3"这种字符串,或提交后后端报参数格式错误。 - 原因:为了省事把「属性ID:值ID」拼成字符串直接挂在属性对象上,提交前忘了还原成对象数组。
- 修法:提交前用
reduce遍历一次,split(':')解构出两个 ID,push成{ attrId, valueId },把结果赋给skuAttrValueList。
- 现象:勾选属性后
销售属性字段名写错,怎么勾都收集不到
- 现象:平台属性正常,销售属性对象上死活没有挂上值。
- 原因:销售属性外层对象上的字段不是
attrId,而是baseSaleAttrId;值也不是valueId那一套命名,凭印象写必然空。 - 修法:先用
console.log打印一条真实数据结构,按实际字段名取值,再对照接口文档确认最终提交字段。
reduce用错,结果是undefined或丢了数据- 现象:
skuAttrValueList拿到undefined,或只保留最后一次的结果。 - 原因:没传第二个参数(初始值),第一轮
prev成了数组首元素;或某个分支忘记return prev。 - 修法:显式传
[]作为初始值,保证每个分支都return prev,没有匹配项时也返回原数组。
- 现象:
图片排他做不出来,或第一张图没勾上
- 现象:图片墙全都不亮;或第一张默认图应该打勾但复选框是空的。
- 原因:服务器返回的图片对象里根本没有
isDefault字段;toggleRowSelection在数据刚回来就调用,此时表格的复选框还没渲染出来。 - 修法:数据回来后先判空再遍历补
isDefault(第 0 项true,其余false);勾选动作放进nextTick里执行,因为要等表格渲染完成才能拿到那行的复选框。
第二次打开对话框还是上次的数据
- 现象:新增一个 SKU 后再点添加,表单里残留着刚才填的内容;或清空后提交反而收集不到数据。
- 原因:没重置表单;或重置和收集的先后顺序颠倒;或
ref忘了.value,清空操作根本没生效。 - 修法:在点击「添加SKU」的回调里、收集参数之前先
reset,ref一律带.value,重置后再重新写入category3Id / spuId / tmId。
v-loading只有第一次转圈- 现象:第一次打开有加载动画,之后切换别的 SPU 就没有了。
- 原因:只在打开对话框时置
true,请求结束后置false就再也没置回来。 - 修法:每次发请求前置
true,请求结束(finally)置false,让开关跟着请求走而不是跟着弹窗走。
切换不同 SPU 查看列表,先闪一下上一个 SPU 的数据
- 现象:点另一个 SPU 的「查看SKU列表」,加载期间表格里还是上一次的条目。
- 原因:旧数组没清空,新数据没回来前渲染的仍是老数据。
- 修法:发请求之前把存放列表的数组清空。
spuId收集错,添加成功但归属不对- 现象:提示添加成功,但在对应 SPU 下查不到这条 SKU。
- 原因:三个父组件传下来的参数里,
spuId取错了字段(例如取成了别的 id),接口不校验就照单全收。 - 修法:提交前在控制台确认三个 ID 与当前行数据一致,别只看「成功」提示,要看返回和列表。
练习
参数拼装练习:给定一份平台属性勾选后的原始数据(字符串形式)和一份接口文档,写出
reduce版本与for循环版本两种整理方案,要求:无勾选时返回空数组、有勾选时返回{attrId, valueId}数组。跑一遍空数组、单条、多条三种用例。默认图片排他:写一个
setDefaultImage(index)方法,实现「选中项isDefault为true、其余为false」,同时把该图地址同步到skuDefaultImg;再补上数据初始化时自动选中第 0 张、且图片数组为空时不报错。复选框同步:数据渲染完成后让第一行的复选框自动勾选;点击「设为默认」按钮时,勾选状态跟着切换。要求解释为什么必须放在
nextTick里,并在注释中写明。列表加载体验:实现「查看SKU列表」:请求前清空旧数据 + 开启 loading,请求结束后关闭 loading 并渲染。人为把网络调慢,验证切换不同 SPU 时不会闪现上一条数据。
新增后收尾:新增 SKU 成功后,要求表单重置、列表刷新、提示成功,并通知父组件切回场景 0。写完后自测:连续新增两条不同 SKU,第二次打开表单必须是空的。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 收集新增SKU的参数 | 43:38 |
| 2 | 完成添加SKU的业务 | 45:48 |
| 3 | 完成查看SKU列表业务 | 23:53 |
| 4 | 完成删除已有的SPU业务 | 04:21 |
小结
- 这个模块真正的难点不在渲染,而在「收集到的数据形态」和「接口要求的形态」不一致——所有脏活都集中在提交前那一次整理。
- 父组件传下来的三个 ID 是唯一不用处理、直接透传的参数,其余每一项都要在提交前过一遍脑子:能不能直接给。
- 平台属性和销售属性的收集思路完全相同,只有字段名不同;写完一个,另一个就是复制粘贴改字段,不必重新设计。
- 图片默认图这一块是「数据不符合业务需求」的典型案例——服务器不给你标记,就得自己在数据回来后补,且必须判空。
reduce在这里的价值是把「过滤 + 转换 + 累加」三件事一次做完;如果写起来别扭,说明对prev和初始值的理解还没到位,这时候用for循环兜底也完全可接受。- 表格类的交互(勾选、复选框状态)永远是「先有数据、再渲染、最后操作 DOM」,凡是想在数据刚回来就操作 DOM 的,一律往
nextTick里放。 - 凡是「打开弹窗类」的业务,三件套缺一不可:打开时重置、请求前清空 + 开 loading、请求后关 loading 并渲染。
- 判断业务是否真的完成,不要看提示弹窗,要看接口返回的参数结构和刷新后的列表——提示成功但数据归属错了,是最容易被放过的 bug。