Skip to content

品牌管理模块 Element UI 插件(Day2 2/2) ​

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

学习目标 ​

  • 能使用 el-table 与 el-table-column 完成品牌管理表格静态搭建,正确配置 border、width、align、label、prop 等属性。
  • 能使用默认插槽和作用域插槽定制品牌 logo、操作按钮等列内容,并通过 scope.row 获取当前行数据。
  • 能封装获取品牌分页列表的 API 方法,传入 page、limit 参数,并使用 TypeScript 泛型约束返回值类型。
  • 能定义品牌数据相关 TS 类型,包括单个品牌对象、品牌列表响应数据,并将其应用于接口返回值和组件响应式数据。
  • 能在组件挂载时调用接口获取已有品牌数据,使用 ref 存储品牌列表与总条数,并完成品牌名称、logo、操作列的渲染。
  • 能配置 el-pagination 的分页布局、当前页、每页条数、每页条数选项和总条数,并在页码或每页条数变化时重新请求品牌数据。

知识点 ​

一、静态搭建:先让表格和分页器站好位 ​

为什么需要:品牌管理页要先有稳定的页面骨架,后面接数据才不会一边改结构一边调接口。转写稿里讲师反复强调,table 是后台管理系统最常用的组件之一,分页器也几乎每个列表页都会出现。

怎么想:先把页面拆成“表格 + 分页器”。表格负责展示多条结构类似的数据,分页器负责数据量大时分批展示。不要一上来就接接口,先用静态结构把位置、列名、宽度、对齐方式调顺。

怎么做:el-table 上加 border 出现纵向边框;每一列用 el-table-column,label 是顶部标题,width 是列宽,align="center" 让该列文字居中。分页器用 el-pagination,layout 用来调整子组件位置,-> 可以把后面的子组件推到右侧。表格和分页器之间加一点 margin,避免贴得太近。

vue
<el-table :data="tableData" border style="margin: 20px 0">
  <el-table-column label="序号" width="80" align="center" />
  <el-table-column label="品牌名称" align="center" />
  <el-table-column label="品牌LOGO" align="center" />
  <el-table-column label="品牌操作" align="center" />
</el-table>

<el-pagination layout="prev, pager, next, sizes, total, ->" />

易错点:border 是 table 的属性,不是 column 的;width、align 是 column 的属性;layout 里写的是分页器子组件名,不是随便写文字。转写稿里讲师提醒:这些属性不要瞎猜,文档里已经定义好了,练的时候要翻文档。

二、没有接口时先用假数据把表格跑通 ​

为什么需要:接口还没接上时,如果直接等后端数据,表格的列、插槽、图片展示都没法验证。先用假数据能把“数据如何变成一行一行”这件事跑通。

怎么想:el-table 的 data 必须是对象数组,一条对象对应一行。第一列一般展示序号,用 type="index",从 1 开始。普通文字列用 prop 取对象字段;如果这一列要用图片、链接、按钮,默认的 div 展示满足不了,就要用作用域插槽。

怎么做:先造几条对象数据,字段按品牌来:id、tmName、logoUrl。品牌名称列写 prop="tmName";LOGO 列用插槽拿到当前行 row,再用 img 展示;操作列先放编辑、删除按钮。

vue
<el-table-column label="品牌名称" prop="tmName" align="center" />

<el-table-column label="品牌LOGO" align="center">
  <template #default="{ row }">
    <img :src="row.logoUrl" style="width: 100px; height: 50px" />
  </template>
</el-table-column>

<el-table-column label="品牌操作" align="center">
  <template #default="{ row }">
    <el-button type="primary" size="mini" icon="Edit" />
    <el-button type="danger" size="mini" icon="Delete" />
  </template>
</el-table-column>

易错点:prop 不要写成 proper;type="index" 是从 1 开始,不是数据里的 id;插槽里 row 是当前行对象,不要写成固定数据;图片地址不能用 div 直接展示,要用 img。转写稿里讲师用“图片地址用 div 展示”反例说明:默认展示结构满足不了时,才需要插槽。

