主题
品牌管理模块 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:品牌管理模块 —— 静态搭建(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[],最后是整体响应类型)。
常见错误
表格空白或控制台提示
data不是数组
现象:接口已返回数据,但表格一行都不显示。
原因:把整个响应对象直接赋给:data,或初始值不是数组。
修法:取接口中的行数组(如records)赋给表格数据,初值用[],分页总数单独存。点击分页页码后页面不刷新数据
现象:页码高亮变了,表格仍是第一页内容。
原因:只改了分页组件状态,没有把当前页带入请求,或没有触发加载函数。
修法:用v-model:current-page维护当前页,在current-change中统一调用loadData,请求参数取当前页。切换每页条数后页码错乱或越界
现象:从每页 3 条切到 9 条后,仍请求旧页码,甚至出现不存在的页码。
原因:把size-change回调参数当成页码使用,且切换条数后未重置当前页。
修法:区分current-change给页码、size-change给条数;切换条数后先将当前页重置为 1,再发起请求。品牌 logo 列显示成一串 URL 文本
现象:表格中显示图片地址,而不是图片。
原因:直接用prop渲染字符串字段。
修法:该列改用作用域插槽,通过row.logoUrl绑定到img的src,并限制宽高。操作列拿不到当前行
id
现象:点击编辑/删除时打印undefined。
原因:作用域插槽参数未解构,或字段名写错。
修法:使用#default="{ row }",绑定row.id,先打印整行确认字段名。TS 类型在新增品牌时报
id必填错误
现象:新增场景不需要id,但类型要求必须传。
原因:新增品牌和已有品牌复用了同一个id必填类型。
修法:将id设为可选,或拆分“已有品牌/新增品牌”类型,按场景使用。首次加载参数为
undefined或请求重复触发
现象:刚进入页面请求参数不对,或一次操作发两次请求。
原因:挂载时直接调用加载函数但未给默认参数;或v-model与事件回调同时触发请求。
修法:封装loadData(current = 1, limit = pageSize.value),统一一个入口请求;排查重复触发源。
练习
完成品牌表格静态列:序号、品牌名称、品牌 logo、操作;要求表格有竖向边框、指定列宽、文字居中,并用假数据先渲染出 3 行。
验收:页面无报错,列标题和布局符合要求。封装
loadData(current = 1, limit = pageSize.value),要求首次挂载、点击页码、切换每页条数都走同一加载逻辑。
验收:网络面板中每次交互只发一次请求,参数正确。根据接口返回定义
TradeMark与分页响应类型,并处理“新增品牌不需要id”的场景。
验收:已有品牌渲染不报错,新增场景类型不强制要求id。将 logo 列改为作用域插槽显示图片,将操作列按钮绑定到
row.id并打印。
验收:图片正常显示,点击编辑/删除能打印对应品牌id。复现“切换每页条数后页码错乱”的问题并修复。
验收:切换条数后当前页回到第 1 页,表格数据与分页总数一致。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 品牌管理模块静态搭建-2 | 27:45 |
| 2 | 完成品牌管理模块静态搭建-3 | 18:08 |
| 3 | 完成品牌管理模块接口书写与ts类型定义 | 16:22 |
| 4 | 完成已有品牌数据渲染 | 17:22 |
| 5 | 完成品牌管理模块的分页业务 | 35:13 |
小结
- 表格能否渲染,先看数据形态是否被转换成“行数组”,不要直接把整个接口响应塞给表格。
- 分页业务要围绕“当前页、每页条数、总数”形成单一数据流,所有交互最终落到同一个加载函数。
- 图片、按钮等非纯文本列优先用作用域插槽,不要指望
prop解决所有展示。 - 事件回调参数语义必须分清:页码变化给页码,条数变化给条数,混用就会出分页 bug。
- TS 类型应从接口文档和真实返回反推,先定义行对象,再定义分页响应;新增/编辑场景注意可选字段。
- 出现重复请求时先查触发源,不要急着合并回调;合并后也不能牺牲参数语义。
- 静态搭建、接口渲染、分页联调分阶段验收,比一上来全混在一起更容易定位问题。
- 联调时务必和后端确认字段含义与分页返回结构,避免靠猜字段写代码。