Skip to content

属性管理模块业务介绍与开发全局组件分类业务(Day4 1/2) ​

本页对应 day4,共 6 段视频,约 1 小时 57 分。

学习目标 ​

  • 能够说明平台属性管理模块中一级、二级、三级分类与属性、属性值之间的业务关系,并指出分类全局组件在属性管理与 SPU 模块中的复用场景。
  • 能够将三级分类封装为全局组件并完成注册,使用 Element Plus 的 Card、Form、FormItem、Select、Option 搭建一级至三级分类静态结构,并通过 inline 控制表单项布局。
  • 能够为一级、二级、三级分类及平台属性接口编写 API 函数与 TypeScript 类型(Category、Attr、AttrValue 等),说明各接口所需分类 ID 参数及返回数据结构。
  • 能够使用 Pinia 组合式写法创建 category 仓库,定义并暴露一级分类数据、一/二/三级分类 ID 及二/三级分类数据,完成组件挂载时获取一级分类并渲染、选择时收集 ID。
  • 能够在一级、二级下拉菜单 change 事件中按需请求二级、三级分类数据,并将选中的分类 ID 与数据存入 Pinia 仓库,实现父子组件共享。
  • 能够处理分类组件切换细节:一级分类变化时清空二级、三级 ID 与三级数据,二级分类变化时清空三级 ID 与数据,并在路由组件销毁时重置仓库分类数据。

知识点 ​

业务背景:属性管理模块到底在做什么 ​

平台属性管理不是孤立页面,它要依附“一级分类→二级分类→三级分类”的层级来展示。三级分类确定后,左侧展示该分类下已有的属性,例如“手机系统”,右侧展示属性值,例如“安卓、苹果”。后台要完成属性与属性值的增删改查。

为什么需要:属性模块和后面的 SPU 模块都会用到同一套三级分类下拉结构,如果各写一遍,维护成本高。
怎么想:先判断复用点。转写稿明确说“属性模块、SPU模块都要使用”,所以把分类部分抽成全局组件。
怎么做:在 components/category/index.vue 写组件,在入口文件注册:

ts
import Category from './components/category/index.vue'
app.component('Category', Category)

易错点:Vue3 没有 Vue.component,要用 app.component;组件名和引入名不要混。

静态结构:el-form 的 inline 与 select 取值 ​

分类组件先用 el-card、el-form、el-form-item、el-select、el-option 搭静态。转写稿调试时发现表单项竖着排,原因是 el-form-item 默认占一行;给 el-form 加 inline 后才一行放多个。

vue
<el-form inline>
  <el-form-item label="一级分类">
    <el-select v-model="categoryStore.c1Id">
      <el-option
        v-for="c1 in categoryStore.c1Arr"
        :key="c1.id"
        :label="c1.name"
        :value="c1.id"
      />
    </el-select>
  </el-form-item>
</el-form>

为什么需要:下拉菜单既要展示名称,又要收集 ID,后续二级、三级请求都依赖 ID。
怎么想:label 决定用户看到什么,value 决定 v-model 收集什么。
怎么做:展示 c1.name,收集 c1.id。
易错点:不要以为 v-model 收的是 label;它收的是 value。若初始值与任何 option 不匹配,会显示空白。

API 与 TS 类型:四个接口的依赖关系 ​

转写稿把接口关系讲得很清楚:一级分类无参;二级分类要一级分类 ID;三级分类要二级分类 ID;平台属性要一级、二级、三级三个 ID。属性接口返回的是“属性对象数组”,每个属性里再有 attrValueList 属性值数组。

ts
// 一级分类
export const reqGetC1 = () => request.get<any, CategoryResponseData>('/...')
// 二级分类
export const reqGetC2 = (category1Id: number) => request.get<any, CategoryResponseData>(`/.../${category1Id}`)
// 三级分类
export const reqGetC3 = (category2Id: number) => request.get<any, CategoryResponseData>(`/.../${category2Id}`)
// 平台属性
export const reqGetAttr = (category1Id: number, category2Id: number, category3Id: number) =>
  request.get<any, AttrResponseData>(`/.../${category1Id}/${category2Id}/${category3Id}`)

类型上,Category 至少有 id: number、name: string;二级多 category1Id,三级多 category2Id。属性值类型可写 id?、value、attrId?;属性类型包含 id?、attrName、categoryLevel、category1Id、category2Id、attrValueList。

