Skip to content

SPU管理模块场景切换与搭建场景2的结构(Day6 1/2) ​

本页对应 day6,共 5 段视频,约 1 小时 51 分。

学习目标 ​

  • 能够用场景值控制 SPU 管理模块中“已有 SPU 列表”“添加/更新 SPU”“添加 SKU”三个场景的显示与隐藏,并完成添加、更新、取消按钮的场景切换。
  • 能够将添加/更新 SPU 与添加 SKU 拆分为独立子组件,并通过自定义事件实现子组件取消操作后通知父组件切回场景 0。
  • 能够搭建添加/更新 SPU 场景的静态表单结构,包括 SPU 名称、品牌、描述、图片墙、销售属性表格及保存/取消按钮。
  • 能够定义并调用获取全部品牌、指定 SPU 照片墙、指定 SPU 已有销售属性、全部销售属性四个接口,并补齐对应 TS 类型。
  • 能够通过 ref 调用子组件暴露的方法,在更新已有 SPU 时携带 SPU ID 发起四个请求,并将返回数据存储到 ref 中拼装成完整 SPU 数据。

知识点 ​

场景拆解:先分清“页面切换”和“弹窗” ​

本节笔记把 SPU 管理模块拆成三个场景:场景 0 展示已有 SPU,场景 1 添加 SPU 与更新已有 SPU,场景 2 添加 SKU。为什么需要先拆场景?因为底部卡片区域的结构会整体变化:场景 0 是表格,场景 1 是表单,不能靠零散按钮硬改。怎么想:先看“场景名是否变化”。转写稿里现场判断:添加 SKU 按钮点开后出现的是弹窗,底部场景名不变,所以不要机械地把所有弹窗都做成场景切换;但按笔记仍预留场景 2,便于后续扩展。怎么做:父组件维护场景值,三个区域三选一显示。易错点:场景值不要用布尔值,因为布尔只有真/假两种情况,而这里至少是 0、1、2 三选一。

ts
// 0 展示已有SPU;1 添加/更新SPU;2 预留添加SKU
const scene = ref(0)

为什么把场景 1 拆成子组件 ​

为什么需要:场景 1 里有 SPU 名称、品牌、描述、图片墙、销售属性、保存/取消等大量结构,全塞进父组件会“写懵逼”。怎么想:父组件只负责场景切换和表格;添加/更新 SPU 的表单拆成 SPUForm,添加 SKU 拆成 SKUForm。怎么做:建 SPUForm/index.vue、SKUForm/index.vue,父组件引入并用 v-show 控制。转写稿强调用 v-show 而不是频繁 v-if,因为子组件结构多,反复销毁重建不划算。易错点:v-show 隐藏时组件已经挂载,所以不能在子组件 onMounted 里就发“更新用”的四个请求,因为此时还不知道要更新哪条 SPU。

vue
<div v-show="scene === 0">SPU表格</div>
<SPUForm v-show="scene === 1" ref="spuFormRef" :spu="currentSpu" @changeScene="changeScene" />
<SKUForm v-show="scene === 2" />

场景 0 到场景 1:添加与更新入口 ​

为什么需要:场景 0 的“添加 SPU”“更新已有 SPU”都要进入场景 1。怎么想:添加只切场景;更新还要把当前行 SPU 数据带给子组件。怎么做:添加按钮执行 scene.value = 1;更新按钮先保存当前行,再切场景,并通过 ref 调子组件初始化方法。易错点:更新按钮必须带 row,否则子组件不知道更新谁;子组件取消时不能直接改父组件变量,要用自定义事件通知父组件切回 0。

ts
const spuFormRef = ref()
const currentSpu = ref<SPU>()

const addSPU = () => { scene.value = 1 }

const updateSPU = (row: SPU) => {
  currentSpu.value = row
  scene.value = 1
  spuFormRef.value.initHasSPU(row)
}

const changeScene = (val: number) => { scene.value = val }
ts
// SPUForm 内
const emit = defineEmits<{ (e: 'changeScene', val: number): void }>()
const cancel = () => emit('changeScene', 0)
defineExpose({ initHasSPU })

更新已有 SPU:数据不是完整的 ​

为什么需要:转写稿反复提醒,场景 0 拿到的已有 SPU 只有名称、描述等少量字段,并不是完整 SPU。怎么想:点击更新后,至少要补齐四类数据:全部品牌、当前 SPU 的照片墙、当前 SPU 已有销售属性与属性值、全部销售属性。怎么做:在子组件 initHasSPU(spu) 中发四个请求,其中照片墙和已有销售属性要带当前 SPU 的 ID;全部品牌和全部销售属性不带 ID。易错点:两个带 ID 的请求不能在子组件挂载时发,因为挂载时还没有当前 SPU;也不建议父组件发完再层层传参或塞仓库,转写稿认为直接在子组件发更顺。

