Skip to content

搭建场景2的结构与先完成更新已有的SPU(Day6 2/2) ​

本页对应 day6,共 4 段视频,约 2 小时 27 分。

学习目标 ​

  1. 能够使用响应式数据存储已有 SPU,并在模板中正确渲染 SPU 名称、描述、品牌、照片墙和已有销售属性。
  2. 能够将后端返回的照片墙数据转换为 Upload 组件要求的格式进行展示,并在提交前转换为接口要求的 imageName、imageUrl 格式。
  3. 能够收集新增销售属性及属性值,完成非空校验、重复校验、删除操作,并按接口格式追加到已有销售属性数组中。
  4. 能够根据 SPU 是否包含 ID 区分添加与更新场景,整理照片墙、销售属性等参数并调用对应接口。
  5. 能够处理更新成功后的提示、通知父组件切换场景,并重新获取 SPU 列表以验证数据已更新。

知识点 ​

场景2的目标:更新已有SPU ​

本节要把“更新已有SPU”的场景结构搭起来。为什么需要?因为SPU管理至少有展示、添加、更新三个场景,前面已经搭好添加场景,现在要复用同一套结构完成更新。怎么想?更新和添加最大的区别是:添加只收集新数据;更新既要展示已有数据,又要收集修改后的数据,是两条战线。怎么做?先接收父组件传过来的已有SPU,把它存到子组件自己的响应式变量里,再分别渲染名称、描述、品牌、照片墙、销售属性。易错点:父组件传过来的SPU往往是“半成品”,只带名称、描述等基础字段,不能假设它已经完整;模板里也不能直接使用函数参数。

把父传SPU变成可编辑数据 ​

为什么需要?父组件通过自定义事件或props把已有SPU传给子组件,但函数参数只能在函数体里用,模板用不了。怎么想?先做一次本地存储,让模板和后续收集都围绕这个响应式对象进行。怎么做?

ts
const SPUParams = ref<SPU>({ ...props.spu })

调试时可以 console.log(SPUParams.value),以S10 Pro为例,确认里面至少有名称、描述、ID等字段。易错点:类型必须是接口里的SPU类型,不能随便写空对象;已有SPU有ID,新增时ID为空,这个差异后面决定调用更新接口还是添加接口。

渲染名称与描述 ​

为什么需要?更新场景首先要把已有值显示出来。怎么想?v-model 可以同时完成“展示已有值”和“收集新值”,比手动赋值更稳。怎么做?

html
<el-input v-model="SPUParams.spuName" />
<el-input v-model="SPUParams.description" />

易错点:不要绑定到父组件传进来的普通对象上;要绑定本地 SPUParams。这样用户改完输入框,值会直接进入 SPUParams,保存时不用再单独收集名称和描述。

品牌下拉:展示已有品牌并收集ID ​

为什么需要?SPU要归属品牌,更新时既要显示当前品牌,也要允许改选。怎么想?下拉框的 value 收集品牌ID,label 展示品牌名称;已有SPU的 tmId 和某个品牌ID匹配时,下拉框自然选中。怎么做?

html
<el-select v-model="SPUParams.tmId">
  <el-option
    v-for="item in trademarkList"
    :key="item.id"
    :value="item.id"
    :label="item.tmName"
  />
</el-select>

调试时先打印品牌列表和当前 tmId,确认S10 Pro对应的品牌能勾上。易错点:value 和 label 不要写反;品牌列表要提前拉取,否则下拉框没有可选项。

照片墙:组件格式与服务器格式互转 ​

为什么需要?服务器返回的照片墙字段是 imgName、imgUrl,而Upload组件文件列表通常要 name、url,格式不一致就不能显示。怎么想?进组件时把服务器格式转成组件格式;保存时再转回服务器格式。怎么做?

ts
const fileList = ref(
  (SPUParams.value.spuImageList ?? []).map(item => ({
    name: item.imgName,
    url: item.imgUrl
  }))
)

保存前:

ts
SPUParams.value.spuImageList = fileList.value.map(item => ({
  imgName: item.name,
  imgUrl: item.url || item.response?.data
}))

调试时打印图片数组,转写中已有图片是11张;点击预览可用 on-preview 拿到文件对象,把地址存起来给对话框展示。易错点:新增图片的地址可能在 response.data,已有图片在 url;删除和新增由Upload组件维护文件列表,但保存前必须转回接口要求的字段。