为什么需要:接口参数和返回结构决定组件收集哪些 ID、怎么渲染。
怎么想:先看接口文档,再写 TS,不要凭感觉猜字段。
怎么做:把一级、二级、三级和属性接口分别封装,返回类型统一约束。
易错点:新增属性与已有属性格式高度相似,但新增时通常没有 id,所以类型中 id 要允许可选;不要把一级分类类型硬套所有层级,二级、三级会多父级 ID 字段。

Pinia 仓库与联动请求:一级、二级、三级如何串起来 ​

转写稿比较了自定义事件和 Pinia。因为一、二、三级 ID 以及数组数据会被父组件和子组件共用,放仓库更直接。

ts
export const useCategoryStore = defineStore('category', () => {
  const c1Arr = ref<Category[]>([])
  const c1Id = ref<number | string>('')
  const c2Arr = ref<Category[]>([])
  const c2Id = ref<number | string>('')
  const c3Arr = ref<Category[]>([])
  const c3Id = ref<number | string>('')

  const getC1 = async () => {
    const result = await reqGetC1()
    c1Arr.value = result.data
  }
  // getC2、getC3 同理,分别传 c1Id、c2Id
  return { c1Arr, c1Id, c2Arr, c2Id, c3Arr, c3Id, getC1 }
})

组件挂载时先拿一级数据;一级下拉 change 后拿二级;二级 change 后拿三级。转写稿调试时用 change 事件,因为“选中值发生变化才触发”,重复点同一个值不会重复请求。三级确定后,父组件用仓库里的三个 ID 发平台属性请求。

为什么需要:上级分类变化后,下级数据必须重新拉取,否则会出现“手机通讯下还显示手机”的脏数据。
怎么想:一级变,清二级 ID、三级 ID、三级数组;二级变,清三级 ID、三级数组。
怎么做:在对应 change 里先清空旧值,再调仓库方法。
易错点:只清 ID 不清数组,旧下拉还会残留;组合式 Pinia 不能用 $reset,转写稿实测报错,需手动把各 ref 置空。路由切换销毁组件时,可在 onUnmounted 里清空仓库数据,避免下次进入还带上次选择。

分类全局组件的封装背景 ​

为什么需要:平台属性管理模块和 SPU 模块都要使用“一级、二级、三级分类”的级联选择结构,如果每个页面各写一份,后续维护会重复且容易不一致。
怎么想:先把分类选择抽成全局组件,在入口文件注册,任何路由组件都能直接使用。
怎么做:在 components/Category/index.vue 写组件,在入口文件用 app.component('Category', Category) 注册。
易错点:Vue3 没有 Vue.component,要用 app.component;全局组件名要和模板中使用的一致。

ts
import Category from './components/Category/index.vue'
app.component('Category', Category)

静态结构:先搭出三级下拉 ​

为什么需要:先把结构和交互位置搭出来,才能验证后面的数据渲染与收集逻辑。
怎么想:用 el-card 包住表单,用 el-form inline 让三个表单项在一行展示;每个表单项里放 el-select 和若干 el-option。
怎么做:一级、二级、三级各一个 el-select,el-option 的 label 决定展示文字,value 决定选中后收集到的值。
易错点:el-select 收集的是 option 的 value,不是 label;如果只写 label,后续拿不到分类 ID。el-form-item 默认占一行,要加 inline 才能一行放多个。

vue
<el-card>
  <el-form inline>
    <el-form-item label="一级分类">
      <el-select v-model="categoryStore.c1Id" @change="handleC1Change">
        <el-option
          v-for="c1 in categoryStore.c1Arr"
          :key="c1.id"
          :label="c1.name"
          :value="c1.id"
        />
      </el-select>
    </el-form-item>
    <!-- 二级、三级结构同理 -->
  </el-form>
</el-card>

API 与 TS 类型:按层级带参数 ​

为什么需要:一级分类可直接查;二级分类必须知道一级 ID;三级分类必须知道二级 ID,否则接口不知道查谁下面的数据。
怎么想:先看接口文档和返回数据,再定义 getC1、getC2、getC3。一级返回数组,二级、三级对象里会多出父级 ID 字段,可复用同一个分类类型并加可选字段。
怎么做:请求函数分别接收 c1Id、c2Id,类型先按接口约束写。
易错点:转写中反复调试发现 c1Id 可能是 string | number,而接口参数要求 number,传参处要断言或处理类型;不要凭感觉猜字段,要以接口返回为准。