三、接口与 TS 类型:先定字段再发请求 ​

为什么需要:表格要渲染真实品牌数据,必须先知道接口要什么参数、返回什么字段。TS 类型能把字段约束住,减少写错 tmName、logoUrl 这类问题。

怎么想:分页接口一般要“当前第几页”和“每页几条”。转写稿里接口参数是 page、limit,通过路径拼进去。返回值里核心是品牌数组和总条数。先写小类型“一个品牌”,再写大类型“分页返回结果”。

怎么做:定义 Trademark,新增品牌时可能没有 id,所以 id 可可选;再定义列表返回类型。请求函数接收 page、limit,拼接地址后发 GET。

ts
export interface Trademark {
  id?: number
  tmName: string
  logoUrl: string
}

export interface TrademarkListResponse {
  records: Trademark[]
  total: number
}

export const reqTrademarkList = (page: number, limit: number) => {
  return request.get<any, TrademarkListResponse>(
    `/product/trademark/${page}/${limit}`
  )
}

易错点:TS 类型要从小写到到大,先写单个品牌,再写数组和分页对象;新增品牌没有 id,所以 id 不要写死为必填;字段含义要和后端联调确认,不能自己猜。转写稿里讲师说,工作时要跟后台做数据联调,问清每个字段代表什么。

四、挂载后取第一页并渲染 ​

为什么需要:页面一打开就应该看到第一页品牌数据,而不是空表格。分页器也需要知道总条数。

怎么想:组件挂载完成后发第一次请求,拿第一页。把返回的数组存到 ref,总条数也存到 ref。模板里 table 绑定数组,分页器绑定总数。

怎么做:封装 getTrademarkList(page = 1),在 onMounted 里调用。请求回来后替换 trademarkList.value 和 total.value。讲师在 Network 里验证:初次加载请求第一页、每页 3 条,表格能渲染出品牌名称和 LOGO。

ts
const trademarkList = ref<Trademark[]>([])
const total = ref(0)

const getTrademarkList = async (page = 1) => {
  const res = await reqTrademarkList(page, pageSize.value)
  trademarkList.value = res.records
  total.value = res.total
}

onMounted(() => {
  getTrademarkList()
})

易错点:不要在钩子里直接写一堆 await,封装成函数更好复用;函数要先定义再调用;第一次请求不传参时用默认第一页;LOGO 列仍要用插槽 img,不要用普通 prop 展示图片地址。

五、分页业务:页码和每页条数变化都要重新请求 ​

为什么需要:分页器不是摆设。点第 2 页、切换每页条数,都必须重新发请求,否则表格永远停在第一页。

怎么想:页码用 v-model:current-page,每页条数用 v-model:page-size,总数用 :total。页码变化触发 current-change,条数变化触发 size-change。两个事件回调参数含义不同:一个是当前页码,一个是每页条数,不能共用一个参数表达两个意思。

怎么做:页码变化时,把当前页收集起来再请求;条数变化时,更新 pageSize 再请求。转写稿里讲师演示了合并优化:请求函数带默认参数,第一次挂载不传参就拿第一页;点页码时把新页码传进去。

vue
<el-pagination
  v-model:current-page="currentPage"
  v-model:page-size="pageSize"
  :total="total"
  layout="prev, pager, next, sizes, total, ->"
  background
  @current-change="handleCurrentChange"
  @size-change="handleSizeChange"
/>
ts
const currentPage = ref(1)
const pageSize = ref(3)

const handleCurrentChange = (page: number) => {
  currentPage.value = page
  getTrademarkList(page)
}

const handleSizeChange = (size: number) => {
  pageSize.value = size
  getTrademarkList(currentPage.value)
}

易错点:文档提示优先用 v-model 双向绑定,旧事件仍可用但要注意版本变化;size-change 的参数不是页码;切换每页条数后当前页可能越界,要关注页码重置;先保证功能,再谈合并优化。讲师在 Network 里调试:点第 2 页看请求页码是否变化,切换每页条数看 size 是否变化,刷新后默认回到第一页。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