销售属性表格与“还未拥有”的属性 ​

为什么需要?更新时要展示已有销售属性,还要能新增没有的销售属性。怎么想?已有销售属性直接给表格;下拉框只展示“全部销售属性减去已有销售属性”。怎么做?

ts
const unSelectSaleAttr = computed(() =>
  allSaleAttr.value.filter(item =>
    SPUParams.value.spuSaleAttrList?.every(attr => attr.id !== item.id)
  )
)

易错点:不要用多层for循环硬怼,优先用 filter + every;字段名要跟接口类型一致。表格第一列展示序号,第二列展示销售属性名,第三列展示属性值。

新增销售属性与属性值校验 ​

为什么需要?用户点“添加”后,要把未拥有的销售属性追加到已有数组里,并收集属性值。怎么想?每行销售属性对象加一个 showInput 控制输入框显示;属性值输入后按接口格式push。怎么做?

ts
const addSaleAttr = (row: SaleAttr) => {
  row.showInput = true
}

属性值提交时校验:不能为空、不能重复。重复判断不需要“排除自己”,因为当前值还没push进数组。易错点:不要用一个全局变量控制所有行输入框,否则三行会一起切换;新增销售属性时属性值数组先给空数组。

保存:整理参数、发请求、通知父组件 ​

为什么需要?页面收集的是组件格式,接口要的是服务器格式。怎么想?保存前整理照片墙和销售属性,再根据有没有ID决定更新还是添加。怎么做?

ts
const save = async () => {
  // 先整理 SPUParams.value.spuImageList
  if (SPUParams.value.id) {
    await reqUpdateSpu(SPUParams.value)
    ElMessage.success('更新成功')
  } else {
    await reqAddSpu(SPUParams.value)
    ElMessage.success('添加成功')
  }
  emit('changeScene', 0)
}

易错点:更新成功后要通知父组件切换场景并重新获取列表;调试时盯住请求参数,重点看照片墙字段是否从 name/url 转回 imgName/imgUrl。

先明确:更新 SPU 有两条战线 ​

SPU 管理模块至少要覆盖“展示已有 SPU”“添加/更新 SPU”“添加 SKU”等场景。现在先完成“更新已有 SPU”。它和单纯添加不同:添加只考虑收集新数据;更新既要把已有数据渲染出来,又要收集更新后的数据。所以本节分成 3.1 渲染已有 SPU 数据、3.2 收集更新后的数据两条线。

3.1 渲染已有 SPU 数据:为什么需要先存到响应式数据 ​

父组件传过来的 SPU 是“半成品”,但身上至少有名称、描述、分类 ID、品牌 ID、照片墙、销售属性等。模板里不能直接依赖局部变量,应该先用 ref 存成响应式对象,再做深拷贝或展开复制。为什么?因为后续表单要既能展示已有值,又能被 v-model 收集新值。

怎么做:定义 SPUParams,类型用项目里的 SPU;把父组件传入的 SPU 展开复制进去。名称、描述用 v-model 绑定,这样一行代码同时完成“展示已有值”和“收集新值”。易错点:只展示不绑定,提交时收集不到;直接引用 props 又可能污染父组件数据。

ts
const SPUParams = ref<SPU>({ ...props.spu })
html
<el-input v-model="SPUParams.spuName" />
<el-input v-model="SPUParams.description" />

品牌下拉:展示全部品牌,用 tmId 回显 ​

品牌下拉要展示全部品牌,label 展示品牌名,value 绑定品牌 ID,并用 v-model="SPUParams.tmId" 回显已有品牌。为什么?因为更新时用户可能把品牌从 A 改成 B,最终收集的应是品牌 ID。易错点:只渲染品牌名却不绑定 tmId,已有品牌不会选中;选项的 value 写成名称而不是 ID,提交格式就错了。

照片墙:格式转换与预览 ​

已有照片墙数据不能直接塞给上传组件,因为上传组件 file-list 往往要求 { name, url },而服务器返回可能是 { imgName, imgUrl }。所以要 map 转换。为什么用 map?因为要“改数组里每个元素的字段”,且返回新数组。易错点:字段名写错、TS 类型缺可选字段、忘记把新增图片和已有图片统一格式。

ts
imageList.value = props.spu.spuImageList.map(item => ({
  name: item.imgName,
  url: item.imgUrl
}))