ts
export const reqGetC1 = () => request.get('/admin/product/getCategory1')
export const reqGetC2 = (category1Id: number) =>
  request.get(`/admin/product/getCategory2/${category1Id}`)
export const reqGetC3 = (category2Id: number) =>
  request.get(`/admin/product/getCategory3/${category2Id}`)

用 Pinia 仓库承接分类数据 ​

为什么需要:分类组件是子组件,属性管理页面是父组件;一级、二级、三级 ID 后续父组件也要用。用仓库比层层自定义事件更直接。
怎么想:仓库里存三组数组和三个 ID:c1Arr/c1Id、c2Arr/c2Id、c3Arr/c3Id,并提供获取一、二、三级数据的方法。
怎么做:用组合式写法 defineStore('category', () => { ... }),数据用 ref,方法内发请求并赋值,最后 return 出去。
易错点:组合式仓库必须 return 数据和函数,否则组件拿不到;$reset() 只适用于选项式 API,组合式仓库不能直接用它清空。

ts
export const useCategoryStore = defineStore('category', () => {
  const c1Arr = ref<Category[]>([])
  const c1Id = ref<string | number>('')
  const c2Arr = ref<Category[]>([])
  const c2Id = ref<string | number>('')
  const c3Arr = ref<Category[]>([])
  const c3Id = ref<string | number>('')

  const getC1 = async () => {
    const res = await reqGetC1()
    c1Arr.value = res.data
  }
  const getC2 = async () => {
    const res = await reqGetC2(c1Id.value as number)
    c2Arr.value = res.data
  }
  const getC3 = async () => {
    const res = await reqGetC3(c2Id.value as number)
    c3Arr.value = res.data
  }

  return { c1Arr, c1Id, c2Arr, c2Id, c3Arr, c3Id, getC1, getC2, getC3 }
})

获取分类数据并渲染 ​

为什么需要:组件一挂载就应该展示一级分类;二级、三级不能一上来就请求,因为还没有父级 ID。
怎么想:onMounted 只通知仓库获取一级分类。用户选择一级后,再根据一级 ID 获取二级;选择二级后,再获取三级。
怎么做:一级 change 时调用 getC2,二级 change 时调用 getC3,三级 change 时只收集三级 ID,供父组件查询属性。
易错点:不要在挂载时把二、三级请求一起发;change 事件在选中值变化时触发,适合做“选中后再请求”。

ts
onMounted(() => {
  categoryStore.getC1()
})

const handleC1Change = async () => {
  categoryStore.c2Id = ''
  categoryStore.c3Id = ''
  categoryStore.c3Arr = []
  await categoryStore.getC2()
}

const handleC2Change = async () => {
  categoryStore.c3Id = ''
  categoryStore.c3Arr = []
  await categoryStore.getC3()
}

分类全局组件细节:清空上一次数据 ​

为什么需要:从“手机”切到“图书”时,如果不清空,上次选中的二级、三级 ID 和三级数组会残留,下拉框可能显示旧值,父组件也会拿到错误 ID。
怎么想:一级变化时,二级数据马上会被新请求覆盖,但二级 ID、三级 ID、三级数组要清;二级变化时,三级 ID 和三级数组要清。
怎么做:在对应 change 里先置空,再发下一级请求。路由组件销毁时,再把仓库里的分类数据整体清空。
易错点:清空时机要放在 change 中,不能等请求回来再清;组合式仓库不要调用 $reset(),要手动逐项置空。

ts
onBeforeUnmount(() => {
  categoryStore.c1Arr = []
  categoryStore.c1Id = ''
  categoryStore.c2Arr = []
  categoryStore.c2Id = ''
  categoryStore.c3Arr = []
  categoryStore.c3Id = ''
})

这样,分类全局组件完成了“挂载取一级、选择取下一级、逐级收集 ID、切换清旧数据”的闭环,父组件后续只需从仓库读取三个 ID 即可继续查询属性数据。

业务目标:先看清属性管理在做什么 ​

为什么需要:平台属性管理不是孤立页面,它要按“一级分类 → 二级分类 → 三级分类”逐级确定范围,再展示该三级分类下已有的属性与属性值。转写稿里反复强调:手机是一级,手机通讯是二级,手机是三级;三级确定后才拿属性与属性值。