代码 1:品牌管理模块 —— 静态搭建(table + pagination) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>品牌管理 - 静态搭建</title>
  <link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
  <style>
    #app { padding: 20px; }
  </style>
</head>
<body>
  <div id="app">
    <el-card>
      <!-- border:让表格在竖直方向也有边框 -->
      <el-table :data="tableData" border style="margin: 10px 0">
        <!-- 第一列:序号,type="index" 从 1 开始 -->
        <el-table-column type="index" label="序号" width="80" align="center" />

        <el-table-column prop="tmName" label="品牌名称" align="center" />

        <!-- logo 是图片地址,默认用 div 渲染满足不了,必须用插槽 -->
        <el-table-column label="品牌logo" align="center">
          <template #default="{ row }">
            <img :src="row.logoUrl" style="width: 100px; height: 50px; object-fit: cover" />
          </template>
        </el-table-column>

        <el-table-column label="品牌操作" align="center">
          <template #default>
            <el-button type="primary" size="small" :icon="Edit" />
            <el-button type="danger" size="small" :icon="Delete" />
          </template>
        </el-table-column>
      </el-table>

      <!-- layout 调整各子组件的位置;-> 把后面的子组件推到最右侧 -->
      <el-pagination
        v-model:current-page="pageNo"
        v-model:page-size="pageSize"
        :page-sizes="[3, 5, 7, 9]"
        layout="prev, pager, next, jumper, ->, sizes, total"
        :total="total"
        background
      />
    </el-card>
  </div>

  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
  <script src="https://unpkg.com/@element-plus/icons-vue/dist/index.iife.min.js"></script>
  <script>
    const { createApp, ref } = Vue;

    const app = createApp({
      setup() {
        const pageNo = ref(1);     // 当前页码,默认第 1 页(分页器没有 0 和负数)
        const pageSize = ref(3);   // 每页显示条数,默认 3 条
        const total = ref(0);      // 总条数,最右侧显示

        // 假数据:table 的 data 必须是数组,一条数据对应一行
        const tableData = ref([
          { id: 1,  tmName: '华为',     logoUrl: 'https://picsum.photos/seed/1/100/50' },
          { id: 2,  tmName: '小米',     logoUrl: 'https://picsum.photos/seed/2/100/50' },
          { id: 3,  tmName: '苹果',     logoUrl: 'https://picsum.photos/seed/3/100/50' },
          { id: 4,  tmName: 'OPPO',     logoUrl: 'https://picsum.photos/seed/4/100/50' },
          { id: 5,  tmName: 'vivo',     logoUrl: 'https://picsum.photos/seed/5/100/50' },
          { id: 6,  tmName: '荣耀',     logoUrl: 'https://picsum.photos/seed/6/100/50' },
          { id: 7,  tmName: '三星',     logoUrl: 'https://picsum.photos/seed/7/100/50' },
          { id: 8,  tmName: '魅族',     logoUrl: 'https://picsum.photos/seed/8/100/50' },
          { id: 9,  tmName: '一加',     logoUrl: 'https://picsum.photos/seed/9/100/50' },
          { id: 10, tmName: 'realme',   logoUrl: 'https://picsum.photos/seed/10/100/50' },
          { id: 11, tmName: '努比亚',   logoUrl: 'https://picsum.photos/seed/11/100/50' },
          { id: 12, tmName: '中兴',     logoUrl: 'https://picsum.photos/seed/12/100/50' },
          { id: 13, tmName: '联想',     logoUrl: 'https://picsum.photos/seed/13/100/50' },
          { id: 14, tmName: '摩托罗拉', logoUrl: 'https://picsum.photos/seed/14/100/50' },
          { id: 15, tmName: '诺基亚',   logoUrl: 'https://picsum.photos/seed/15/100/50' }
        ]);
        total.value = tableData.value.length;

        return {
          pageNo, pageSize, total, tableData,
          Edit: ElementPlusIconsVue.Edit,
          Delete: ElementPlusIconsVue.Delete
        };
      }
    });

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