预览用 on-preview 钩子拿到当前文件对象,控制 dialogVisible,并把图片地址存到 previewImageUrl。调试时可先 console.log 打印钩子参数,确认点的是哪张图。易错点:预览不需要收集数据,只负责展示;删除和新增才需要最终整理进提交参数。

ts
const dialogVisible = ref(false)
const previewImageUrl = ref('')
const onPreview = (file) => {
  dialogVisible.value = true
  previewImageUrl.value = file.url
}

销售属性表格与“未拥有属性”计算 ​

已有销售属性用表格展示:序号、销售属性名、属性值、操作。属性值用 v-for 渲染 spuSaleAttrValueList,展示 saleAttrValueName。接着要计算“当前 SPU 还没有的销售属性”:用全部销售属性 filter,再用 every 判断已有列表里是否出现过同名属性。为什么不用 for 循环?filter + every 语义更清楚;for 是最后底线。易错点:every 返回布尔值,条件写反会把已拥有的过滤出来;比较字段要用 saleAttrName 与 name。

ts
const unSelectSaleAttr = computed(() => {
  return baseSaleAttrList.value.filter(item => {
    return !spuSaleAttrList.value.every(it => it.saleAttrName === item.name)
  })
})

下拉选中后,可先用 id:name 字符串收集,点击添加时再拆成对象 push 到已有销售属性列表,属性值数组先给空。易错点:添加后要清空下拉值;按钮要根据是否选中做禁用;新增对象字段必须和表格数据格式一致。

3.2 收集更新后的数据:整理参数再发请求 ​

保存时要合并“半成品 SPU + 收集到的照片墙 + 销售属性”。照片墙要转回服务器格式,销售属性一般可直接复制。根据 SPUParams.id 是否存在判断更新还是添加。为什么先整理?因为上传组件收集的格式和接口要求不一致。易错点:新增图片与已有图片字段来源不同,要统一取 name/url 或 response 中的数据;更新成功后要提示,并通知父组件切换场景、重新获取列表。

ts
const save = async () => {
  const params = {
    ...SPUParams.value,
    spuImageList: imageList.value.map(item => ({
      imgName: item.name,
      imgUrl: item.url
    })),
    spuSaleAttrList: spuSaleAttrList.value
  }

  if (params.id) {
    await reqUpdateSpu(params)
    ElMessage.success('更新成功')
  } else {
    await reqAddSpu(params)
    ElMessage.success('添加成功')
  }

  emit('changeScene', 0)
}

调试时盯住开发者工具里的 SPUParams、imageList、spuSaleAttrList:先确认已有数据能回显,再确认新增/删除后数组变化,最后看提交参数格式是否与接口一致。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

代码示例 ​

示例1:渲染已有 SPU 数据 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>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/dist/index.full.js"></script>
</head>
<body>
<div id="app">
  <spu-update :spu="currentSpu"></spu-update>
</div>

<script>
const { createApp, ref, computed, watch, onMounted } = Vue;