ts
const initHasSPU = async (spu: SPU) => {
  const [trademarkRes, imageRes, hasSaleRes, saleRes] = await Promise.all([
    reqAllTrademark(),
    reqImageList(spu.id),
    reqSpuHasSaleAttr(spu.id),
    reqAllSaleAttr()
  ])
  trademarkArr.value = trademarkRes
  imageArr.value = imageRes
  spuHasSaleAttrArr.value = hasSaleRes
  saleAttrArr.value = saleRes
}

TS 类型与易错点 ​

为什么需要:这四个接口返回的大多是数组,数组里套对象,对象里还有属性值数组,不写类型很容易“眼花缭乱”。怎么想:先看接口返回,再定 Trademark、图片墙数组、销售属性与属性值、全部销售属性等类型。怎么做:不确定是否必有的字段加 ?,例如新增图片时图片 ID、SPU ID 可能还没有;品牌下拉只展示,ID 可不加问号。易错点:不要为了 TS 类型卡死业务逻辑,转写稿建议先把按钮、请求、数据存储跑通,再补类型;四个请求的结果要存到 ref,最后与传入的半成品 SPU 拼成完整 SPU 供更新表单展示。

一、先明确三个场景,为什么用数字场景值 ​

SPU 管理模块不是一个固定页面:底部卡片切换时,主体结构会变。转写稿里梳理出三个场景:场景 0 展示已有 SPU(含分页,用 el-table);场景 1 添加新 SPU 或更新已有 SPU;场景 2 添加 SKU。为什么需要场景值? 因为三块结构差异大,不能靠一个布尔值表达。怎么想? 父组件只负责“当前显示谁”,具体结构拆成子组件。怎么做? 在父组件定义 scene,用 v-show 控制:

vue
<el-table v-show="scene === 0" />
<SPUForm v-show="scene === 1" />
<SKUForm v-show="scene === 2" />
ts
const scene = ref(0)

易错点:不要用 true/false 控制三个场景;不要用 v-if,因为子组件结构多,频繁销毁重建不划算,且更新时要通过 ref 调子组件方法。

二、搭建添加/更新 SPU 表单结构 ​

为什么需要拆组件? 添加/更新 SPU 的表单项很多,全塞在父组件里结构容易写乱。怎么想? 把“添加新 SPU 与更新已有 SPU”的结构放进 SPUForm 子组件,父组件只做切换。怎么做? 用 el-form 搭表单项:SPU 名称、SPU 品牌(el-select)、SPU 描述(textarea)、SPU 图片(el-upload 多图上传)、SPU 销售属性(下拉菜单 + 添加销售属性按钮 + el-table)、保存/取消按钮。图片上传要引入 plus 图标;销售属性表格列包括序号、属性名、属性值、操作。销售属性全项目最多三个:颜色、版本、尺码。

vue
<el-form>
  <el-form-item label="SPU名称">
    <el-input placeholder="请输入SPU名称" />
  </el-form-item>
  <el-form-item label="SPU品牌">
    <el-select><el-option label="华为" value="华为" /></el-select>
  </el-form-item>
  <el-form-item label="SPU描述">
    <el-input type="textarea" />
  </el-form-item>
  <el-form-item label="SPU图片">
    <el-upload action="" :before-upload="" :on-success="" />
  </el-form-item>
  <el-form-item label="SPU销售属性">
    <el-select />
    <el-button type="primary" :icon="Plus">添加销售属性</el-button>
    <el-table>
      <el-table-column type="index" label="序号" width="80" />
      <el-table-column label="属性名" width="120" />
      <el-table-column label="属性值" width="120" />
      <el-table-column label="操作" width="120" />
    </el-table>
  </el-form-item>
  <el-form-item>
    <el-button type="primary">保存</el-button>
    <el-button @click="cancel">取消</el-button>
  </el-form-item>
</el-form>

易错点:label 宽度不统一会参差不齐,可统一设置;图片墙是多图上传,不是单图;销售属性最多三个,不能凭空加第四种。

三、父子通信:更新按钮如何把已有 SPU 交给子组件 ​