关键行与易错点

  • :data="tableData":el-table 的数据必须是数组,一条数据对应一行;prop 写的是对象里的字段名(不带点、不带冒号),写错就是空白。
  • border:让表格竖着方向也有边框;style="margin: 10px 0" 是让表格和分页器上下拉开距离。
  • type="index":序号列自动从 1 开始,不是数据里的 id;别自己写 prop="id" 冒充序号。
  • label 是列标题,width 是列宽,align="center" 是这一列文字居中——三个都是 el-table-column 的属性,别写到 el-table 上去。
  • 插槽 #default="{ row }":Element Plus 已经在组件内部留好了坑,row 就是当前这一行的数据。什么时候用插槽?默认的 div 渲染满足不了需求时(图片地址、按钮、标签),就必须用插槽。
  • layout 里的 -> 不是子组件,是一个符号,作用是把后面的 sizes、total 顶到最右侧,不用自己写样式。
  • 按钮 size 是 small,不是 mini(mini 是 element-ui 的老写法,Element Plus 里不生效)。

代码 2:获取品牌数据渲染 + 分页业务 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>品牌管理 - 数据渲染与分页业务</title>
  <link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
  <style>
    #app { padding: 20px; }
  </style>
</head>
<body>
  <div id="app">
    <el-card>
      <el-table :data="tableData" border style="margin: 10px 0">
        <el-table-column type="index" label="序号" width="80" align="center" />
        <el-table-column prop="tmName" label="品牌名称" align="center" />

        <el-table-column label="品牌logo" align="center">
          <template #default="{ row }">
            <img :src="row.logoUrl" style="width: 100px; height: 50px; object-fit: cover" />
          </template>
        </el-table-column>

        <!-- 这一列现在没用到 row,但将来编辑/删除要用到品牌 id,所以作用域插槽先留着 -->
        <el-table-column label="品牌操作" align="center">
          <template #default="{ row }">
            <el-button type="primary" size="small" :icon="Edit" @click="onEdit(row)" />
            <el-button type="danger" size="small" :icon="Delete" @click="onDelete(row)" />
          </template>
        </el-table-column>
      </el-table>

      <el-pagination
        v-model:current-page="pageNo"
        v-model:page-size="pageSize"
        :page-sizes="[3, 5, 7, 9]"
        layout="prev, pager, next, jumper, ->, sizes, total"
        :total="total"
        background
        @current-change="getTrademarkList"
        @size-change="sizeChange"
      />
    </el-card>
  </div>

  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
  <script src="https://unpkg.com/@element-plus/icons-vue/dist/index.iife.min.js"></script>
  <script>
    const { createApp, ref, onMounted } = Vue;

    // 模拟数据库里已有的品牌(真实项目是接口返回的 records 数组)
    const ALL_TRADEMARKS = [
      { id: 1,  tmName: '华为',     logoUrl: 'https://picsum.photos/seed/1/100/50' },
      { id: 2,  tmName: '小米',     logoUrl: 'https://picsum.photos/seed/2/100/50' },
      { id: 3,  tmName: '苹果',     logoUrl: 'https://picsum.photos/seed/3/100/50' },
      { id: 4,  tmName: 'OPPO',     logoUrl: 'https://picsum.photos/seed/4/100/50' },
      { id: 5,  tmName: 'vivo',     logoUrl: 'https://picsum.photos/seed/5/100/50' },
      { id: 6,  tmName: '荣耀',     logoUrl: 'https://picsum.photos/seed/6/100/50' },
      { id: 7,  tmName: '三星',     logoUrl: 'https://picsum.photos/seed/7/100/50' },
      { id: 8,  tmName: '魅族',     logoUrl: 'https://picsum.photos/seed/8/100/50' },
      { id: 9,  tmName: '一加',     logoUrl: 'https://picsum.photos/seed/9/100/50' },
      { id: 10, tmName: 'realme',   logoUrl: 'https://picsum.photos/seed/10/100/50' },
      { id: 11, tmName: '努比亚',   logoUrl: 'https://picsum.photos/seed/11/100/50' },
      { id: 12, tmName: '中兴',     logoUrl: 'https://picsum.photos/seed/12/100/50' },
      { id: 13, tmName: '联想',     logoUrl: 'https://picsum.photos/seed/13/100/50' },
      { id: 14, tmName: '摩托罗拉', logoUrl: 'https://picsum.photos/seed/14/100/50' },
      { id: 15, tmName: '诺基亚',   logoUrl: 'https://picsum.photos/seed/15/100/50' }
    ];

    const app = createApp({
      setup() {
        const pageNo = ref(1);     // 分页器当前页码
        const pageSize = ref(3);   // 分页器每页展示条数
        const total = ref(0);      // 总条数
        const tableData = ref([]); // 当前页的品牌数据

        // 获取品牌列表:page 有默认值 1,组件一挂载不传参就是拉第一页
        const getTrademarkList = async (page = 1) => {
          pageNo.value = page; // 收集分页器注入进来的当前页码

          // 真实项目写法:
          // const res = await request.get(`/admin/product/baseTrademark/${pageNo.value}/${pageSize.value}`)
          // tableData.value = res.data.data.records
          // total.value = res.data.data.total

          const start = (pageNo.value - 1) * pageSize.value;
          tableData.value = ALL_TRADEMARKS.slice(start, start + pageSize.value);
          total.value = ALL_TRADEMARKS.length;
        };

        // 每页条数变化:先改条数,再回到第一页重新请求
        const sizeChange = (val) => {
          pageSize.value = val;
          getTrademarkList(1);
        };

        // 组件挂载完毕,先拿第一页数据
        onMounted(() => {
          getTrademarkList();
        });

        const onEdit = (row) => console.log('编辑品牌 id:', row.id);
        const onDelete = (row) => console.log('删除品牌 id:', row.id);

        return {
          pageNo, pageSize, total, tableData,
          getTrademarkList, sizeChange, onEdit, onDelete,
          Edit: ElementPlusIconsVue.Edit,
          Delete: ElementPlusIconsVue.Delete
        };
      }
    });

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

