三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Vue3 还原一个企业级后台-07-通用组件封装

Vue3 还原一个企业级后台-07-通用组件封装

通用组件:分页器、表格、对话框的封装

业务页面 80% 都是列表 + 弹窗 + 表单。如果把 Element Plus 的原生组件比作"砖头",那通用组件就是用砖头砌好的"预制墙"——拿来直接拼,不用每次都从和泥开始。


一、为什么需要自建通用组件

Element Plus 已经很好了——表格、分页器、对话框都有。为什么还要自己包一层?

1.1 设计稿和 Element Plus 默认样式不完全一致

Figma 里的分页器长这样:

  • 按钮是 32×32px 的圆角方块(8px 圆角)
  • 选中态:主色#0073FB背景 + 白色文字
  • 默认态:白色背景 +#333文字
  • 页数显示:总页数为 3 时只展示 3 页,为 30 时显示省略号

而 Element Plus 的el-pagination默认样式:

  • 按钮略高、圆角更小
  • 选中态颜色是 Element Plus 的默认蓝色#409EFF(我们覆写了主题,但间距/字号等细节仍有差异)
  • 布局的间距和设计稿不一致

差距不大,但设计稿是像素级还原的目标——每个像素的差异都会在验收时被指出来。

1.2 业务定制需求

一个典型的企业级列表页需要:

  • 表格的复选框列(全选/反选/跨页选择)
  • 序号列(自动编号,第 1 页第 5 行显示 “5”,第 2 页第 1 行显示 “31”)
  • 行操作列(编辑/删除/详情 等按钮,不同页面的操作不一样)
  • 表格内置分页器(不用在每个页面单独引入分页)

如果每个列表页都写一遍这些逻辑——选中的el-table-column配置、序号计算、分页事件——代码复制的成本比封装还高。

1.3 统一 API,降低团队学习成本

Element Plus 组件的 API 很灵活,但灵活的反面是——同一个"分页"功能,不同人写出来的代码完全不同。一个人用:current-page+:total,另一个人用v-model:current-page+:page-count。代码能跑,但接手的人要停下来理解"为什么这样写"。

封装一层通用组件,强制统一 API——让所有人都用同一种方式写列表页。


二、AppPagination:分页器的"贴皮手术"

2.1 设计稿数据

从 Figma 提取的分页器参数(componentSets232:38):

属性
按钮尺寸32×32px
按钮圆角8px
选中态背景#0073FB
选中态文字#FFFFFF
默认态背景#FFFFFF
默认态边框#DCDFE6
按钮间距8px

2.2 组件实现

AppPagination 不是从零写一个分页器——那太蠢了。而是在el-pagination的基础上,用 CSS 变量和深度选择器精确覆盖样式:

<!-- components/AppPagination.vue --> <template> <div class="app-pagination"> <el-pagination v-model:current-page="current" :page-size="pageSize" :page-sizes="pageSizes" :total="total" :layout="layout" :background="true" @current-change="handleChange" @size-change="handleSizeChange" /> </div> </template> <script setup> import { computed } from 'vue' const props = defineProps({ modelValue: { type: Number, default: 1 }, total: { type: Number, default: 0 }, pageSize: { type: Number, default: 30 }, pageSizes: { type: Array, default: () => [10, 20, 30, 50] }, layout: { type: String, default: 'total, sizes, prev, pager, next, jumper' } }) const emit = defineEmits(['update:modelValue', 'change', 'size-change']) const current = computed({ get: () => props.modelValue, set: (val) => emit('update:modelValue', val) }) function handleChange(page) { emit('change', page) } function handleSizeChange(size) { emit('size-change', size) } </script>

2.3 样式覆写的关键

样式覆写不是简单改颜色,而是把 Element Plus 默认的选中态按钮从"蓝色高亮"变成"主色填充 + 阴影":