怎么想:先看前台详情页的筛选结构,理解“属性”是颜色、系统等,“属性值”是红、安卓等。后台要做增删改查,但本节先完成分类全局组件、接口、TS 类型和仓库数据流转。

怎么做:把分类下拉结构封装成全局组件,因为属性管理模块和后续 SPU 模块都要用。注册方式在 Vue3 是 app.component,不是 Vue2 的 Vue.component。

易错点:不要一上来就写属性表格;先把分类联动跑通,否则拿不到 category1Id/category2Id/category3Id,属性接口无法调用。

全局分类组件静态搭建 ​

为什么需要:属性模块和 SPU 模块都有相同的三级分类选择结构,重复写会难维护。转写稿明确说“封装成一个全局组件”。

怎么想:组件放 components/Category/index.vue,在入口文件注册为全局组件,路由组件里直接使用。

怎么做:

ts
// main.ts
import Category from './components/Category/index.vue'
app.component('Category', Category)

静态结构用 el-card、el-form、el-form-item、el-select、el-option。一级、二级、三级表单项需要一行排列时,给 el-form 加 inline。

vue
<el-card>
  <el-form inline>
    <el-form-item label="一级分类">
      <el-select v-model="categoryStore.c1Id">
        <el-option
          v-for="c1 in categoryStore.c1Arr"
          :key="c1.id"
          :label="c1.name"
          :value="c1.id"
        />
      </el-select>
    </el-form-item>
    <!-- 二级、三级同理 -->
  </el-form>
</el-card>

易错点:el-option 的 label 只决定展示文本,v-model 收集的是 value。转写稿中专门调试过:选择上海收集到 2,选择深圳收集到 3;如果绑定值在选项中不存在,就显示为空。

API 与 TS 类型:四个接口要分清 ​

为什么需要:一级、二级、三级分类接口和属性接口参数不同,先定义清楚,组件里才不会乱传。

怎么想:一级不需要参数;二级需要一级 ID;三级需要二级 ID;属性与属性值需要三个 ID。转写稿说“看接口要看格式”,已有属性对象和新增属性对象格式基本一致,区别主要在是否有 id。

怎么做:

ts
// api/category.ts
export const reqGetC1 = () =>
  request.get<any, CategoryResponseData>('/admin/product/getCategory1')

export const reqGetC2 = (category1Id: number) =>
  request.get<any, CategoryResponseData>(`/admin/product/getCategory2/${category1Id}`)

export const reqGetC3 = (category2Id: number) =>
  request.get<any, CategoryResponseData>(`/admin/product/getCategory3/${category2Id}`)

export const reqGetAttr = (
  category1Id: number,
  category2Id: number,
  category3Id: number
) =>
  request.get<any, AttrResponseData>(
    `/admin/product/attrInfoList/${category1Id}/${category2Id}/${category3Id}`
  )
ts
export interface Category {
  id: number
  name: string
  category1Id?: number
  category2Id?: number
}

export interface CategoryResponseData {
  code: number
  message: string
  data: Category[]
  ok: boolean
}

export interface AttrValue {
  id?: number
  valueName: string
  attrId?: number
}

export interface Attr {
  id?: number
  attrName: string
  categoryId?: number
  category1Id?: number
  category2Id?: number
  attrValueList: AttrValue[]
}

export interface AttrResponseData {
  code: number
  message: string
  data: Attr[]
  ok: boolean
}

易错点:二级分类数据比一级多 category1Id,三级多 category2Id;TS 里可用可选属性兼容。属性值对象里的 attrId 表示它归属于哪个属性。

Pinia 仓库管理分类数据与联动 ​

为什么需要:分类组件是子组件,属性区域在父组件;三级 ID 要给父组件发属性请求。转写稿比较了自定义事件、props 和仓库,最后选择 Pinia:数据放仓库,父子都能取。

怎么想:一级挂载时请求;一级 change 时清空二级、三级旧数据并请求二级;二级 change 时清空三级并请求三级。每次选择都把 ID 收集到仓库。

怎么做:

ts
export const useCategoryStore = defineStore('category', () => {
  const c1Arr = ref<Category[]>([])
  const c1Id = ref<string | number>('')
  const c2Arr = ref<Category[]>([])
  const c2Id = ref<string | number>('')
  const c3Arr = ref<Category[]>([])
  const c3Id = ref<string | number>('')

  const getC1 = async () => {
    const res = await reqGetC1()
    c1Arr.value = res.data
  }
  const getC2 = async () => {
    const res = await reqGetC2(c1Id.value as number)
    c2Arr.value = res.data
  }
  const getC3 = async () => {
    const res = await reqGetC3(c2Id.value as number)
    c3Arr.value = res.data
  }

  return { c1Arr, c1Id, c2Arr, c2Id, c3Arr, c3Id, getC1, getC2, getC3 }
})

组件中:

ts
onMounted(() => {
  categoryStore.getC1()
})

const handleC1Change = () => {
  categoryStore.c2Id = ''
  categoryStore.c3Id = ''
  categoryStore.c3Arr = []
  categoryStore.getC2()
}

const handleC2Change = () => {
  categoryStore.c3Id = ''
  categoryStore.getC3()
}

易错点:切换一级时必须清空二级 ID、三级 ID 和三级数组;切换二级时清空三级 ID。转写稿调试时发现,不清空会出现“二级数据没了但 ID 还在”的错位。组合式 Pinia 中 $reset 不生效,转写稿验证后改为手动清空;$reset 只适用于选项式写法。

路由切换与销毁清空 ​

为什么需要:从属性管理切到其他路由再回来,仓库数据还在,容易残留上一次选择。

怎么想:路由组件切换会销毁子组件,可在 onUnmounted 清空仓库分类数据。

怎么做:

ts
onUnmounted(() => {
  categoryStore.c1Arr = []
  categoryStore.c2Arr = []
  categoryStore.c3Arr = []
  categoryStore.c1Id = ''
  categoryStore.c2Id = ''
  categoryStore.c3Id = ''
})

易错点:不要误以为组合式仓库能直接 $reset;要么手动清,要么改用选项式写法。父组件后续用三个 ID 调 reqGetAttr 展示属性与属性值,所以分类 ID 的收集与清空必须先稳定。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 1:分类全局组件的静态搭建(el-card + el-form inline + 三级 el-select) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>01-分类全局组件静态搭建</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">
    <!-- 相当于 components/category/index.vue 里的模板 -->
    <el-card>
      <!-- inline:让多个 el-form-item 在一行里横向排布 -->
      <el-form inline>
        <el-form-item label="一级分类">
          <el-select v-model="c1Id" placeholder="请选择" style="width: 200px">
            <el-option
              v-for="item in c1Arr"
              :key="item.id"
              :label="item.name"
              :value="item.id"
            />
          </el-select>
        </el-form-item>

        <el-form-item label="二级分类">
          <el-select v-model="c2Id" placeholder="请选择" style="width: 200px">
            <el-option
              v-for="item in c2Arr"
              :key="item.id"
              :label="item.name"
              :value="item.id"
            />
          </el-select>
        </el-form-item>

        <el-form-item label="三级分类">
          <el-select v-model="c3Id" placeholder="请选择" style="width: 200px">
            <el-option
              v-for="item in c3Arr"
              :key="item.id"
              :label="item.name"
              :value="item.id"
            />
          </el-select>
        </el-form-item>
      </el-form>
    </el-card>

    <p style="margin-top: 16px">收集到的值:{{ c1Id }} / {{ c2Id }} / {{ c3Id }}</p>
  </div>

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

    const app = createApp({
      setup() {
        // 静态数据先占位,后面换成仓库里的真实数据
        const c1Arr = ref([
          { id: 1, name: '图书' },
          { id: 2, name: '手机' },
          { id: 3, name: '数码' },
        ]);
        const c2Arr = ref([
          { id: 13, name: '手机通讯' },
          { id: 14, name: '运营商' },
          { id: 15, name: '手机配件' },
        ]);
        const c3Arr = ref([
          { id: 61, name: '手机' },
          { id: 62, name: '对讲机' },
        ]);

        // v-model 绑定的值:收集的是 option 的 value(ID),不是 label
        const c1Id = ref(2);
        const c2Id = ref(13);
        const c3Id = ref(61);

        return { c1Arr, c2Arr, c3Arr, c1Id, c2Id, c3Id };
      },
    });

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