关键行与易错点

  • 不要在 onMounted 的 async 回调里直接写请求:先封装成 getTrademarkList,挂载时只负责“调一次”。onMounted 里调用的函数必须在上面先定义好(setup 相当于先执行函数体、再执行钩子,定义后使用,写在哪都一样,但写在钩子下面容易看晕)。
  • getTrademarkList(page = 1):用 ES6 默认参数兜住第一次请求——组件挂载时没传参,就拿第 1 页。这样 onMounted 和 @current-change 共用同一个函数,不用“二道贩子”再包一层。
  • @current-change="getTrademarkList":这个事件会自动把用户选中的当前页码注入进来,所以直接绑定函数即可,不需要再写一个 currentChange(val) 去转发。同时 v-model:current-page 已经做了父子双向同步,pageNo.value = page 这行是自己收集参数,保留它更合理。
  • @size-change="sizeChange":它注入的参数是每页条数,不是页码。千万不能和 current-change 共用一个函数——一个形参表达两个意思(页码 / 条数)必然出错。
  • sizeChange 里必须 getTrademarkList(1) 回到第一页:比如共 15 条、原来每页 3 条在第 4 页,改成每页 9 条后最多只有 2 页,不重置就会请求到空页。
  • type="index" 的序号是当前页内的序号,不是全局序号,也不是数据库 id;编辑/删除要用的 id 必须从作用域插槽的 row 里取。
  • 真实联调时,records(当前页数组)和 total(总条数)是接口返回的,字段名要和后台数据联调确认,TS 类型从小往大写(先定义单个品牌 Trademark,再定义 records: Trademark[],最后是整体响应类型)。