为什么需要? 点击“更新”时,父组件手里有当前行的已有 SPU,但数据不完整,只有名称、描述等;子组件需要拿到它并继续取完整数据。怎么想? 父组件用 ref 拿到子组件实例,调用子组件暴露的方法。怎么做? 子组件用 defineExpose 暴露 initHasSPU;父组件点击更新时切换 scene = 1,并调用方法:

ts
const spuForm = ref()
const updateSPU = (row: SPU) => {
  scene.value = 1
  spuForm.value.initHasSPU(row)
}
ts
const initHasSPU = (spu: SPU) => {
  // 在这里发四个请求,拼完整SPU
}
defineExpose({ initHasSPU })

取消按钮不能直接改父组件的 scene,要用自定义事件通知父组件切回场景 0:

ts
const emit = defineEmits(['changeScene'])
const cancel = () => emit('changeScene', 0)

易错点:四个请求不要在子组件 onMounted 里发。子组件虽然隐藏但已挂载,此时还没有明确的 SPU ID;应在点击更新、调用 initHasSPU 时发。

四、四个接口与 TS 类型:拼出完整 SPU ​

为什么需要? 已有 SPU 不是完整数据:品牌下拉要所有品牌;照片墙要按 SPU ID 取;已有销售属性与属性值要按 SPU ID 取;全部销售属性只有颜色、版本、尺码。怎么想? 四个请求返回后拼成完整 SPU。怎么做? 接口分别获取全部品牌、某 SPU 照片墙、某 SPU 已有销售属性与属性值、全部销售属性;参数该带 spuId 的必须带。

ts
export interface Trademark { id: number; tmName: string; logoUrl: string }
export interface ImageItem { id?: number; spuId?: number; imgName: string; imgUrl: string }
export interface SaleAttrValue { id?: number; saleAttrId?: number; saleAttrValueName: string }
export interface SaleAttr { id?: number; saleAttrName: string; spuSaleAttrValueList: SaleAttrValue[] }

易错点:新增时 ID 由数据库生成,照片墙的 spuId、销售属性/属性值 ID 可能没有,类型可加 ?;已有数据有 ID 就带。更新和添加的数据格式相近,区别是新增没有 ID。把四个请求结果存起来,再与已有 SPU 拼成完整数据,后续表单回显和保存都依赖它。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

一、父组件:SPU 管理场景切换(src/views/product/spu/index.vue) ​

vue
<template>
  <div>
    <!-- 场景 0:展示已有 SPU -->
    <div v-show="scene === 0">
      <el-card>
        <el-button type="primary" :icon="Plus" @click="addSPU">添加SPU</el-button>
        <el-table :data="spuList" border style="margin-top: 10px">
          <el-table-column type="index" label="序号" width="80" align="center" />
          <el-table-column prop="spuName" label="SPU名称" />
          <el-table-column prop="description" label="SPU描述" />
          <el-table-column label="操作" width="240">
            <template #default="{ row }">
              <el-button type="primary" size="small" @click="updateSPU(row)">更新SPU</el-button>
            </template>
          </el-table-column>
        </el-table>
      </el-card>
    </div>

    <!-- 场景 1:添加新的SPU / 更新已有SPU -->
    <SPUForm
      v-show="scene === 1"
      ref="spuRef"
      :spu="currentSPU"
      @changeScene="changeScene"
    />

    <!-- 场景 2:添加SKU -->
    <SKUForm v-show="scene === 2" @changeScene="changeScene" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import SPUForm from './SPUForm/index.vue'
import SKUForm from './SKUForm/index.vue'

// 场景值:0-展示已有SPU  1-添加/更新SPU  2-添加SKU
const scene = ref<number>(0)

const spuRef = ref<any>()          // 拿到子组件实例
const currentSPU = ref<any>()      // 当前要更新的那条(残缺的)SPU
const spuList = ref<any[]>([])     // 已有SPU列表(前一天的接口)

// 点击「添加SPU」:只切场景
const addSPU = () => {
  scene.value = 1
}

// 点击「更新SPU」:切场景 + 传数据 + 调子组件方法发请求
const updateSPU = (row: any) => {
  scene.value = 1
  currentSPU.value = row
  spuRef.value.initHasSPU(row)     // 关键:父调子的方法
}

// 子组件通过自定义事件把场景值传回来
const changeScene = (num: number) => {
  scene.value = num
}
</script>

