主题
搭建场景2的结构与先完成更新已有的SPU(Day6 2/2)
本页对应 day6,共 4 段视频,约 2 小时 27 分。
学习目标
- 能够使用响应式数据存储已有 SPU,并在模板中正确渲染 SPU 名称、描述、品牌、照片墙和已有销售属性。
- 能够将后端返回的照片墙数据转换为 Upload 组件要求的格式进行展示,并在提交前转换为接口要求的
imageName、imageUrl格式。 - 能够收集新增销售属性及属性值,完成非空校验、重复校验、删除操作,并按接口格式追加到已有销售属性数组中。
- 能够根据 SPU 是否包含 ID 区分添加与更新场景,整理照片墙、销售属性等参数并调用对应接口。
- 能够处理更新成功后的提示、通知父组件切换场景,并重新获取 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:渲染已有 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常见错误
现象:模板里直接写
SPU.spuName能渲染,但后续收集/更新时数据不联动。
原因:直接用了父组件传入的props对象,模板作用域和响应式更新都不受控。
修法:先用ref承接已有 SPU,再在模板和保存逻辑中统一操作该ref。现象:照片墙已有图片不显示,或新增图片保存后字段丢失。
原因:组件要求imageName/imageUrl,服务器已有数据是name/url,新增图片又来自上传响应字段,三类格式混用。
修法:展示前统一映射为组件格式;保存前再统一转回接口格式,并区分已有图与新增图取 URL 的来源。现象:品牌下拉能展开,但当前 SPU 的品牌没有默认选中。
原因:只渲染了品牌列表,没有把已有 SPU 的品牌 ID 绑定到下拉的v-model。
修法:将已有 SPU 的品牌字段绑定到下拉值,确认选中项与列表项的 value 类型一致。现象:“还未拥有的销售属性”下拉出现已有属性,或该出现的没出现。
原因:过滤逻辑写成简单包含判断,未逐项判断当前 SPU 是否已存在同名/同 ID 销售属性。
修法:用filter + every过滤:全部已有销售属性都不匹配时,才保留为可添加项。现象:点击添加销售属性后表格多出一行,但保存时接口参数格式不对。
原因:直接把下拉的字符串值 push 进数组,没有整理成接口要求的对象结构。
修法:push 前拆出 ID/名称,按{ id, name, attrValues: [] }之类的接口结构组装,并清空当前选择。现象:属性值输入后能显示,但失焦后没进数组,或空值/重复值也能添加。
原因:只在输入框上收集了临时值,没有在失焦/确认时做整理、校验和 push。
修法:失焦时先校验非空和重复,再组装属性值对象 push 到对应销售属性,最后清空输入并切回查看态。
练习
- 排查并修复“已有 SPU 名称能显示,但保存时名称不是最新输入值”的问题,要求说明问题出在
props、ref还是模板绑定。 - 将已有照片墙数据转换为组件可展示格式,再在保存前转回接口格式;要求同时处理新增图片的 URL 来源差异。
- 实现“还未拥有的销售属性”过滤,并用一个已有两个销售属性的 SPU 验证只出现缺失项。
- 完成新增销售属性及属性值的收集:要求包含空值校验、重复校验、添加后清空、删除后同步数组。
- 点击保存时合并已有 SPU、照片墙、销售属性等数据,发起更新请求;成功后提示并通知父组件切换场景、重新拉取列表。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 展示已有的SPU数据 | 50:47 |
| 2 | 收集更新的SPU数据-上 | 22:45 |
| 3 | 收集更新的SPU数据-中 | 29:07 |
| 4 | 完成更新SPU的业务 | 45:17 |
小结
- 更新业务要同时处理“回显已有数据”和“收集变更后数据”,两条链路不能混。
- 父组件传入的 SPU 通常只是半成品,照片墙、销售属性等需要在前端补齐并整理。
- 展示格式和接口格式往往不一致,照片墙是典型代表,必须在展示前和保存前各做一次转换。
- 下拉回显的关键不是“有列表”,而是已有 ID 正确绑定到
v-model。 - 销售属性的新增、属性值新增、删除都要同步维护对应数组,保存前再统一合并。
- 保存前先做参数整理,再发请求;成功后要提示、通知父组件切场景并刷新列表。
- 新增与更新共用保存逻辑时,用是否存在 ID 区分请求类型,避免写两套重复流程。