要点与易错点:

  • el-form 必须加 inline,否则每个 el-form-item 会像表格项一样独占一行,三级下拉会竖着排下来——转写稿里"因滥属性"说的就是这个 inline。
  • el-select 收集数据写在 v-model 上,收集的是选中 el-option 的 value,不是 label;label 只决定用户看到的内容。所以这里 :value="item.id" 绑定 ID,因为后面发二级、三级请求都要拿一级、二级的 ID。
  • v-model 是双向绑定:把 c1Id 初始值写成 2 会自动选中"手机";写成下拉里不存在的值(如 8)则显示空白;写成空串 '' 就显示 placeholder。
  • :key="item.id" 不要用 index,避免后续数据变化时复用错乱。

示例 2:Pinia 仓库(组合式写法)管理三级分类数据 + 三级联动收集 ID ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>02-分类全局组件业务:Pinia 仓库 + 三级联动</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>
  <script src="https://unpkg.com/pinia"></script>
</head>
<body>
  <div id="app">
    <el-card>
      <el-form inline>
        <el-form-item label="一级分类">
          <el-select
            v-model="categoryStore.c1Id"
            placeholder="请选择"
            style="width: 200px"
            @change="handleC1Change"
          >
            <el-option
              v-for="item in categoryStore.c1Arr"
              :key="item.id"
              :label="item.name"
              :value="item.id"
            />
          </el-select>
        </el-form-item>

        <el-form-item label="二级分类">
          <el-select
            v-model="categoryStore.c2Id"
            placeholder="请选择"
            style="width: 200px"
            @change="handleC2Change"
          >
            <el-option
              v-for="item in categoryStore.c2Arr"
              :key="item.id"
              :label="item.name"
              :value="item.id"
            />
          </el-select>
        </el-form-item>

        <el-form-item label="三级分类">
          <el-select
            v-model="categoryStore.c3Id"
            placeholder="请选择"
            style="width: 200px"
          >
            <el-option
              v-for="item in categoryStore.c3Arr"
              :key="item.id"
              :label="item.name"
              :value="item.id"
            />
          </el-select>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card style="margin-top: 16px">
      <p>一级分类ID:{{ categoryStore.c1Id }}</p>
      <p>二级分类ID:{{ categoryStore.c2Id }}</p>
      <p>三级分类ID:{{ categoryStore.c3Id }}</p>
    </el-card>
  </div>

  <script>
    const { createApp, ref, onMounted } = Vue;
    const { createPinia, defineStore } = Pinia;

    /* ===== 模拟后端接口:真实项目里就是 request.get('/admin/product/getCategory1') 等 ===== */
    const DB = {
      c1: [
        { id: 1, name: '图书' },
        { id: 2, name: '手机' },
        { id: 3, name: '数码' },
      ],
      c2: {
        1: [{ id: 11, name: '文学' }, { id: 12, name: '历史' }],
        2: [{ id: 13, name: '手机通讯' }, { id: 14, name: '运营商' }, { id: 15, name: '手机配件' }],
        3: [{ id: 21, name: '摄影摄像' }],
      },
      c3: {
        11: [{ id: 111, name: '小说' }],
        12: [{ id: 121, name: '通史' }],
        13: [{ id: 61, name: '手机' }, { id: 62, name: '对讲机' }],
        14: [{ id: 63, name: '合约机' }],
        15: [{ id: 64, name: '充电器' }],
        21: [{ id: 211, name: '单反相机' }],
      },
    };

    const reqGetC1 = () =>
      new Promise((resolve) => setTimeout(() => resolve(DB.c1), 100));
    // 二级分类需要一级分类的 ID
    const reqGetC2 = (c1Id) =>
      new Promise((resolve) => setTimeout(() => resolve(DB.c2[c1Id] || []), 100));
    // 三级分类需要二级分类的 ID
    const reqGetC3 = (c2Id) =>
      new Promise((resolve) => setTimeout(() => resolve(DB.c3[c2Id] || []), 100));

    /* ===== 仓库:组合式 API 写法(setup 函数 + return) ===== */
    const useCategoryStore = defineStore('category', () => {
      const c1Arr = ref([]);
      const c1Id = ref('');

      const c2Arr = ref([]);
      const c2Id = ref('');

      const c3Arr = ref([]);
      const c3Id = ref('');

      // 一级分类数据
      const getC1 = async () => {
        c1Arr.value = await reqGetC1();
      };
      // 二级分类数据:必须带上一级分类 ID
      const getC2 = async (id) => {
        c2Arr.value = await reqGetC2(id);
      };
      // 三级分类数据:必须带上二级分类 ID
      const getC3 = async (id) => {
        c3Arr.value = await reqGetC3(id);
      };

      // 不 return 出去,组件里就拿不到
      return { c1Arr, c1Id, c2Arr, c2Id, c3Arr, c3Id, getC1, getC2, getC3 };
    });

    const app = createApp({
      setup() {
        const categoryStore = useCategoryStore();

        // 组件挂载时只捞一级分类
        onMounted(() => {
          categoryStore.getC1();
        });

        // 一级分类下拉 change:清空上一次的二级ID、三级ID、三级数组,再请求二级
        const handleC1Change = () => {
          categoryStore.c2Id = '';
          categoryStore.c3Id = '';
          categoryStore.c3Arr = [];
          categoryStore.getC2(categoryStore.c1Id);
        };

        // 二级分类下拉 change:清空上一次的三级ID,再请求三级
        const handleC2Change = () => {
          categoryStore.c3Id = '';
          categoryStore.getC3(categoryStore.c2Id);
        };

        return { categoryStore, handleC1Change, handleC2Change };
      },
    });

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

