外观
第5章:用 Element Plus 完成业务界面
约 10165 字大约 34 分钟
Element Plus表格表单校验
2026-07-28
第 4 章已经搭好了管理端路由骨架,但商品管理页还是一个占位页面。现在要把它改造成真正能操作的模块:看商品列表、处理空数据、打开新增弹窗、检查输入、编辑记录、确认删除,并让每次操作都有明确反馈。
原生 HTML 也能完成这些功能,但每个页面都从头写按钮、表格、弹窗和错误提示,会产生大量重复代码。UI 组件库提供的是一套做好的界面零件;商品字段、业务规则、数据状态和操作结果仍由我们的代码负责。
项目目标
在管理端路由骨架上,用 Element Plus 完成商品管理模块:商品列表、空状态、新增与编辑弹窗、表单校验、删除反馈和窄屏适配。本章只使用本地数组,不接接口,也不使用 Pinia。
学习说明
本章依次学习组件库接入、商品表格、共用表单和响应式布局,并通过章末任务完成综合应用。
配套资源
本章示例工程位于 resources/ch05/classroom-demo/,章末任务起始工程位于 resources/ch05/after-class-starter/。进入对应项目目录后,先执行 npm install,再执行 npm run dev。
🎯 学习梯度指引(分层通关)
- 核心必学(保底通关 · Element Plus 业务实战):掌握 5.1、5.2(Element Plus 单一主线接入)、5.3(商品表格、空状态与删除确认反馈)、5.4(新增商品弹窗与基础表单校验)。能够独立完成中后台经典 CRUD 列表与基础新增弹窗。
- 进阶选学(🌟 自主拓展 · 组件复用与架构素养):5.2(三套 UI 组件库跨端选型对比)、5.4(新增与编辑双模式共用表单及提交防重锁)、5.5(可访问性无障碍与移动端卡片响应式适配)。供学有余力或有进阶架构需求的学生自主选学探索,不作为基础达标强制考核。
5.1 本章任务清单
本章沿着“先接入组件库,再完成表格和表单,最后检查窄屏与可访问性”的顺序推进,最终形成可操作且适配窄屏的商品管理页面。
| 知识点 | 本章要做什么 | 当堂练习 | 完成后应该看到 |
|---|---|---|---|
| UI 组件库与 Element Plus 接入 | 说清组件库能做什么、不能做什么;完整引入 Element Plus;粗略比较 Vant 和 Ant Design Vue | 给三套组件库的按钮增加禁用状态,并写出不能点击的原因 | 三套按钮都有正确样式;禁用时按钮不可点击,页面仍有文字说明 |
| 商品表格 | 使用稳定 id、row-key、表格列、空状态、确认框和结果消息表达商品列表 | 库存小于 5 时显示“库存紧张”;空状态按钮弹出提示 | 正常、空数据、加载和失败四种状态能区分;删除不会删错行 |
| 新增与编辑共用表单 | 使用 draft 草稿、弹窗、model/rules/prop 校验链和提交锁 | 在新增案例上补齐编辑,取消不污染原记录,保存只更新对应 id | 无效数据留在弹窗内;保存后列表正确更新;连续点击不会重复提交 |
| 可访问性与响应式布局 | 对比移动商品卡片与桌面管理表格,检查标签、文字状态、键盘和窄屏布局 | 按检查清单至少修复两项问题,并说明修改内容 | 库存为 0 或下架时按钮禁用;状态不只靠颜色;窄屏下主要操作可用 |
开工顺序
- 在
resources/ch05/classroom-demo/中确认项目可以启动。 - 完整引入 Element Plus,用一个按钮验证组件和样式都已进入应用。
- 使用本地商品数组完成表格、空状态、删除确认和结果反馈。
- 加入新增表单,再把同一套表单复用为编辑弹窗。
- 最后缩窄浏览器窗口,检查标签、文字状态、按钮和表单是否仍可使用。
本章边界
本章不接接口、不使用 Axios、不引入 Pinia。rows、loading 和 errorMessage 都是本地状态;第 6 章再把本地数组替换为接口数据。
5.2 知识点一:UI 组件库与 Element Plus 接入
学习目标
掌握 UI 组件库的作用和 Element Plus 的完整引入方式,能够让组件和样式正确进入 Vue 应用,并能粗略判断三套组件库的适用场景。
语法/概念
可以把 UI 组件库理解成“一箱已经做好的界面零件”。按钮、输入框、表格和弹窗已经有统一的外观与基本交互,我们不需要每次都从零开始写。
但组件库不会自动知道下面这些业务问题:
- 商品名称是否允许重复;
- 库存为什么不能小于 0;
- 哪些账号可以删除商品;
- 删除成功或失败后应该怎么提示;
- 数据应该来自本地数组还是后端接口。
| 组件库能帮忙的事情 | 仍然要由业务代码决定的事情 |
|---|---|
| 提供按钮、表格、表单、弹窗等组件 | 商品有哪些字段、字段代表什么 |
| 提供禁用、加载、校验样式 | 什么情况下应该禁用、什么输入算错误 |
| 提供消息框、确认框 | 什么时候提示、删除会影响什么 |
| 提供基础响应式和可访问性能力 | 页面在当前业务中是否真的可读、可操作 |
🌟 进阶选学:前端 UI 组件库生态与跨端选型视野
现代前端开发中,不同终端和业务形态通常匹配不同的 UI 库:
- Element Plus:专为 Vue 3 桌面端中后台管理系统设计,是本课程唯一的业务主线,也是国内企业后台事实上的主流标准;
- Vant:专为移动端(H5、微信公众号、混合 App、触屏界面)打造,注重触控体验与轻量化;
- Ant Design Vue:适用于金融、政企等复杂大型企业级后台。 本章以 Element Plus 为绝对核心,Vant 与 Ant Design Vue 仅在此处作为跨端选型视野的最小体验对比,不展开深入细节。
| 组件库 | 安装命令 | 更适合的场景 | 本章怎么处理 |
|---|---|---|---|
| Element Plus | npm install element-plus | Vue 3 桌面管理端 | 本章业务主线,深入表格、表单和反馈 |
| Vant | npm install vant | 手机端、触屏页面 | 只看一个移动端按钮示例 |
| Ant Design Vue | npm install ant-design-vue | 桌面企业后台 | 只看一个后台按钮示例 |
Element Plus 的完整引入语法模板如下:
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
createApp(App)
.use(ElementPlus)
.mount('#app').use(ElementPlus):让整个应用能识别 Element Plus 组件;import 'element-plus/dist/index.css':把组件库样式放进应用;- 两者缺一不可。只注册插件不导入样式,按钮可能能点击,但外观会不正确;
- 自动按需导入可以减少打包体积,但要增加额外配置,等项目进入优化阶段再比较,本章不演示。
思考问题
问题:如果页面能看到按钮文字,但按钮没有 Element Plus 的颜色、圆角和间距,应该先检查 JavaScript 注册还是 CSS 样式导入?
课堂演示
步骤 1:启动课堂演示项目并安装三套组件库。
在仓库根目录打开终端,执行:
cd docs/courses/web-frontend-framework/resources/ch05/classroom-demo
npm install
npm install element-plus vant ant-design-vue
npm run dev运行后,终端应显示本地访问地址。浏览器能打开原有商品页面,说明项目环境正常。
步骤 2:把实验页改成三套组件库的最小对比。
Element Plus 对应的 npm 包名是 element-plus。先观察 main.js 中插件和样式的引入位置,再解锁完整代码。
三套 UI 组件库最小对比
src
main.js
App.vue
src/main.js
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'vant/lib/index.css'
import 'ant-design-vue/dist/reset.css'
import App from './App.vue'
createApp(App)
.use(ElementPlus)
.mount('#app')src/App.vue
<script setup>
import { ref } from 'vue'
import { Button as VanButton } from 'vant'
import { Button as AButton } from 'ant-design-vue'
const message = ref('点击任意按钮,观察三套组件的事件反馈。')
</script>
<template>
<main class="page">
<h1>UI 组件库实验台</h1>
<p>这里只比较最小用法,商品管理主线仍然只使用 Element Plus。</p>
<section class="library-card">
<h2>Element Plus:桌面管理端</h2>
<el-button
type="primary"
@click="message = 'Element Plus:商品已保存'"
>
保存商品
</el-button>
</section>
<section class="library-card mobile-card">
<h2>Vant:移动端</h2>
<VanButton
type="primary"
block
@click="message = 'Vant:已加入购物车'"
>
加入购物车
</VanButton>
</section>
<section class="library-card">
<h2>Ant Design Vue:企业后台</h2>
<AButton
type="primary"
@click="message = 'Ant Design Vue:已新增记录'"
>
新增记录
</AButton>
</section>
<p class="message" aria-live="polite">{{ message }}</p>
</main>
</template>
<style>
body {
margin: 0;
background: #f5f7fb;
color: #202a44;
font-family: system-ui, sans-serif;
}
.page {
display: grid;
gap: 18px;
width: min(800px, calc(100% - 32px));
margin: 42px auto;
}
.library-card {
display: grid;
gap: 12px;
padding: 20px;
border: 1px solid #dce3f0;
border-radius: 16px;
background: white;
}
.library-card h2 {
margin: 0;
}
.mobile-card {
max-width: 420px;
}
.message {
color: #5367d8;
font-weight: 700;
}
</style>保存后回到浏览器,应该看到三张区域卡片和三个样式正常的按钮。依次点击按钮,页面底部文字应跟着变化。
步骤 3:确认样式不是碰巧出现的。
暂时注释 main.js 中的一条样式导入,保存后观察对应组件外观,再立即恢复。恢复后刷新页面,三个按钮应重新显示正常样式,Console 不应出现组件解析错误。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:在 Element Plus 按钮案例上声明布尔变量
disabled(初始值设为true),绑定到<el-button>的:disabled属性;按钮下方显示明确的文字原因(如“商品资料未填写完整,暂不能操作”);将disabled改为false后验证按钮恢复可点击且点击后能正常更新反馈文字。 - 【选做挑战(🌟 自主拓展)】:在对比案例中,将
disabled属性同时应用到 Vant 和 Ant Design Vue 按钮上,对比观察三套组件库在禁用状态下的默认视觉表现(透明度、光标形状、背景色),以及恢复点击后的交互差异。
5.3 知识点二:商品表格
学习目标
掌握 Element Plus 商品表格的行身份和状态表达方式,能够使用稳定 id 完成空状态、删除确认和结果反馈。
语法/概念
表格不是“把数组放上去就结束”。商品页至少要考虑四种状态:
| 页面状态 | 由什么判断 | 页面应该告诉用户什么 |
|---|---|---|
| 加载中 | loading === true | 数据正在准备,不要重复操作 |
| 成功有数据 | rows.length > 0 | 显示商品表格和行操作 |
| 成功但为空 | 没有错误且 rows.length === 0 | 暂无商品,并提供新增入口 |
| 加载失败 | errorMessage 有内容 | 显示失败原因和重试方向 |
本章先用本地变量观察这四种状态,不发送接口请求:
const rows = ref([])
const loading = ref(false)
const errorMessage = ref('')Element Plus 表格的最小语法如下:
<el-table :data="rows" row-key="id">
<el-table-column prop="name" label="商品名称" />
<el-table-column prop="price" label="价格" />
</el-table>:data="rows":告诉表格要显示哪一个数组;prop="name":告诉这一列去每条商品记录里找name;row-key="id":告诉表格每一行的稳定身份;- 数组下标只是“现在排在第几位”,排序、筛选后会变化;
id跟着商品本身走,删除和编辑都应该依赖稳定id。
播放下面的流程,观察“点击删除”之后为什么要把商品 id 交给处理函数。
交互演示
业务表格不是“把数组放进去”
从数据状态到行操作,观察商品管理表的完整反馈。
- 1准备状态rows / loading
- 2定义列TableColumn
- 3触发行操作edit / delete
- 4刷新反馈result
当前发生
表格同时接收行数据、加载状态和空结果。
观察证据
未返回数据时不会显示旧列表。
删除是一条完整操作链:
点击删除 → 说明影响 → 用户确认 → 按 id 修改数组 → 显示操作结果ElMessageBox.confirm 负责二次确认,ElMessage 负责告诉用户最后发生了什么。确认框不能代替删除逻辑,消息提示也不能代替数据更新。
思考问题
问题:删除处理函数应该接收“当前行号”还是稳定 id?如果表格刚刚按库存排序,行号还能代表原来的商品吗?
课堂演示
步骤 1:恢复只使用 Element Plus 的课堂环境。
src/main.js 保持 Element Plus 完整引入;Vant 和 Ant Design Vue 只用于上一节对比,不再进入商品管理主线。
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
createApp(App).use(ElementPlus).mount('#app')保存后,浏览器仍应能正常打开,Element Plus 按钮应保留完整样式。
步骤 2:把 App.vue 改成可切换四种状态的商品表格。
商品在排序、筛选后仍保持不变的身份字段是 id。先回答课堂问题,再解锁代码。
<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const initialProducts = [
{ id: 101, name: 'Vue 组件化学习卡', price: 29.9, stock: 20, enabled: true },
{ id: 102, name: '前端排错记录本', price: 16, stock: 4, enabled: false },
]
const rows = ref(initialProducts.map(product => ({ ...product })))
const loading = ref(false)
const errorMessage = ref('')
function showNormal() {
loading.value = false
errorMessage.value = ''
rows.value = initialProducts.map(product => ({ ...product }))
}
function showLoading() {
errorMessage.value = ''
loading.value = true
window.setTimeout(() => {
loading.value = false
}, 800)
}
function showEmpty() {
loading.value = false
errorMessage.value = ''
rows.value = []
}
function showFailure() {
loading.value = false
rows.value = []
errorMessage.value = '商品数据加载失败,请稍后重试。'
}
function formatPrice(row, column, value) {
return '¥' + Number(value).toFixed(2)
}
function openCreate() {
ElMessage.info('新增弹窗会在下一节完成。')
}
async function removeProduct(row) {
try {
await ElMessageBox.confirm(
'删除“' + row.name + '”后,当前列表无法恢复,确认继续吗?',
'删除商品',
{
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
},
)
rows.value = rows.value.filter(product => product.id !== row.id)
ElMessage.success('已删除“' + row.name + '”')
} catch {
ElMessage.info('已取消删除')
}
}
</script>
<template>
<main class="page">
<header class="page-header">
<div>
<p class="eyebrow">ADMIN · PRODUCTS</p>
<h1>商品管理</h1>
</div>
<el-button type="primary" @click="openCreate">新增商品</el-button>
</header>
<div class="state-buttons" aria-label="课堂状态切换">
<el-button @click="showNormal">正常数据</el-button>
<el-button @click="showLoading">加载中</el-button>
<el-button @click="showEmpty">空数据</el-button>
<el-button @click="showFailure">失败状态</el-button>
</div>
<p v-if="errorMessage" class="error-message" role="alert">
{{ errorMessage }}
</p>
<section v-else class="table-panel">
<p v-if="loading" class="state-message" role="status">
商品数据加载中,请稍候……
</p>
<el-empty
v-else-if="rows.length === 0"
description="暂无商品"
>
<el-button type="primary" @click="openCreate">新增商品</el-button>
</el-empty>
<el-table v-else :data="rows" row-key="id" border>
<el-table-column prop="name" label="商品名称" min-width="190" />
<el-table-column
prop="price"
label="价格"
width="120"
:formatter="formatPrice"
/>
<el-table-column prop="stock" label="库存" width="110" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">
{{ row.enabled ? '在售' : '下架' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button
type="danger"
link
@click="removeProduct(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</section>
</main>
</template>
<style scoped>
.page {
width: min(960px, calc(100% - 32px));
margin: 40px auto;
color: #172033;
font-family: system-ui, sans-serif;
}
.page-header,
.state-buttons {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: #d97706;
font-size: 12px;
font-weight: 800;
letter-spacing: .12em;
}
h1 {
margin: 6px 0 0;
}
.state-buttons {
justify-content: flex-start;
margin: 24px 0 16px;
}
.table-panel {
min-height: 260px;
padding: 16px;
border: 1px solid #e1e6ef;
border-radius: 14px;
background: white;
}
.state-message,
.error-message {
padding: 18px;
border-radius: 10px;
}
.state-message {
color: #35506f;
background: #eef5ff;
}
.error-message {
color: #9f2d2d;
background: #fff0f0;
}
</style>保存后应看到两条商品记录。点击“加载中”出现文字提示,点击“空数据”出现 el-empty,点击“失败状态”出现错误提示,点击“正常数据”恢复表格。
步骤 3:验证删除链。
点击第二条商品的“删除”,先选择“取消”,列表不应变化,并显示“已取消删除”;再次删除并确认,只有点击的商品消失。继续删除最后一条,页面应自动进入空状态。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:在库存列旁增加“库存紧张”文字标签。把库存列改成带默认插槽的
<el-table-column>,始终显示数字row.stock;当row.stock < 5时,额外显示一个type="warning"的<el-tag>库存紧张</el-tag>;空状态里的“新增商品”按钮点击调用openCreate弹出提示。 - 【选做挑战(🌟 自主拓展)】:尝试为
<el-empty>空状态组件定制插槽,增加一个“重新加载数据”按钮,点击后调用showNormal恢复两条初始商品记录,使空状态具备双向重置体验。
5.4 知识点三:新增与编辑共用表单
学习目标
掌握 draft 草稿、弹窗和 model/rules/prop 校验链,能够先完成新增商品,再把同一份表单安全复用为编辑。
语法/概念
表格里的商品是“已经保存的数据”,弹窗输入的是“还没决定要不要保存的草稿”。如果输入框直接绑定表格行对象,用户只是打了几个字、还没点击保存,表格就已经变化,取消按钮也就失去了意义。
因此打开表单时要创建一份副本,并把这份临时副本叫作 draft:
const draft = reactive({
id: null,
name: '',
price: 0,
stock: 0,
enabled: true,
})
Object.assign(draft, { ...row })Element Plus 表单校验是一条必须对齐的链:
draft.name
↕
rules.name ↔ prop="name" ↔ v-model="draft.name"最小语法模板:
<el-form ref="formRef" :model="draft" :rules="rules">
<el-form-item label="商品名称" prop="name">
<el-input v-model="draft.name" />
</el-form-item>
</el-form>const valid = await formRef.value.validate().catch(() => false)
if (!valid) return播放下面的流程,观察 model、rules、prop 和提交函数是怎样连接起来的。
交互演示
表单从输入到提交的验证链
验证是提交边界的一部分,不只是红色提示文字。
- 1绑定模型model
- 2触发校验rules / prop
- 3阻止提交validate
- 4处理结果success / failure
当前发生
每个输入项绑定到明确字段,初始值类型稳定。
观察证据
数字字段不会长期保存为含空格字符串。
把完整案例拆开看,重点只有四件事:
draft是弹窗中的临时数据,取消时直接丢弃;v-model="dialogVisible"用同一个布尔值控制弹窗打开和关闭;model、rules、prop和v-model的字段名必须一致;submitting是提交锁,保存期间按钮显示加载并阻止重复点击。
| 组件或状态 | 简单说明 |
|---|---|
el-dialog | 暂时盖在页面上的操作窗口 |
draft | 用户正在修改、还没正式保存的副本 |
el-form | 把多个输入字段和校验组织到一起 |
rules | 每个字段提交前要通过的检查 |
prop | 把当前表单项和某一条规则对上号 |
submitting | 保存期间上锁,避免连续点击两次 |
课堂演示先把完整流程写在 App.vue,方便直接复制运行。确认新增和编辑都正确后,再放回第 4 章的管理端路由骨架,按下面的职责拆分:
商品管理模块
src
views/admin
ProductListView.vue# 保存商品数组,打开新增或编辑弹窗
components/product
ProductFormDialog.vue# 管理 draft、表单校验和提交锁
ProductStatusTag.vue# 用文字和颜色显示在售或下架
ProductListView.vue 拥有已经保存的列表,ProductFormDialog.vue 只管理当前草稿。弹窗点击保存时把草稿交回列表,点击取消时直接丢弃草稿。
避坑警示:草稿引用污染与校验四要素对齐
- 绝不能直接引用原对象:切勿写
draft = row。必须使用对象克隆(如{ ...row }),否则用户在弹窗中打字时,即便最终点击“取消”,背后的表格数据也已被实时修改污染! - 校验四要素必须严格同名:
el-form的:model="draft"、:rules="rules"、<el-form-item prop="name">与内部输入组件的v-model="draft.name"四处的字段名必须完全保持一致,拼错任何一处校验规则都将静默失效。
🌟 进阶选学:新增与编辑双模式共用及提交防重锁
在成熟中后台业务中,新增与编辑往往复用同一个表单弹窗组件:
- 状态标记:依靠
editingId区分当前是新增(null)还是编辑(具体商品 ID); - 动态文案:根据
editingId动态显示弹窗标题“新增商品”或“编辑商品”; - 防重复提交锁(submitting):在点击保存期间将
submitting.value = true,保存按钮呈现:loading="submitting"并暂时禁用取消,有效避免因网络延迟或手抖狂点导致产生多条重复数据。
客户端校验不是安全边界
浏览器中的校验可以被绕过。它的主要作用是让用户尽早发现输入错误。真正接入后端时,服务端仍然必须再次校验数据。
思考问题
问题:为什么不能直接写 draft = row,然后让弹窗输入框修改这个对象?点击取消时,原商品会发生什么?
课堂演示
步骤 1:在上一节商品表格上加入新增弹窗。
为了让取消按钮真正有用,表单中使用的编辑副本叫作 draft。课堂案例先完成“新增”,编辑功能留给紧接着的当堂练习。
<script setup>
import { nextTick, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const formRef = ref()
const dialogVisible = ref(false)
const submitting = ref(false)
const products = ref([
{ id: 101, name: 'Vue 组件化学习卡', price: 29.9, stock: 20, enabled: true },
{ id: 102, name: '前端排错记录本', price: 16, stock: 4, enabled: false },
])
const draft = reactive(createEmptyDraft())
const rules = {
name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' },
{ min: 2, max: 30, message: '长度应为 2 到 30 个字符', trigger: 'blur' },
],
price: [
{ required: true, message: '请输入价格', trigger: 'change' },
{ type: 'number', min: 0, message: '价格不能小于 0', trigger: 'change' },
],
stock: [
{ required: true, message: '请输入库存', trigger: 'change' },
{ type: 'integer', min: 0, message: '库存必须是非负整数', trigger: 'change' },
],
}
function createEmptyDraft() {
return {
id: null,
name: '',
price: 0,
stock: 0,
enabled: true,
}
}
function openCreate() {
Object.assign(draft, createEmptyDraft())
dialogVisible.value = true
nextTick(() => formRef.value?.clearValidate())
}
function closeDialog() {
if (submitting.value) return
dialogVisible.value = false
}
function formatPrice(row, column, value) {
return '¥' + Number(value).toFixed(2)
}
async function saveProduct() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || submitting.value) return
submitting.value = true
try {
await new Promise(resolve => window.setTimeout(resolve, 500))
products.value.push({
...draft,
id: Date.now(),
})
dialogVisible.value = false
ElMessage.success('商品已新增')
} finally {
submitting.value = false
}
}
async function removeProduct(row) {
try {
await ElMessageBox.confirm(
'确定删除“' + row.name + '”吗?',
'删除确认',
{
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
},
)
products.value = products.value.filter(item => item.id !== row.id)
ElMessage.success('商品已删除')
} catch {
ElMessage.info('已取消删除')
}
}
</script>
<template>
<main class="page">
<header class="page-header">
<div>
<p class="eyebrow">ADMIN · PRODUCTS</p>
<h1>商品管理</h1>
</div>
<el-button type="primary" @click="openCreate">新增商品</el-button>
</header>
<el-empty v-if="products.length === 0" description="暂无商品">
<el-button type="primary" @click="openCreate">新增商品</el-button>
</el-empty>
<el-table v-else :data="products" row-key="id" border>
<el-table-column prop="name" label="商品名称" min-width="180" />
<el-table-column
prop="price"
label="价格"
width="120"
:formatter="formatPrice"
/>
<el-table-column prop="stock" label="库存" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">
{{ row.enabled ? '在售' : '下架' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button type="danger" link @click="removeProduct(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialogVisible"
title="新增商品"
width="min(520px, 92vw)"
:close-on-click-modal="!submitting"
:close-on-press-escape="!submitting"
>
<el-form
ref="formRef"
:model="draft"
:rules="rules"
label-position="top"
>
<el-form-item label="商品名称" prop="name">
<el-input
v-model.trim="draft.name"
autocomplete="off"
placeholder="请输入 2 到 30 个字符"
/>
</el-form-item>
<el-form-item label="价格" prop="price">
<el-input-number
v-model="draft.price"
:min="0"
:precision="2"
class="number-input"
/>
</el-form-item>
<el-form-item label="库存" prop="stock">
<el-input-number
v-model="draft.stock"
:min="0"
:precision="0"
class="number-input"
/>
</el-form-item>
<el-form-item label="销售状态" prop="enabled">
<el-switch
v-model="draft.enabled"
inline-prompt
active-text="在售"
inactive-text="下架"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="submitting" @click="closeDialog">
取消
</el-button>
<el-button
type="primary"
:loading="submitting"
@click="saveProduct"
>
保存
</el-button>
</template>
</el-dialog>
</main>
</template>
<style scoped>
.page {
width: min(960px, calc(100% - 32px));
margin: 40px auto;
color: #172033;
font-family: system-ui, sans-serif;
}
.page-header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.eyebrow {
margin: 0;
color: #d97706;
font-size: 12px;
font-weight: 800;
letter-spacing: .12em;
}
h1 {
margin: 6px 0 0;
}
.number-input {
width: 100%;
}
@media (max-width: 560px) {
.page-header {
align-items: stretch;
flex-direction: column;
}
}
</style>保存后点击“新增商品”,应该看到弹窗。直接清空名称并保存,名称下方出现校验信息,弹窗保持打开;填写合法数据后点击保存,按钮短暂显示加载,弹窗关闭,表格增加一条商品并显示成功消息。
步骤 2:验证草稿、校验链和提交锁。
- 输入一半后点“取消”,表格不能增加记录;
- 再次打开弹窗,上一轮未保存内容应该已经清空;
- 把
prop="name"临时改成错误字段,观察校验不再对应,再立即恢复; - 合法保存时连续点击“保存”,列表只能增加一条记录。
完成后应能清楚说明:输入先进入 draft,校验通过后才写入 products,submitting 负责阻止重复提交。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:在现有代码基础上跑通“新增商品”完整闭环:点击“新增商品”打开弹窗,输入合法名称和价格(测试清空名称时能够正确拦截并显示校验提示),点击“保存”成功将新商品追加进表格并弹出成功消息;点击“取消”关闭弹窗且不产生多余记录。
- 【选做挑战(🌟 自主拓展)】:引入
editingId将弹窗扩展为编辑模式:为表格每行增加“编辑”按钮,点击后将当前行克隆复制进draft;弹窗标题根据模式动态显示“编辑商品”或“新增商品”;保存时根据editingId按稳定id更新对应记录,并验证编辑中途点击“取消”原数据保持不变。
5.5 知识点四:可访问性与响应式布局
🌟 进阶选学:企业级 Web 可访问性(a11y)与响应式适配
在日常前端开发中,保底的要求是养成规范的工程习惯:输入框有可见 label、状态不仅依赖颜色(附带明确文字)、危险操作二次确认带具体名称。 而更高级的“全键盘无鼠化流、无障碍读屏广播(aria-live)以及复杂桌面表格向手机端卡片响应式重构”,属于中大型企业或国际化产品的高阶可访问性(a11y)素养,供学有余力的同学自主探索,不作为基础达标强制考核。
学习目标
掌握业务页面的基础可访问性与窄屏检查方法,能够对比移动商品卡片和桌面管理表格,并修复明显的不可读、不可点问题。
语法/概念
组件库只能提供基础能力,不能保证页面自动适合所有人和所有屏幕。我们仍然要检查:
| 检查项 | 简单说明 | 达标表现 |
|---|---|---|
| 字段标签 | 不要让用户猜输入框是做什么的 | 每个输入框旁有明确 label,不能只放 placeholder |
| 文字状态 | 颜色可能看不清,也可能无法被读屏理解 | 显示“在售、下架、库存紧张”等文字 |
| 键盘操作 | 有些用户不使用鼠标 | Tab 能到达主要按钮,焦点顺序合理 |
| 危险操作 | 删除不能只写“确定吗” | 确认框写清商品名和删除影响 |
| 窄屏布局 | 桌面表格在手机上可能被压得看不懂 | 允许横向查看,或改成更适合手机的卡片 |
| 禁用原因 | 变灰不等于解释 | 按钮禁用,同时显示“库存为 0”或“商品已下架” |
同一组商品数据在不同界面上的重点不同:
- 移动商品卡片关注“快速浏览、立即操作”;
- 桌面管理表格关注“比较多条记录、编辑和管理”;
- 响应式设计不是把桌面表格硬缩小,而是重新安排信息优先级。
最小响应式语法:
.page {
display: grid;
grid-template-columns: 360px 1fr;
}
@media (max-width: 760px) {
.page {
grid-template-columns: 1fr;
}
}思考问题
问题:库存为 0 时只把按钮变成灰色,用户能不能确定它为什么不能点击?页面还应该补上什么文字?
课堂演示
步骤 1:创建移动商品卡片。
在 src/ 下新建 components/ 文件夹,再创建 MobileProductCard.vue。然后把 App.vue 替换为下面代码。
移动商品卡片与桌面管理表格
src
App.vue
components
MobileProductCard.vue
src/App.vue
<script setup>
import { ref } from 'vue'
import MobileProductCard from './components/MobileProductCard.vue'
const products = ref([
{ id: 101, name: 'Vue 组件化学习卡', price: 29.9, stock: 20, enabled: true },
{ id: 102, name: '前端排错记录本', price: 16, stock: 0, enabled: false },
])
const message = ref('请选择一件可以购买的商品。')
function addToCart(product) {
message.value = '已加入“' + product.name + '”'
}
</script>
<template>
<main class="page">
<section class="mobile-shell">
<p class="eyebrow">MOBILE</p>
<h1>校园微商城</h1>
<MobileProductCard
v-for="product in products"
:key="product.id"
:product="product"
@add="addToCart(product)"
/>
<p class="message" aria-live="polite">{{ message }}</p>
</section>
<section class="admin-shell">
<p class="eyebrow">DESKTOP ADMIN</p>
<h2>商品管理表</h2>
<div class="table-wrap">
<el-table :data="products" row-key="id" border>
<el-table-column prop="name" label="商品名称" min-width="180" />
<el-table-column prop="price" label="价格" width="100">
<template #default="{ row }">
¥{{ row.price.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="stock" label="库存" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">
{{ row.enabled ? '在售' : '下架' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</section>
</main>
</template>
<style>
body {
margin: 0;
background: #f5f7fb;
color: #172033;
font-family: system-ui, sans-serif;
}
.page {
display: grid;
grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
gap: 24px;
width: min(1080px, calc(100% - 32px));
margin: 36px auto;
}
.mobile-shell,
.admin-shell {
padding: 22px;
border: 1px solid #e1e6ef;
border-radius: 18px;
background: white;
}
.eyebrow {
margin: 0;
color: #5367d8;
font-size: 12px;
font-weight: 800;
letter-spacing: .12em;
}
h1,
h2 {
margin: 8px 0 20px;
}
.message {
min-height: 24px;
color: #5367d8;
font-weight: 700;
}
.table-wrap {
overflow-x: auto;
}
.table-wrap .el-table {
min-width: 520px;
}
@media (max-width: 760px) {
.page {
grid-template-columns: 1fr;
}
}
</style>src/components/MobileProductCard.vue
<script setup>
defineProps({
product: {
type: Object,
required: true,
},
})
const emit = defineEmits(['add'])
</script>
<template>
<article class="product-card">
<div class="product-row">
<div>
<strong>{{ product.name }}</strong>
<small>
{{ product.enabled ? '在售' : '已下架' }} · 库存 {{ product.stock }}
</small>
</div>
<b>¥{{ product.price.toFixed(2) }}</b>
</div>
<div class="product-row">
<span v-if="product.stock === 0" class="reason">
库存为 0,暂时不能购买
</span>
<span v-else-if="!product.enabled" class="reason">
商品已下架,暂时不能购买
</span>
<span v-else class="ready">当前可以购买</span>
<button
type="button"
:disabled="!product.enabled || product.stock === 0"
@click="emit('add')"
>
加入购物车
</button>
</div>
</article>
</template>
<style scoped>
.product-card {
display: grid;
gap: 14px;
padding: 16px 0;
border-bottom: 1px solid #e5e9f2;
}
.product-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
strong,
small {
display: block;
}
small,
.reason {
margin-top: 5px;
color: #778195;
}
.ready {
color: #18794e;
}
b {
color: #d14343;
}
button {
flex: 0 0 auto;
padding: 9px 12px;
border: 0;
border-radius: 8px;
color: white;
background: #1989fa;
cursor: pointer;
}
button:disabled {
opacity: .45;
cursor: not-allowed;
}
button:focus-visible {
outline: 3px solid #91caff;
outline-offset: 2px;
}
@media (max-width: 420px) {
.product-row {
align-items: stretch;
flex-direction: column;
}
button {
width: 100%;
}
}
</style>保存后,左侧应显示移动商品卡片,右侧显示桌面管理表格。第二件商品库存为 0 且已下架,移动端按钮必须禁用,旁边同时显示文字原因。
步骤 2:检查桌面与窄屏。
运行项目:
npm run dev浏览器宽度大于 760px 时,两块内容并排;缩小到 760px 以下时,两块内容上下排列。继续缩小到 420px 以下,移动卡片按钮应占满一行,桌面表格可以在自己的区域内横向查看,不应把整个页面撑破。
步骤 3:只用键盘走一遍主要操作。
按 Tab 键移动焦点。可购买商品的“加入购物车”按钮应该能够获得清楚的焦点轮廓;库存为 0 或下架商品的按钮不能点击,但旁边的文字要说明原因。点击可用按钮后,aria-live 区域应显示加入结果。
当堂练习(分层双轨制)
- 【必做任务(基础通关)】:对照自查清单核查当前商品管理页,重点确认 3 项基础工程习惯:①输入框均有明确可见的
<el-form-item label="...">;②在售/下架/库存紧张均保留直观文本表达,不只依赖颜色;③删除确认弹窗必须清晰呈现目标商品名称(如“确定删除‘前端排错记录本’吗?”)。 - 【选做挑战(🌟 自主拓展)】:脱离鼠标,尝试完全依靠键盘(Tab 键切换焦点、Enter 键触发按钮、Esc 键关闭弹窗)走一遍新增流程,验证焦点轮廓是否清晰;或将浏览器缩窄至 420px,验证页面横向滚动与卡片自适应效果。
5.6 排错矩阵
| 现象 | 优先检查 | 典型原因 |
|---|---|---|
| Element Plus 组件没有样式 | element-plus/dist/index.css 是否导入 | 只注册插件,忘记导入 CSS |
| 页面提示组件无法解析 | 包是否安装、导入名称是否正确 | 写错组件名,或没有执行 npm install |
| Vant 按钮没有样式 | vant/lib/index.css 是否导入 | 只导入组件,没有导入样式 |
| Ant Design Vue 按钮没有样式 | ant-design-vue/dist/reset.css 是否导入 | 缺少基础样式 |
| 三套组件库样式互相影响 | 是否只在对比实验中同时使用 | 把三套库混进正式业务页,并写了过多全局覆盖 |
| 表格删除错行 | row-key 和删除事件载荷 | 使用数组下标,没有使用稳定 id |
| 空数组时页面一片空白 | rows.length 与 el-empty | 只写了表格,没有设计空状态 |
| 删除后没有结果提示 | ElMessageBox 后是否更新数组并调用 ElMessage | 只弹确认框,没有完成操作链 |
| 表单规则不触发 | model、rules、prop、v-model | 四处字段名没有对齐 |
| 取消后表格已经变化 | 是否复制 draft | 输入框直接绑定表格行对象 |
| 连续点击新增两条记录 | submitting 和保存按钮 | 没有提交锁,保存函数被重复调用 |
| 弹窗在窄屏超出页面 | 弹窗宽度和输入组件宽度 | 使用固定像素宽度,没有考虑视口 |
| 表格把整个页面撑宽 | 表格外层容器和列宽 | 没有局部横向滚动,固定列过多 |
先根据现象找证据
不要一看到页面不对就重装全部依赖。先看 Console、当前状态变量和对应组件属性,再决定修哪一处。
5.7 本章小测
完成下面的小测,重点检查是否能根据业务现象选择正确做法,而不是只背组件名。
章节测评
3 题第5章小测:Element Plus 业务界面
检查表格身份、表单校验和编辑草稿。
5.8 本章知识自检
在做章末课后任务前,先用课堂案例完成一次自检。
| 自检问题 | 能拿出的证据 |
|---|---|
| 能说明组件库能做什么、不能做什么吗? | 能指出“界面零件”和“业务规则”的区别 |
| 能证明 Element Plus 接入完整吗? | 组件有样式,Console 没有解析错误 |
| 能区分列表四种状态吗? | 能切换正常、加载、空数据和失败页面 |
| 能证明删除不会删错行吗? | 排序或筛选后仍按稳定 id 删除目标商品 |
| 能说明校验链吗? | 能对应 model、rules、prop 和 v-model |
| 能证明取消不污染原数据吗? | 编辑草稿后取消,表格保持不变 |
| 能证明页面适合窄屏吗? | 缩窄窗口后主要按钮仍能看见并操作 |
学习自检
停一下:组件库变成业务了吗
确认表格、表单和弹窗不只完成外观,还覆盖状态、校验与反馈。
5.9 课后任务:社团物资登记界面
任务目标
社团需要一个桌面端物资管理页,用于查看、登记、编辑和删除物资。使用 resources/ch05/after-class-starter/ 作为起始项目,独立完成页面;目录、组件名和字段名使用英文。
这个任务综合本章知识,不提供操作流程。完成后的页面要让操作结果清楚可见,在空数据、无效输入和窄屏下也能继续操作。
基础通关要求(全员必做)
- 物资数据表格:使用
el-table展示物资列表(物资名称、分类、数量、借用状态和操作列),必须使用row-key="id"绑定稳定行身份。 - 状态直观表达:借用状态使用
el-tag标签展示,且必须同时包含文字说明(如“借用中”或“在库”),不能只依赖颜色。 - 新增物资弹窗:使用
el-dialog承载新增表单,使用独立的空草稿draft输入,点击“保存”通过校验后向列表中追加新记录。 - 表单非空校验:使用
el-form校验链(model/rules/prop/v-model四要素对齐),名称不能为空且数量不能小于 0,未通过校验时阻断提交并高亮错误。 - 行级删除二次确认:点击操作列删除时弹出
ElMessageBox.confirm二次确认(包含具体物资名);确认后按稳定id从数组中移除,并弹出ElMessage.success提示;取消删除不改变数据。 - 空数据优雅降级:当物资列表被清空后,自动展示
el-empty空状态并提供“新增物资”入口。
进阶拓展任务(自主选做 · 🌟 自主拓展)
- 新增与编辑共用表单:使用同一个弹窗实现新增与编辑双模式,使用对象克隆隔离草稿,确保编辑中途点击取消不改动原数据,保存时按
editingId替换目标记录。 - 响应式卡片流:在手机或窄屏宽度下(
< 760px),将物资表格重构为竖向排列的轻量物资卡片展示,提供跨端友好体验。 - 无障碍键盘交互:脱离鼠标,全程依靠 Tab 键、Enter 键与 Esc 键完成新增物资的完整输入与弹窗关闭。
- 组件库选型小结:撰写简短的组件库选型说明,解释为什么桌面端管理场景应统一使用 Element Plus,而不是将多套组件库混在同一个项目中。
必须使用的知识
- 基础核心:
el-table与row-key="id"、el-table-column插槽定制、el-dialog弹窗控制、el-form基础校验链、ElMessageBox.confirm二次确认、ElMessage交互反馈、el-empty空状态。 - 进阶拓展(🌟 自主拓展):双模式表单复用与草稿克隆隔离、
@media响应式卡片流、可访问性焦点流动。
完成效果
- 基础效果:页面清晰呈现社团物资表格,可顺畅进行空状态查看、新增弹窗输入、输入校验阻断与稳定 ID 确认删除,每一次操作都有明确的文字与气泡反馈。
- 拓展效果(🌟 自主拓展):点击某行可打开编辑弹窗回显数据并安全修改;手机端自动转为卡片流展示;可用键盘顺畅操作。
验收步骤
| 顺序 | 验收操作 | 应该看到的结果 |
|---|---|---|
| 1 | 启动项目并查看初始表格 | 字段完整,每行都有稳定 id 对应的操作 |
| 2 | 新增一条合法物资 | 弹窗关闭,表格增加一条,出现成功反馈 |
| 3 | 提交空名称或负数数量 | 数据不写入,错误信息显示在对应字段旁 |
| 4 | 分别取消删除和确认删除 | 取消时列表不变;确认后目标记录消失;两种结果都有文字反馈 |
| 5 | 删除全部记录 | 页面显示 el-empty 和“新增物资”入口 |
| 6 | 缩窄浏览器至移动视口 | 基础版允许局部横向查看,核心操作按钮不被遮挡 |
| 7 | 执行 npm run build | 构建成功,Console 没有组件解析错误 |
| 8 | (🌟 自主拓展验收) | 验证编辑弹窗回显与取消保护、窄屏卡片响应式或键盘操作流 |
提交内容
- 项目源码,不包含
node_modules/和dist/; - 正常表格、校验提示、空状态三张效果图;
- 一张
npm run build成功终端截图; - (🌟 自主拓展选交):编辑弹窗效果图、手机卡片效果图或组件库选型小结。
本章小结
- UI 组件库提供一致的界面零件,不替代字段、权限和业务状态设计;
- 本章业务主线使用 Element Plus,Vant 和 Ant Design Vue 只做最小场景对比;
- 商品表格要同时表达稳定行身份、加载、成功、空数据和失败状态;
- 表单校验由
model、rules、prop和v-model共同组成; draft让保存与取消有明确边界,submitting防止重复提交;- 可访问性和响应式布局都需要根据实际业务逐项验收;
- 下一章会把本地数组替换为 Axios 接口,并处理请求封装、加载和异常状态。