关键行与易错点

  • 场景值用 number 而不是布尔:三个场景是三选一,boolean 只有真假两种状态,写不下;用 0/1/2(或 ''/a/b)都可以。
  • 必须用 v-show,不能随手换成 v-if:v-show 只是隐藏,子组件一开始就挂载好了,所以 spuRef.value 第一次点击就能拿到;换成 v-if 后子组件首次不渲染,spuRef.value 是 undefined,initHasSPU 直接报错。
  • updateSPU 里先切场景、再传数据、最后调方法:调 initHasSPU 时子组件内部要用 spu.id 发两个带参请求,所以顺序不能乱。
  • addSPU 不调 initHasSPU:添加是全新 SPU,没有 ID,不需要发那四个请求。
  • 三个场景拆成三个子组件,而不是在一个组件里堆三个 div,否则结构太长容易写乱。

二、子组件:场景 1 静态结构 + 取消事件 + 暴露初始化方法(src/views/product/spu/SPUForm/index.vue) ​

vue
<template>
  <el-form label-width="120px">
    <el-form-item label="SPU名称">
      <el-input v-model="spuObj.spuName" placeholder="请您输入SPU名称" />
    </el-form-item>

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

    <el-form-item label="SPU描述">
      <el-input v-model="spuObj.description" type="textarea" placeholder="请您输入描述" />
    </el-form-item>

    <el-form-item label="SPU图片">
      <el-upload
        action="/api/product/upload"
        list-type="picture-card"
        :file-list="imageList"
      >
        <el-icon><Plus /></el-icon>
      </el-upload>
    </el-form-item>

    <el-form-item label="SPU销售属性">
      <el-select placeholder="请选择销售属性" style="width: 200px">
        <el-option
          v-for="item in baseSaleAttrList"
          :key="item.id"
          :label="item.name"
          :value="item.id"
        />
      </el-select>
      <el-button type="primary" :icon="Plus" style="margin-left: 10px">添加销售属性</el-button>

      <el-table style="margin-top: 10px">
        <el-table-column type="index" label="序号" width="80" align="center" />
        <el-table-column label="属性名" width="120" />
        <el-table-column label="属性值" />
        <el-table-column label="操作" width="120" />
      </el-table>
    </el-form-item>

    <el-form-item>
      <el-button type="primary">保存</el-button>
      <el-button @click="cancel">取消</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import {
  reqAllTradeMark,
  reqImageList,
  reqSaleAttrList,
  reqBaseSaleAttrList,
} from '@/api/product/spu'
import type {
  TradeMark,
  ImageList,
  SaleAttrList,
  BaseSaleAttrList,
} from '@/api/product/spu'

defineProps<{ spu?: any }>()

const emit = defineEmits<{ (e: 'changeScene', num: number): void }>()

// 最终拼出来的完整 SPU
const spuObj = ref<any>({})
const trademarkList = ref<TradeMark[]>([])
const imageList = ref<ImageList>([])
const saleAttrList = ref<SaleAttrList>([])
const baseSaleAttrList = ref<BaseSaleAttrList>([])

// 取消:通知父组件切回场景 0
const cancel = () => {
  emit('changeScene', 0)
}

// 父组件点「更新SPU」时调用,参数是那条残缺的已有 SPU
const initHasSPU = async (spu: any) => {
  spuObj.value = spu                       // 先存下名字、描述

  // 四个请求:两个不带参、两个必须带 spu.id
  const result1 = await reqAllTradeMark()
  trademarkList.value = result1

  const result2 = await reqImageList(spu.id)
  imageList.value = result2

  const result3 = await reqSaleAttrList(spu.id)
  saleAttrList.value = result3

  const result4 = await reqBaseSaleAttrList()
  baseSaleAttrList.value = result4
}

// 必须暴露,否则父组件拿不到
defineExpose({ initHasSPU })
</script>

关键行与易错点

  • defineExpose({ initHasSPU }) 不能漏:<script setup> 里定义的变量和方法默认是“关闭”的,父组件通过 ref 只能拿到被 expose 出去的东西,不加这一行父组件调 spuRef.value.initHasSPU 会提示方法不存在。
  • 四个请求不能写在 onMounted 里:这个子组件默认就是挂载状态(v-show),一挂载时根本没有 spu.id,两个带参请求必错;必须在父组件点“更新SPU”后,由父组件调方法触发。
  • 四个请求都 await,且带参的两个必须用 spu.id:照片墙要告诉服务器“要谁的图片”,销售属性要告诉服务器“要哪个 SPU 的”,少传 ID 拿到的就是别人的数据。
  • 取消必须走自定义事件:场景值是父组件的数据,子组件不能直接改,只能 emit('changeScene', 0) 让父组件改。
  • label-width="120px" 统一写在 el-form 上,不然各个 el-form-item 的标签宽度参差不齐,表格也对不齐。
  • 图片墙是多图上传:要用 el-upload 的 list-type="picture-card",图标用 Plus(需要 import { Plus } from '@element-plus/icons-vue'),少这个 import 加号图标就不显示。