<style lang="scss" scoped> .app-pagination { --app-pagination-size: 32px; :deep(.el-pagination) { .btn-prev, .btn-next, .el-pager li { width: var(--app-pagination-size); height: var(--app-pagination-size); line-height: var(--app-pagination-size); border-radius: 8px; font-size: 14px; color: #333333; background: #FFFFFF; border: 1px solid #DCDFE6; } .el-pager li.is-active { background-color: #0073FB; color: #FFFFFF; border-color: #0073FB; box-shadow: 0 2px 6px rgba(0, 115, 251, 0.25); } .btn-prev, .btn-next { font-weight: normal; } // 禁用态的前后翻页按钮 .btn-prev.is-disabled, .btn-next.is-disabled { color: #C0C4CC; background: #F5F7FA; } } } </style>

使用方式:

<AppPagination v-model="page" :total="total" @change="fetchData" />

和 Element Plus 原生分页器的 API 几乎一样——但样式已经是 Figma 的效果。使用者不需要知道分页器按钮是 32px 还是 30px、圆角是 8px 还是 6px,他只需要传totalv-model


三、AppTable:一张表格解决所有列表页

AppTable 是整个系列中最重要的通用组件——它直接决定了后续 3 个业务模块(API 注册管理、模型汇聚、模型标准发布)的开发效率。

3.1 设计目标

AppTable 的一个 use case 应该覆盖一个列表页 90% 的需求:

<AppTable :data="tableData" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:selection="selectedRows" @page-change="onPageChange" > <template #action="{ row }"> <el-button type="primary" link @click="edit(row)">编辑</el-button> <el-button type="danger" link @click="del(row)">删除</el-button> </template> </AppTable>

一行 template、几行 script,一个增删改查列表页就出来了。

3.2 Props 设计

AppTable 的 Props 要同时兼顾 Element Plus 表格的能力和业务定制的需求:

Prop类型默认值说明
dataArray[]表格数据
columnsArray[]列配置(见下方)
loadingBooleanfalse加载状态
totalNumber0数据总数(分页用)
pageNumber1当前页码
pageSizeNumber30每页条数
selectionArray[]复选框选中行
showIndexBooleantrue是否显示序号列
showSelectionBooleantrue是否显示复选框列
rowKeyString'id'行唯一键

columns的每一项:

{prop:'name',// 字段名(对应 data 里的 key)label:'API 名称',// 列标题width:200,// 列宽(可选,不传则自动分配)align:'left',// 对齐方式formatter:(row)=>row.name.toUpperCase(),// 格式化函数slot:'status'// 自定义插槽名(用于需要渲染组件而非文本的列)}

3.3 完整实现

<!-- components/AppTable.vue --> <template> <div class="app-table-wrap"> <el-table :data="data" :loading="loading" :row-key="rowKey" @selection-change="onSelectionChange" border stripe style="width: 100%" > <!-- 复选框列 --> <el-table-column v-if="showSelection" type="selection" width="50" align="center" :reserve-selection="true" /> <!-- 序号列 --> <el-table-column v-if="showIndex" type="index" width="60" align="center" label="序号" :index="indexMethod" /> <!-- 动态列 --> <el-table-column v-for="col in columns" :key="col.prop" :prop="col.prop" :label="col.label" :width="col.width" :align="col.align || 'left'" :show-overflow-tooltip="true" > <template v-if="col.slot" #default="{ row }"> <slot :name="col.slot" :row="row" /> </template> <template v-else-if="col.formatter" #default="{ row }"> {{ col.formatter(row) }} </template> </el-table-column> <!-- 操作列 --> <el-table-column v-if="$slots.action" label="操作" :width="actionWidth" align="center" fixed="right" > <template #default="{ row }"> <slot name="action" :row="row" /> </template> </el-table-column> </el-table> <!-- 内置分页器 --> <div class="app-table-pagination" v-if="total > 0"> <AppPagination v-model="currentPage" :total="total" :page-size="pageSize" @change="$emit('page-change', $event)" /> </div> </div> </template> <script setup> import { computed } from 'vue' import AppPagination from './AppPagination.vue' const props = defineProps({ data: { type: Array, default: () => [] }, columns: { type: Array, default: () => [] }, loading: { type: Boolean, default: false }, total: { type: Number, default: 0 }, page: { type: Number, default: 1 }, pageSize: { type: Number, default: 30 }, selection: { type: Array, default: () => [] }, showIndex: { type: Boolean, default: true }, showSelection: { type: Boolean, default: true }, rowKey: { type: String, default: 'id' }, actionWidth: { type: [String, Number], default: 180 } }) const emit = defineEmits([ 'update:page', 'update:selection', 'page-change', 'selection-change', 'size-change' ]) const currentPage = computed({ get: () => props.page, set: (val) => emit('update:page', val) }) // 序号列:跨页自动累加 function indexMethod(index) { return (props.page - 1) * props.pageSize + index + 1 } function onSelectionChange(rows) { emit('update:selection', rows) emit('selection-change', rows) } </script>

3.4 几个设计决策

为什么分页器内置在 AppTable 里?

数据显示和分页切换是强绑定的——有表格的地方 90% 有分页,有分页的地方 100% 有表格。一个业务页面不需要关心"分页器该放表格上面还是下面"“分页事件怎么和表格刷新联动”——这些都被 AppTable 封装了。

使用者只需要传datacolumnstotalpage,然后监听page-change事件刷新数据。分页器的位置、样式、联动——全在 AppTable 内部消化。

为什么序号列用indexMethod计算而不直接用type="index"默认值?

Element Plus 的type="index"默认序号从 1 开始,但第 2 页的第一行也显示 “1”。用户看到的是"序号列有重复",而不是"这是第二页的第一行"。

indexMethod把页码纳入了计算:(page - 1) * pageSize + index + 1。第 1 页显示 1~30,第 2 页显示 31~60。

reserve-selection的作用

:reserve-selection="true"让复选框在翻页后保持选中状态。不加这个属性,用户在第 1 页选了 3 条,翻到第 2 页选了 2 条,再翻回第 1 页——之前的 3 条全掉了。对批量操作(跨页选择后批量删除)来说,这是致命 bug。


四、AppDialog:弹窗的"统一风格"方案

设计稿里的弹窗和 Element Plus 默认弹窗的差异:

属性Figma 设计稿Element Plus 默认
圆角14px8px
头部深色背景条 + 白色标题无背景条
关闭按钮圆形背景 + × 图标纯图标
底部按钮确认(主色)+ 取消(默认)默认无底部

如果每个页面用el-dialog时都覆写一遍这些样式,代码重复量比 AppPagination 还大——因为弹窗的样式差异比分页器多得多。

4.1 组件实现

<!-- components/AppDialog.vue --> <template> <el-dialog :model-value="modelValue" :title="title" :width="width" :close-on-click-modal="closeOnClickModal" :destroy-on-close="true" class="app-dialog" @update:model-value="$emit('update:modelValue', $event)" > <!-- 内容区 --> <div class="app-dialog-body"> <slot /> </div> <!-- 底部按钮区 --> <template #footer v-if="showFooter"> <div class="app-dialog-footer"> <el-button @click="$emit('cancel')" :disabled="loading"> {{ cancelText }} </el-button> <el-button type="primary" @click="$emit('confirm')" :loading="loading"> {{ confirmText }} </el-button> </div> </template> </el-dialog> </template> <script setup> defineProps({ modelValue: { type: Boolean, default: false }, title: { type: String, default: '' }, width: { type: String, default: '600px' }, loading: { type: Boolean, default: false }, showFooter: { type: Boolean, default: true }, confirmText: { type: String, default: '确认' }, cancelText: { type: String, default: '取消' }, closeOnClickModal: { type: Boolean, default: false } }) defineEmits(['update:modelValue', 'confirm', 'cancel']) </script>

4.2 样式覆写

<style lang="scss" scoped> .app-dialog { :deep(.el-dialog) { border-radius: 14px; overflow: hidden; .el-dialog__header { height: 56px; padding: 0 24px; margin: 0; background: linear-gradient(135deg, #0073FB, #409EFF); display: flex; align-items: center; } .el-dialog__title { color: #ffffff; font-size: 16px; font-weight: 600; } .el-dialog__headerbtn { top: 14px; right: 20px; width: 28px; height: 28px; .el-dialog__close { color: #ffffff; font-size: 18px; font-weight: bold; } } .el-dialog__body { padding: 24px; } .el-dialog__footer { padding: 0 24px 24px; } } } .app-dialog-footer { display: flex; justify-content: flex-end; gap: 12px; } </style>

4.3 使用方式

<template> <AppDialog v-model="dialogVisible" title="注册 API" :loading="submitting" @confirm="handleSubmit" @cancel="dialogVisible = false" > <el-form :model="form" label-width="100px"> <el-form-item label="API 名称"> <el-input v-model="form.name" /> </el-form-item> <!-- 其他表单项 --> </el-form> </AppDialog> </template>

AppDialog屏蔽了弹窗的样式细节——圆角 14px、头部渐变条、底部按钮间距——使用者只需要关注弹窗里有什么表单、点了确认之后做什么。


五、其他通用组件一览

除了三大核心组件,项目中还有几个轻量级的通用组件,它们虽然代码量不大,但在保证一致性上价值很高:

5.1 StatusTag:状态标签

后台系统里"状态"无处不在——草稿、审核中、已发布、已下线、启用、禁用。每个状态有自己的颜色 + 文案,如果每个页面分别写这些判断,同一套状态枚举会散落在 5 个文件里。

<!-- components/StatusTag.vue --> <template> <el-tag :type="tagType" :size="size" effect="plain"> {{ label }} </el-tag> </template> <script setup> import { computed } from 'vue' const STATUS_MAP = { draft: { label: '草稿', type: 'info' }, auditing: { label: '审核中', type: 'warning' }, published: { label: '已发布', type: 'success' }, offline: { label: '已下线', type: 'danger' }, enabled: { label: '启用', type: 'success' }, disabled: { label: '禁用', type: 'info' } } const props = defineProps({ status: { type: String, required: true }, size: { type: String, default: 'default' } }) const tagType = computed(() => STATUS_MAP[props.status]?.type || 'info') const label = computed(() => STATUS_MAP[props.status]?.label || props.status) </script>

用的时候一行搞定:<StatusTag status="published" />。如果要加一个新状态(比如"已归档"),只改STATUS_MAP一个地方,所有页面自动生效。

5.2 AppBreadcrumb:面包屑

<!-- components/AppBreadcrumb.vue --> <template> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item v-for="item in items" :key="item.path" :to="item.path ? { path: item.path } : undefined"> {{ item.title }} </el-breadcrumb-item> </el-breadcrumb> </template> <script setup> defineProps({ items: { type: Array, default: () => [] // 每项格式: { title: 'API 注册管理', path: '/api-manage' } } }) </script>

用配置数组驱动,而不是在每个页面写硬编码的面包屑 HTML。

5.3 AppButton:统一按钮风格

设计稿里主色按钮、次要按钮、危险按钮的圆角和字重和 Element Plus 默认有微小差异。包一层确保全局统一。

5.4 AppSearchInput:搜索框

带防抖(500ms)的搜索输入框,内置search事件。不用在每个列表页写setTimeout


六、组件设计原则:五条铁律

封装了这么多组件,回头总结一下指导设计的原则——它们来自这个项目的踩坑和经验,也适用于任何组件封装的场景。

原则 1:单向数据流(props down, events up)

数据从父组件通过 props 流入子组件,子组件通过 emit 事件通知父组件——绝不通过直接修改 props 来改变父组件状态。

<!-- ✅ 正确:用 computed + emit --> const currentPage = computed({ get: () => props.page, set: (val) => emit('update:page', val) }) <!-- ❌ 错误:直接在子组件里改 props --> <script> const page = ref(props.page) // 脱钩了!父组件改 page 不会生效 </script>

原则 2:v-model 双向绑定

Vue 3 支持多个v-model(如v-model:page+v-model:selection),让组件用起来像原生表单元素一样自然。

<AppTable v-model:page="page" v-model:selection="selectedRows" />

内部实现是modelValueprop +update:modelValue事件的语法糖。

原则 3:slot 提供扩展点

表格的操作列、弹窗的内容区、分页器的左侧区域——这些是"一定会变"的部分。用 slot 而不是用 prop 传字符串/模板,是组件可扩展性的关键。

<!-- ✅ 操作列用 slot --> <template #action="{ row }"> <el-button @click="edit(row)">编辑</el-button> </template> <!-- ❌ 用 prop 传按钮配置——能覆盖的场景太有限 --> <AppTable :actions="[{ label: '编辑', handler: edit }]" />

slot 给了使用者完全的模板控制权,prop 只能做到"预定义的组合"。

原则 4:props 必有默认值

每个 prop 都应该有一个合理的默认值。没有默认值的 prop 是"陷阱"——使用者不传就报错或行为异常。

// ✅props:{showIndex:{type:Boolean,default:true}}// ❌props:{showIndex:Boolean// 不传就是 undefined,truthy/falsy 行为不确定}

原则 5:TypeScript 类型定义

本项目虽然用 JS,但这个原则值得提一下。如果用了 TS,组件 Props 应该用defineProps<T>()写类型声明。类型定义不仅是给编译器看的——它是组件的"使用说明书"。新成员看到类型,就知道这个组件需要什么数据、返回什么事件。


七、业务开发流程:从"搭积木"到"填空"

通用组件就位后,一个列表页的开发流程变成了:

1. 在 views/ 下新建页面组件 2. 定义 columns 数组(告诉 AppTable 有哪些列) 3. 写 fetchData 请求函数(从 Mock 接口拿数据) 4. 在 template 里写 <AppTable :data :columns :total @page-change /> 5. 如果有操作列,用 #action slot 注入按钮

整个页面组件的 template 不超过 15 行。业务逻辑集中在<script setup>里——数据获取、筛选条件、事件处理。没有重复的表格配置、分页器引入、弹窗样式覆写。

这就是通用组件的核心价值:让重复的"配置"消失在底层,让业务逻辑浮上表面。


八、小结:组件封装是投资,不是成本

有些人觉得封装通用组件"浪费时间"——“直接用 Element Plus 不也能跑吗?”

能跑和能维护的区别,就是"砖头"和"预制墙"的区别。用砖头砌第一面墙要一个小时,砌第十面墙还要一个小时。用预制墙,第一面要花三个小时设计和浇筑模具——但从第二面开始,每面墙只要 10 分钟拼装。

这个项目的 7 个业务页面(API 注册管理 × 3 帧、模型汇聚 × 2 帧、模型发布 × 2 帧),每页都是一个列表 + 弹窗的组合。如果没有 AppTable 和 AppDialog,每个页面要写 100+ 行模板;有了它们,每个页面只要 30 行。

封装通用组件是投资。投资 300 行代码,省下 700 行重复。


上一篇:06 - 主布局:顶栏 + 侧栏的工程化设计

下一篇预告:通用组件就位,该上战场了——第一个业务模块"API 注册管理",看列表页范式如何用 AppTable 一路平推。

← 返回列表