要点与易错点:

  • 仓库用组合式写法时,所有数据和方法都必须 return 出去,否则组件里拿不到(转写稿反复强调"不 return 组件用不了")。
  • 请求时机:onMounted 里只发一级分类请求;二级要等一级选中(@change)才发;三级要等二级选中才发——因为二级接口要一级 ID、三级接口要二级 ID。
  • 用 @change 而不是 watch 监听,好处是选中值真正变化时才触发,重复点同一个值不会重复发请求。
  • handleC1Change 里必须先清空 c2Id、c3Id、c3Arr 再请求二级:c2Arr 不用手动清,请求回来会覆盖;但 c2Id 不清就会出现"换了二级分类数据,下拉框里却还挂着上一次的 ID",因为 v-model 没有匹配项时那一栏就显示空白。
  • 注意接口层级:一级分类返回 { id, name },二级多一个 category1Id,三级再多一个 category2Id,同一个类型可以用可选字段(?)兼容三级。

示例 3:组件销毁时清空仓库数据(路由切换场景) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>03-销毁时清空仓库数据</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>
  <script src="https://unpkg.com/pinia"></script>
</head>
<body>
  <div id="app">
    <el-button type="primary" @click="visible = !visible">
      {{ visible ? '离开属性管理页(模拟路由切换)' : '进入属性管理页' }}
    </el-button>

    <category-page v-if="visible" />

    <el-card style="margin-top: 16px">
      <pre>仓库快照:{{ snapshot }}</pre>
    </el-card>
  </div>

  <script>
    const { createApp, ref, computed, onMounted, onBeforeUnmount } = Vue;
    const { createPinia, defineStore } = Pinia;

    const reqGetC1 = () =>
      new Promise((resolve) =>
        setTimeout(
          () =>
            resolve([
              { id: 1, name: '图书' },
              { id: 2, name: '手机' },
            ]),
          100
        )
      );

    /* ===== 仓库:组合式 API 写法 ===== */
    const useCategoryStore = defineStore('category', () => {
      const c1Arr = ref([]);
      const c1Id = ref('');
      const c2Arr = ref([]);
      const c2Id = ref('');
      const c3Arr = ref([]);
      const c3Id = ref('');

      const getC1 = async () => {
        c1Arr.value = await reqGetC1();
      };

      return { c1Arr, c1Id, c2Arr, c2Id, c3Arr, c3Id, getC1 };
    });

    /* ===== 分类全局组件(对应 components/category/index.vue) ===== */
    const CategoryPage = {
      setup() {
        const categoryStore = useCategoryStore();

        onMounted(() => {
          categoryStore.getC1();
        });

        // 路由切换 → 路由组件销毁 → 子组件跟着销毁,在这里清空仓库
        onBeforeUnmount(() => {
          categoryStore.c1Arr = [];
          categoryStore.c1Id = '';
          categoryStore.c2Arr = [];
          categoryStore.c2Id = '';
          categoryStore.c3Arr = [];
          categoryStore.c3Id = '';
        });

        return { categoryStore };
      },
      template: `
        <el-card style="margin-top: 16px">
          <el-form inline>
            <el-form-item label="一级分类">
              <el-select v-model="categoryStore.c1Id" placeholder="请选择" style="width: 200px">
                <el-option
                  v-for="item in categoryStore.c1Arr"
                  :key="item.id"
                  :label="item.name"
                  :value="item.id"
                />
              </el-select>
            </el-form-item>
            <el-form-item label="二级分类">
              <el-select v-model="categoryStore.c2Id" placeholder="请选择" style="width: 200px">
                <el-option
                  v-for="item in categoryStore.c2Arr"
                  :key="item.id"
                  :label="item.name"
                  :value="item.id"
                />
              </el-select>
            </el-form-item>
            <el-form-item label="三级分类">
              <el-select v-model="categoryStore.c3Id" placeholder="请选择" style="width: 200px">
                <el-option

常见错误 ​

  1. 现象:模板里用 <category /> 不渲染或报未注册 → 原因:全局组件未在入口 app.component 注册,或注册名与模板名不一致 → 修法:入口统一注册,检查注册名、模板名、文件路径拼写。
  2. 现象:选了一级分类,二级请求参数仍是旧 ID 或 undefined → 原因:v-model 绑到了 label 或局部字段,未收集 value/ID,类型 number|string 未收窄 → 修法:el-option :value 绑 ID,v-model 绑 store 中对应 ID,发请求前判空并统一类型。
  3. 现象:切换一级分类后,三级下拉仍显示上次选项/残留 ID → 原因:只更新二级数据,未清空 C2ID、C3ID、三级数组 → 修法:一级 change 先清二三级 ID 和三级数组,再拉二级;二级 change 清三级 ID 和数组再拉三级。
  4. 现象:组合式 Pinia store 调 $reset 报错 → 原因:$reset 只适用于选项式 store → 修法:组合式 store 显式重置各 ref/数组,或改用选项式写法。
  5. 现象:一进页面就发二级/三级请求,参数为空或重复请求 → 原因:请求写在 onMounted 或 watch 初值,未按依赖变化触发 → 修法:挂载只拉一级,二级由一级 change 触发,三级由二级 change 触发。
  6. 现象:仓库里已有数据,组件却拿不到 → 原因:组合式 store 返回对象漏写字段/方法,或解构后失去响应性 → 修法:return 全部需要字段,使用 storeToRefs 或直接 store.xxx,不要直接解构。
  7. 现象:属性接口 TS 报字段不存在或 ID 类型不匹配 → 原因:复用分类类型时可选字段未处理,接口参数 string/number 混用 → 修法:可选字段标 ?,参数统一类型,调用处显式转换并判空。
  8. 现象:同一分类反复点击仍发请求 → 原因:用 click 代替 change,或未判断值是否变化 → 修法:使用 change 事件,必要时比较新旧值,避免重复请求。

练习 ​

  1. 将 category 封装为全局组件并在入口注册,在属性管理和 SPU 两个路由组件中复用,验证注册名与模板名一致。
  2. 完成三级联动:一级挂载拉取,一级 change 拉二级,二级 change 拉三级;把 C1ID/C2ID/C3ID 存入 Pinia,并让父组件可读取。
  3. 增加切换清空逻辑:一级变化清 C2ID/C3ID/C3 数组;二级变化清 C3ID/C3 数组;验证不会用旧 ID 发请求。
  4. 为属性接口补齐 TS 类型与请求函数,父组件拿到三级 ID 后请求属性列表并渲染;ID 为空时不发请求。
  5. 增加路由离开重置:在属性组件销毁时重置分类 store(组合式写法显式重置),再次进入验证无残留数据。

原视频对照 ​

#视频时长
1属性管理模块业务的介绍05:14
2全局组件分类的静态的搭建10:22
3书写属性管理模块API与数据ts类型40:30
4pinia仓库管理数据之一级分类26:23
5获取分类全局组件二级、三级分类的数据并且收集数据的分类的id18:30
6完成属性管理模块分类全局组件的业务16:29

小结 ​

  1. 分类组件是跨模块复用资产,注册、命名、数据来源必须统一。
  2. 联动请求由依赖变化驱动,二三级请求不能抢跑。
  3. 分类 ID 只保留一份可信来源,展示与接口参数都从它取。
  4. 切换上级必须清下级,清 ID 与清展示数据要成对。
  5. 组合式 Pinia 的重置要显式实现,不要照搬选项式 API。
  6. 先定 API 与 TS 类型再写页面,能减少参数和字段类型问题。
  7. 用 change 事件和条件判断控制请求频率,避免重复请求。
  8. 调试时先核对 store 中的 ID、请求参数、接口返回三者是否一致。