三、四个接口与 TS 类型(src/api/product/spu.ts) ​

ts
import request from '@/utils/request'
import API from '@/api/product/api'   // 存放接口地址常量

/* ========== 1. 获取全部品牌(不带参) ========== */
export interface TradeMark {
  id: number          // 已有品牌一定有 ID,不用加问号
  tmName: string
  logoUrl: string
}
export type TradeMarkList = TradeMark[]

export const reqAllTradeMark = () =>
  request.get<any, TradeMarkList>(API.TRADE_MARK_URL)

/* ========== 2. 获取某个已有 SPU 的照片墙(带 spuId) ========== */
export interface ImageValue {
  id?: number         // 新增图片的 ID 由数据库生成,所以可选
  spuId?: number      // 新增 SPU 时还没有 spuId,所以可选
  imgName: string
  imgUrl: string
}
export type ImageList = ImageValue[]

export const reqImageList = (spuId: number) =>
  request.get<any, ImageList>(`${API.IMAGE_LIST_URL}/${spuId}`)

/* ========== 3. 获取某个已有 SPU 的销售属性与属性值(带 spuId) ========== */
export interface SaleAttrValue {
  id?: number
  saleAttrId?: number
  baseSaleAttrId?: number
  saleAttrValueName: string
  isChecked?: boolean
}
export type SaleAttrValueList = SaleAttrValue[]

export interface SaleAttr {
  id?: number
  spuId?: number
  baseSaleAttrId: number      // 颜色=1、版本=2、尺码=3,必须带
  saleAttrName: string
  spuSaleAttrValueList: SaleAttrValueList
}
export type SaleAttrList = SaleAttr[]

export const reqSaleAttrList = (spuId: number) =>
  request.get<any, SaleAttrList>(`${API.SALE_ATTR_URL}/${spuId}`)

/* ========== 4. 获取全部销售属性(全项目只有 3 个,不带参) ========== */
export interface BaseSaleAttr {
  id: number          // 颜色/版本/尺码,ID 固定,不用加问号
  name: string
}
export type BaseSaleAttrList = BaseSaleAttr[]

export const reqBaseSaleAttrList = () =>
  request.get<any, BaseSaleAttrList>(API.BASE_SALE_ATTR_URL)

关键行与易错点

  • “不确定的字段一律加 ?”:id、spuId、saleAttrId 这些字段在“更新”时有值、在“添加”时由后端生成,写成可选类型才不会在新增时报类型错误。
  • 确定的字段不加问号:品牌 TradeMark.id、全部销售属性 BaseSaleAttr.id 都是库里已有数据,一定有值。
  • baseSaleAttrId 是“属性 ID”,不是“SPU 的 ID”:颜色=1、版本=2、尺码=3,全项目销售属性最多 3 个,所以这个字段必填。
  • 照片墙返回的是数组,数组里是对象,所以要有 ImageValue 和 ImageList 两个类型;销售属性同理,要有 SaleAttrValue / SaleAttrValueList / SaleAttr / SaleAttrList,别只写一个 interface。
  • 带参接口用模板字符串拼 ID,别把 ID 写成 query 参数之外的 undefined,否则服务器拿不到要查谁。
  • 四个请求返回的都是数组,所以 ref 初始化时给 [],别给 {},否则 v-for 渲染会报错。