常见错误 ​

  1. 表格空白或控制台提示 data 不是数组
    现象:接口已返回数据,但表格一行都不显示。
    原因:把整个响应对象直接赋给 :data,或初始值不是数组。
    修法:取接口中的行数组(如 records)赋给表格数据,初值用 [],分页总数单独存。

  2. 点击分页页码后页面不刷新数据
    现象:页码高亮变了,表格仍是第一页内容。
    原因:只改了分页组件状态,没有把当前页带入请求,或没有触发加载函数。
    修法:用 v-model:current-page 维护当前页,在 current-change 中统一调用 loadData,请求参数取当前页。

  3. 切换每页条数后页码错乱或越界
    现象:从每页 3 条切到 9 条后,仍请求旧页码,甚至出现不存在的页码。
    原因:把 size-change 回调参数当成页码使用,且切换条数后未重置当前页。
    修法:区分 current-change 给页码、size-change 给条数;切换条数后先将当前页重置为 1,再发起请求。

  4. 品牌 logo 列显示成一串 URL 文本
    现象:表格中显示图片地址,而不是图片。
    原因:直接用 prop 渲染字符串字段。
    修法:该列改用作用域插槽,通过 row.logoUrl 绑定到 img 的 src,并限制宽高。

  5. 操作列拿不到当前行 id
    现象:点击编辑/删除时打印 undefined。
    原因:作用域插槽参数未解构,或字段名写错。
    修法:使用 #default="{ row }",绑定 row.id,先打印整行确认字段名。

  6. TS 类型在新增品牌时报 id 必填错误
    现象:新增场景不需要 id,但类型要求必须传。
    原因:新增品牌和已有品牌复用了同一个 id 必填类型。
    修法:将 id 设为可选,或拆分“已有品牌/新增品牌”类型,按场景使用。

  7. 首次加载参数为 undefined 或请求重复触发
    现象:刚进入页面请求参数不对,或一次操作发两次请求。
    原因:挂载时直接调用加载函数但未给默认参数;或 v-model 与事件回调同时触发请求。
    修法:封装 loadData(current = 1, limit = pageSize.value),统一一个入口请求;排查重复触发源。

练习 ​

  1. 完成品牌表格静态列:序号、品牌名称、品牌 logo、操作;要求表格有竖向边框、指定列宽、文字居中,并用假数据先渲染出 3 行。
    验收:页面无报错,列标题和布局符合要求。

  2. 封装 loadData(current = 1, limit = pageSize.value),要求首次挂载、点击页码、切换每页条数都走同一加载逻辑。
    验收:网络面板中每次交互只发一次请求,参数正确。

  3. 根据接口返回定义 TradeMark 与分页响应类型,并处理“新增品牌不需要 id”的场景。
    验收:已有品牌渲染不报错,新增场景类型不强制要求 id。

  4. 将 logo 列改为作用域插槽显示图片,将操作列按钮绑定到 row.id 并打印。
    验收:图片正常显示,点击编辑/删除能打印对应品牌 id。

  5. 复现“切换每页条数后页码错乱”的问题并修复。
    验收:切换条数后当前页回到第 1 页,表格数据与分页总数一致。

原视频对照 ​

#视频时长
1品牌管理模块静态搭建-227:45
2完成品牌管理模块静态搭建-318:08
3完成品牌管理模块接口书写与ts类型定义16:22
4完成已有品牌数据渲染17:22
5完成品牌管理模块的分页业务35:13

小结 ​

  1. 表格能否渲染,先看数据形态是否被转换成“行数组”,不要直接把整个接口响应塞给表格。
  2. 分页业务要围绕“当前页、每页条数、总数”形成单一数据流,所有交互最终落到同一个加载函数。
  3. 图片、按钮等非纯文本列优先用作用域插槽,不要指望 prop 解决所有展示。
  4. 事件回调参数语义必须分清:页码变化给页码,条数变化给条数,混用就会出分页 bug。
  5. TS 类型应从接口文档和真实返回反推,先定义行对象,再定义分页响应;新增/编辑场景注意可选字段。
  6. 出现重复请求时先查触发源,不要急着合并回调;合并后也不能牺牲参数语义。
  7. 静态搭建、接口渲染、分页联调分阶段验收,比一上来全混在一起更容易定位问题。
  8. 联调时务必和后端确认字段含义与分页返回结构,避免靠猜字段写代码。