const SpuUpdate = {
  name: 'SpuUpdate',
  props: {
    spu: { type: Object, required: true }
  },
  setup(props) {
    // 已有 SPU 是半成品,先复制到本地响应式对象
    const spuParams = ref({
      id: undefined,
      spuName: '',
      description: '',
      category3Id: undefined,
      tmId: undefined,
      spuImageList: [],
      spuSaleAttrList: []
    });

    // 模拟接口数据
    const trademarkList = ref([
      { id: 6, tmName: 'vivo' },
      { id: 7, tmName: '华为' },
      { id: 8, tmName: '小米' }
    ]);

    const allSaleAttrList = ref([
      { id: 1, name: '颜色' },
      { id: 2, name: '版本' },
      { id: 3, name: '尺码' }
    ]);

    const initSpuParams = () => {
      const {
        id,
        spuName,
        description,
        category3Id,
        tmId,
        spuImageList,
        spuSaleAttrList
      } = props.spu;

      spuParams.value = {
        id,
        spuName: spuName || '',
        description: description || '',
        category3Id,
        tmId,
        // 接口返回 imageName / imageUrl,Upload 需要 name / url
        spuImageList: (spuImageList || []).map(item => ({
          name: item.imageName,
          url: item.imageUrl
        })),
        spuSaleAttrList: JSON.parse(JSON.stringify(spuSaleAttrList || []))
      };
    };

    onMounted(initSpuParams);
    watch(() => props.spu, initSpuParams, { deep: true });

    // 计算还没有的销售属性
    const unSelectSaleAttr = computed(() => {
      const existNames = (spuParams.value.spuSaleAttrList || []).map(
        item => item.saleAttrName
      );
      return allSaleAttrList.value.filter(
        item => !existNames.includes(item.name)
      );
    });

    const saleAttrIdAndValue = ref('');

    const addSaleAttr = () => {
      if (!saleAttrIdAndValue.value) return;
      const [saleAttrId, saleAttrName] = saleAttrIdAndValue.value.split(':');
      spuParams.value.spuSaleAttrList.push({
        saleAttrId: Number(saleAttrId),
        saleAttrName,
        saleAttrValueList: []
      });
      saleAttrIdAndValue.value = '';
    };

    const handlePreview = () => {};
    const handleRemove = (file, fileList) => {
      spuParams.value.spuImageList = fileList;
    };

    return {
      spuParams,
      trademarkList,
      unSelectSaleAttr,
      saleAttrIdAndValue,
      addSaleAttr,
      handlePreview,
      handleRemove
    };
  },
  template: `
    <el-card>
      <el-form label-width="100px">
        <el-form-item label="SPU名称">
          <el-input v-model="spuParams.spuName" placeholder="请输入SPU名称" />
        </el-form-item>

        <el-form-item label="SPU描述">
          <el-input v-model="spuParams.description" type="textarea" />
        </el-form-item>

        <el-form-item label="SPU品牌">
          <el-select v-model="spuParams.tmId" placeholder="请选择品牌">
            <el-option
              v-for="item in trademarkList"
              :key="item.id"
              :label="item.tmName"
              :value="item.id"
            />
          </el-select>
        </el-form-item>

        <el-form-item label="照片墙">
          <el-upload
            list-type="picture-card"
            :file-list="spuParams.spuImageList"
            :auto-upload="false"
            :on-preview="handlePreview"
            :on-remove="handleRemove"
          >
            <span>+</span>
          </el-upload>
        </el-form-item>

        <el-form-item label="销售属性">
          <el-select v-model="saleAttrIdAndValue" placeholder="请选择销售属性">
            <el-option
              v-for="item in unSelectSaleAttr"
              :key="item.id"
              :label="item.name"
              :value="item.id + ':' + item.name"
            />
          </el-select>
          <el-button
            type="primary"
            :disabled="!saleAttrIdAndValue"
            @click="addSaleAttr"
          >添加销售属性</el-button>
        </el-form-item>

        <el-form-item label="销售属性列表">
          <el-table :data="spuParams.spuSaleAttrList" border>
            <el-table-column type="index" label="序号" width="80" />
            <el-table-column prop="saleAttrName" label="销售属性名" />
            <el-table-column label="属性值">
              <template v-slot:default="{ row }">
                <el-tag
                  v-for="val in row.saleAttrValueList"
                  :key="val.id || val.saleAttrValueName"
                >{{ val.saleAttrValueName }}</el-tag>
                <span v-if="!row.saleAttrValueList || row.saleAttrValueList.length === 0">
                  暂无属性值
                </span>
              </template>
            </el-table-column>
          </el-table>
        </el-form-item>
      </el-form>
    </el-card>
  `
};

const app = createApp({
  components: { SpuUpdate },
  setup() {
    // 模拟父组件传过来的已有 SPU
    const currentSpu = ref({
      id: 16,
      spuName: 'S10pro',
      description: '手机通讯手机',
      category3Id: 61,
      tmId: 6,
      spuImageList: [
        { imageName: '1.jpg', imageUrl: 'https://via.placeholder.com/100?text=1' },
        { imageName: '2.jpg', imageUrl: 'https://via.placeholder.com/100?text=2' }
      ],
      spuSaleAttrList: [
        {
          saleAttrId: 1,
          saleAttrName: '颜色',
          saleAttrValueList: [{ id: 1, saleAttrValueName: '蓝色' }]
        },
        {
          saleAttrId: 2,
          saleAttrName: '版本',
          saleAttrValueList: [{ id: 2, saleAttrValueName: '8+128' }]
        }
      ]
    });

    return { currentSpu };
  }
});

app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>