常见错误 ​

  1. 现象:场景值用 boolean,结果“添加 SPU”和“更新 SPU”只能二选一,切到 SKU 场景就崩。 原因:场景有三个(0 展示 / 1 添加或更新 / 2 添加 SKU),布尔只有两态。 修法:场景值用 number(0/1/2)或字符串枚举,父组件统一持有,子组件只读不改。

  2. 现象:一进页面就在子组件里连发四个请求,照片墙和销售属性全是 undefined 或报错。 原因:子组件用的是 v-show 而非 v-if,onMounted 在页面初始化就执行了,此时父组件还没把要更新的 SPU 传进来,更没有 SPU 的 ID。 修法:请求不能在 onMounted 发;由父组件在“更新”按钮回调里通过 ref 调子组件暴露的方法,在方法内部发请求。

  3. 现象:父组件 spuRef.value.initHasSPU is not a function,或拿到的是 undefined。 原因:<script setup> 里的变量和方法默认不对外暴露,ref 只能拿到组件实例,拿不到内部方法。 修法:子组件用 defineExpose({ initHasSPU }) 显式导出,父组件 const spuRef = ref() 同名绑定后再调用。

  4. 现象:点“更新”后表单里只有名称和描述,品牌下拉是空的、照片墙是空的、销售属性是空的。 原因:场景 0 列表里拿到的 SPU 是“残次品”,只含 id / spuName / description;品牌、照片墙、已有销售属性、全部销售属性这四份数据都要单独请求后拼装。 修法:明确“一个完整 SPU = 列表数据 + 四个接口结果”,在子组件内用 ref 分别存 trademarkArr / imgArr / spuHasSaleAttrArr / saleAttrArr,拼装后再渲染。

  5. 现象:点“取消”后场景没切回 0,或者子组件直接改父组件的场景值,控制台警告 / 状态错乱。 原因:子组件想直接修改父组件的数据,违反单向数据流。 修法:子组件 emit('changeScene', 0),父组件监听后修改自己的场景值;defineEmits 里把参数类型声明为 number。

  6. 现象:TS 类型一路标红,Property 'id' does not exist、spuId 报错,越写越乱。 原因:没有区分“已有数据”和“新增数据”的字段差异:照片的 id、spuId,销售属性值的 id、baseSaleAttrId 在新增时都不存在。 修法:这类字段一律加 ? 声明为可选;确定由后端生成的(如品牌 id)不加 ?。先跑通逻辑再补类型,不要一边写结构一边纠结类型。

  7. 现象:“添加销售属性”的下拉里还能选到该 SPU 已有的属性,出现重复项。 原因:下拉直接用了“全部销售属性”,没有做差集。 修法:下拉数据 = 全部销售属性(颜色/版本/尺码,最多 3 个)过滤掉当前 SPU 已有的;同时加“已满 3 个禁用按钮”的保护。

练习 ​

  1. 在父组件里用 ref + defineExpose 打通“更新”按钮 → 子组件方法的调用链,并在子组件方法里打印父组件传入的 SPU,确认能拿到 id。
  2. 在子组件内并发发出四个请求(全部品牌、该 SPU 照片墙、该 SPU 已有销售属性、全部销售属性),用 Promise.all 或顺序 await 拿到结果并分别存入四个 ref,最后拼出一个完整 SPU 对象打印出来。
  3. 为四个接口补全 TS 类型:注意 ImageItem 的 id?、spuId?,SaleAttrValue 的 id?、baseSaleAttrId?,并说明每个可选字段在“新增”场景下为什么可以没有。
  4. 实现“添加销售属性”下拉的差集逻辑:已有 2 个销售属性时,下拉只剩 1 项;已有 3 项时按钮置灰不可点。
  5. 实现“取消”按钮:子组件 emit 通知父组件把场景值切回 0,并验证再次点“更新”时四个请求会重新发起、数据不残留。

原视频对照 ​

#视频时长
1SPU管理模块场景的切换16:58
2场景1静态搭建16:07
3场景切换之更新已有SPU32:01
4书写更新SPU模块四个接口与ts类型34:10
5书写更新SPU的四个接口并且存储数据12:21

小结 ​

  1. SPU 管理一共三个场景:0 展示列表、1 添加/更新 SPU、2 添加 SKU;场景值必须是能表达三态的 number,不能用布尔。
  2. 三个场景各自拆成子组件(SpuForm、SkuForm),父组件只负责“谁显示”和“传什么”,结构才不会写乱。
  3. 场景 0 拿到的 SPU 是不完整的,只有 id、名称、描述;完整 SPU 必须靠四个接口拼装,这是本模块最核心的数据认知。
  4. 四个请求必须在“点击更新”之后发,不能在 onMounted 发;用 ref + defineExpose 让父组件调用子组件方法,避免父子来回传参和塞仓库。
  5. 照片墙和销售属性的 TS 类型必须区分“已有”和“新增”:后端生成的 id 类字段加 ?,不确定就加 ?,宁可宽松也不要卡住逻辑。
  6. 子组件改父组件状态一律走自定义事件(如 changeScene),不要直接改父级数据。
  7. 商品销售属性全项目最多 3 个(颜色、版本、尺码),下拉选项要做差集,满 3 个要禁用添加。
  8. 这个模块的难点不在静态结构,而在接口与数据格式;写之前先看一遍接口返回,比写完再调快得多。