Skip to content

完成添加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

课堂画面 2

课堂画面 3

课堂画面 4

代码示例 ​

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

常见错误 ​

  1. 平台属性/销售属性收集成字符串,提交时格式不对

    • 现象:勾选属性后 skuAttrValueList 里是 "1:3" 这种字符串,或提交后后端报参数格式错误。
    • 原因:为了省事把「属性ID:值ID」拼成字符串直接挂在属性对象上,提交前忘了还原成对象数组。
    • 修法:提交前用 reduce 遍历一次,split(':') 解构出两个 ID,push 成 { attrId, valueId },把结果赋给 skuAttrValueList。
  2. 销售属性字段名写错,怎么勾都收集不到

    • 现象:平台属性正常,销售属性对象上死活没有挂上值。
    • 原因:销售属性外层对象上的字段不是 attrId,而是 baseSaleAttrId;值也不是 valueId 那一套命名,凭印象写必然空。
    • 修法:先用 console.log 打印一条真实数据结构,按实际字段名取值,再对照接口文档确认最终提交字段。
  3. reduce 用错,结果是 undefined 或丢了数据

    • 现象:skuAttrValueList 拿到 undefined,或只保留最后一次的结果。
    • 原因:没传第二个参数(初始值),第一轮 prev 成了数组首元素;或某个分支忘记 return prev。
    • 修法:显式传 [] 作为初始值,保证每个分支都 return prev,没有匹配项时也返回原数组。
  4. 图片排他做不出来,或第一张图没勾上

    • 现象:图片墙全都不亮;或第一张默认图应该打勾但复选框是空的。
    • 原因:服务器返回的图片对象里根本没有 isDefault 字段;toggleRowSelection 在数据刚回来就调用,此时表格的复选框还没渲染出来。
    • 修法:数据回来后先判空再遍历补 isDefault(第 0 项 true,其余 false);勾选动作放进 nextTick 里执行,因为要等表格渲染完成才能拿到那行的复选框。
  5. 第二次打开对话框还是上次的数据

    • 现象:新增一个 SKU 后再点添加,表单里残留着刚才填的内容;或清空后提交反而收集不到数据。
    • 原因:没重置表单;或重置和收集的先后顺序颠倒;或 ref 忘了 .value,清空操作根本没生效。
    • 修法:在点击「添加SKU」的回调里、收集参数之前先 reset,ref 一律带 .value,重置后再重新写入 category3Id / spuId / tmId。
  6. v-loading 只有第一次转圈

    • 现象:第一次打开有加载动画,之后切换别的 SPU 就没有了。
    • 原因:只在打开对话框时置 true,请求结束后置 false 就再也没置回来。
    • 修法:每次发请求前置 true,请求结束(finally)置 false,让开关跟着请求走而不是跟着弹窗走。
  7. 切换不同 SPU 查看列表,先闪一下上一个 SPU 的数据

    • 现象:点另一个 SPU 的「查看SKU列表」,加载期间表格里还是上一次的条目。
    • 原因:旧数组没清空,新数据没回来前渲染的仍是老数据。
    • 修法:发请求之前把存放列表的数组清空。
  8. spuId 收集错,添加成功但归属不对

    • 现象:提示添加成功,但在对应 SPU 下查不到这条 SKU。
    • 原因:三个父组件传下来的参数里,spuId 取错了字段(例如取成了别的 id),接口不校验就照单全收。
    • 修法:提交前在控制台确认三个 ID 与当前行数据一致,别只看「成功」提示,要看返回和列表。

练习 ​

  1. 参数拼装练习:给定一份平台属性勾选后的原始数据(字符串形式)和一份接口文档,写出 reduce 版本与 for 循环版本两种整理方案,要求:无勾选时返回空数组、有勾选时返回 {attrId, valueId} 数组。跑一遍空数组、单条、多条三种用例。

  2. 默认图片排他:写一个 setDefaultImage(index) 方法,实现「选中项 isDefault 为 true、其余为 false」,同时把该图地址同步到 skuDefaultImg;再补上数据初始化时自动选中第 0 张、且图片数组为空时不报错。

  3. 复选框同步:数据渲染完成后让第一行的复选框自动勾选;点击「设为默认」按钮时,勾选状态跟着切换。要求解释为什么必须放在 nextTick 里,并在注释中写明。

  4. 列表加载体验:实现「查看SKU列表」:请求前清空旧数据 + 开启 loading,请求结束后关闭 loading 并渲染。人为把网络调慢,验证切换不同 SPU 时不会闪现上一条数据。

  5. 新增后收尾:新增 SKU 成功后,要求表单重置、列表刷新、提示成功,并通知父组件切回场景 0。写完后自测:连续新增两条不同 SKU,第二次打开表单必须是空的。

原视频对照 ​

#视频时长
1收集新增SKU的参数43:38
2完成添加SKU的业务45:48
3完成查看SKU列表业务23:53
4完成删除已有的SPU业务04:21

小结 ​

  1. 这个模块真正的难点不在渲染,而在「收集到的数据形态」和「接口要求的形态」不一致——所有脏活都集中在提交前那一次整理。
  2. 父组件传下来的三个 ID 是唯一不用处理、直接透传的参数,其余每一项都要在提交前过一遍脑子:能不能直接给。
  3. 平台属性和销售属性的收集思路完全相同,只有字段名不同;写完一个,另一个就是复制粘贴改字段,不必重新设计。
  4. 图片默认图这一块是「数据不符合业务需求」的典型案例——服务器不给你标记,就得自己在数据回来后补,且必须判空。
  5. reduce 在这里的价值是把「过滤 + 转换 + 累加」三件事一次做完;如果写起来别扭,说明对 prev 和初始值的理解还没到位,这时候用 for 循环兜底也完全可接受。
  6. 表格类的交互(勾选、复选框状态)永远是「先有数据、再渲染、最后操作 DOM」,凡是想在数据刚回来就操作 DOM 的,一律往 nextTick 里放。
  7. 凡是「打开弹窗类」的业务,三件套缺一不可:打开时重置、请求前清空 + 开 loading、请求后关 loading 并渲染。
  8. 判断业务是否真的完成,不要看提示弹窗,要看接口返回的参数结构和刷新后的列表——提示成功但数据归属错了,是最容易被放过的 bug。