关键行与易错点

  • 父组件传过来的 spu 是“半成品”,只含 id、spuName、description 等,不能直接在模板里改 props,所以先用 ref 复制到 spuParams。
  • 照片墙接口字段通常是 imageName / imageUrl,而 el-upload 需要 name / url,所以初始化时必须 map 转换。
  • v-model="spuParams.spuName" 一行同时完成两件事:展示已有值、收集新值。
  • unSelectSaleAttr 用已有销售属性名过滤全部销售属性,得到“还没有的销售属性”,不要用 for 循环硬写。
  • addSaleAttr 里下拉 value 用 id:name 拼接,添加时再 split(':') 拆开,便于同时拿到 ID 和名称。

示例2:收集更新数据并保存 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>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

常见错误 ​

  1. 现象:模板里直接写 SPU.spuName 能渲染,但后续收集/更新时数据不联动。
    原因:直接用了父组件传入的 props 对象,模板作用域和响应式更新都不受控。
    修法:先用 ref 承接已有 SPU,再在模板和保存逻辑中统一操作该 ref。

  2. 现象:照片墙已有图片不显示,或新增图片保存后字段丢失。
    原因:组件要求 imageName/imageUrl,服务器已有数据是 name/url,新增图片又来自上传响应字段,三类格式混用。
    修法:展示前统一映射为组件格式;保存前再统一转回接口格式,并区分已有图与新增图取 URL 的来源。

  3. 现象:品牌下拉能展开,但当前 SPU 的品牌没有默认选中。
    原因:只渲染了品牌列表,没有把已有 SPU 的品牌 ID 绑定到下拉的 v-model。
    修法:将已有 SPU 的品牌字段绑定到下拉值,确认选中项与列表项的 value 类型一致。

  4. 现象:“还未拥有的销售属性”下拉出现已有属性,或该出现的没出现。
    原因:过滤逻辑写成简单包含判断,未逐项判断当前 SPU 是否已存在同名/同 ID 销售属性。
    修法:用 filter + every 过滤:全部已有销售属性都不匹配时,才保留为可添加项。

  5. 现象:点击添加销售属性后表格多出一行,但保存时接口参数格式不对。
    原因:直接把下拉的字符串值 push 进数组,没有整理成接口要求的对象结构。
    修法:push 前拆出 ID/名称,按 { id, name, attrValues: [] } 之类的接口结构组装,并清空当前选择。

  6. 现象:属性值输入后能显示,但失焦后没进数组,或空值/重复值也能添加。
    原因:只在输入框上收集了临时值,没有在失焦/确认时做整理、校验和 push。
    修法:失焦时先校验非空和重复,再组装属性值对象 push 到对应销售属性,最后清空输入并切回查看态。

练习 ​

  1. 排查并修复“已有 SPU 名称能显示,但保存时名称不是最新输入值”的问题,要求说明问题出在 props、ref 还是模板绑定。
  2. 将已有照片墙数据转换为组件可展示格式,再在保存前转回接口格式;要求同时处理新增图片的 URL 来源差异。
  3. 实现“还未拥有的销售属性”过滤,并用一个已有两个销售属性的 SPU 验证只出现缺失项。
  4. 完成新增销售属性及属性值的收集:要求包含空值校验、重复校验、添加后清空、删除后同步数组。
  5. 点击保存时合并已有 SPU、照片墙、销售属性等数据,发起更新请求;成功后提示并通知父组件切换场景、重新拉取列表。

原视频对照 ​

#视频时长
1展示已有的SPU数据50:47
2收集更新的SPU数据-上22:45
3收集更新的SPU数据-中29:07
4完成更新SPU的业务45:17

小结 ​

  1. 更新业务要同时处理“回显已有数据”和“收集变更后数据”,两条链路不能混。
  2. 父组件传入的 SPU 通常只是半成品,照片墙、销售属性等需要在前端补齐并整理。
  3. 展示格式和接口格式往往不一致,照片墙是典型代表,必须在展示前和保存前各做一次转换。
  4. 下拉回显的关键不是“有列表”,而是已有 ID 正确绑定到 v-model。
  5. 销售属性的新增、属性值新增、删除都要同步维护对应数组,保存前再统一合并。
  6. 保存前先做参数整理,再发请求;成功后要提示、通知父组件切场景并刷新列表。
  7. 新增与更新共用保存逻辑时,用是否存在 ID 区分请求类型,避免写两套重复流程。