---
url: /courses/web-frontend-framework/element-plus-business-ui/index.md
---
# 第5章：用 Element Plus 完成业务界面

第 4 章已经搭好了管理端路由骨架，但商品管理页还是一个占位页面。现在要把它改造成真正能操作的模块：看商品列表、处理空数据、打开新增弹窗、检查输入、编辑记录、确认删除，并让每次操作都有明确反馈。

原生 HTML 也能完成这些功能，但每个页面都从头写按钮、表格、弹窗和错误提示，会产生大量重复代码。UI 组件库提供的是一套做好的界面零件；商品字段、业务规则、数据状态和操作结果仍由我们的代码负责。

::: tip 项目目标
在管理端路由骨架上，用 Element Plus 完成商品管理模块：商品列表、空状态、新增与编辑弹窗、表单校验、删除反馈和窄屏适配。本章只使用本地数组，不接接口，也不使用 Pinia。
:::

::: tip 学习说明
本章依次学习组件库接入、商品表格、共用表单和响应式布局，并通过章末任务完成综合应用。
:::

::: tip 配套资源
本章示例工程位于 `resources/ch05/classroom-demo/`，章末任务起始工程位于 `resources/ch05/after-class-starter/`。进入对应项目目录后，先执行 `npm install`，再执行 `npm run dev`。
:::

::: tip 🎯 学习梯度指引（分层通关）

* **核心必学（保底通关 · 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 或下架时按钮禁用；状态不只靠颜色；窄屏下主要操作可用 |

### 开工顺序

:::: steps

1. 在 `resources/ch05/classroom-demo/` 中确认项目可以启动。
2. 完整引入 Element Plus，用一个按钮验证组件和样式都已进入应用。
3. 使用本地商品数组完成表格、空状态、删除确认和结果反馈。
4. 加入新增表单，再把同一套表单复用为编辑弹窗。
5. 最后缩窄浏览器窗口，检查标签、文字状态、按钮和表单是否仍可使用。
   ::::

::: warning 本章边界
本章不接接口、不使用 Axios、不引入 Pinia。`rows`、`loading` 和 `errorMessage` 都是本地状态；第 6 章再把本地数组替换为接口数据。
:::

## 5.2 知识点一：UI 组件库与 Element Plus 接入

**学习目标**

掌握 UI 组件库的作用和 Element Plus 的完整引入方式，能够让组件和样式正确进入 Vue 应用，并能粗略判断三套组件库的适用场景。

**语法/概念**

可以把 UI 组件库理解成“一箱已经做好的界面零件”。按钮、输入框、表格和弹窗已经有统一的外观与基本交互，我们不需要每次都从零开始写。

但组件库不会自动知道下面这些业务问题：

* 商品名称是否允许重复；
* 库存为什么不能小于 0；
* 哪些账号可以删除商品；
* 删除成功或失败后应该怎么提示；
* 数据应该来自本地数组还是后端接口。

| 组件库能帮忙的事情 | 仍然要由业务代码决定的事情 |
| --- | --- |
| 提供按钮、表格、表单、弹窗等组件 | 商品有哪些字段、字段代表什么 |
| 提供禁用、加载、校验样式 | 什么情况下应该禁用、什么输入算错误 |
| 提供消息框、确认框 | 什么时候提示、删除会影响什么 |
| 提供基础响应式和可访问性能力 | 页面在当前业务中是否真的可读、可操作 |

::: tip 🌟 进阶选学：前端 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 的完整引入语法模板如下：

```js
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'`：把组件库样式放进应用；
* 两者缺一不可。只注册插件不导入样式，按钮可能能点击，但外观会不正确；
* 自动按需导入可以减少打包体积，但要增加额外配置，等项目进入优化阶段再比较，本章不演示。

::: tip 思考问题
问题：如果页面能看到按钮文字，但按钮没有 Element Plus 的颜色、圆角和间距，应该先检查 JavaScript 注册还是 CSS 样式导入？
:::

**课堂演示**

**步骤 1：启动课堂演示项目并安装三套组件库。**

在仓库根目录打开终端，执行：

```bash
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` 中插件和样式的引入位置，再解锁完整代码。

::: code-tree title="三套 UI 组件库最小对比" entry="src/main.js" height="660px"

```js title="src/main.js" :active
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')
```

```vue title="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` 有内容 | 显示失败原因和重试方向 |

本章先用本地变量观察这四种状态，不发送接口请求：

```js
const rows = ref([])
const loading = ref(false)
const errorMessage = ref('')
```

Element Plus 表格的最小语法如下：

```vue
<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` 交给处理函数。

删除是一条完整操作链：

```text
点击删除 → 说明影响 → 用户确认 → 按 id 修改数组 → 显示操作结果
```

`ElMessageBox.confirm` 负责二次确认，`ElMessage` 负责告诉用户最后发生了什么。确认框不能代替删除逻辑，消息提示也不能代替数据更新。

::: tip 思考问题
问题：删除处理函数应该接收“当前行号”还是稳定 `id`？如果表格刚刚按库存排序，行号还能代表原来的商品吗？
:::

**课堂演示**

**步骤 1：恢复只使用 Element Plus 的课堂环境。**

`src/main.js` 保持 Element Plus 完整引入；Vant 和 Ant Design Vue 只用于上一节对比，不再进入商品管理主线。

```js
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**。先回答课堂问题，再解锁代码。

```vue
<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`：

```js
const draft = reactive({
  id: null,
  name: '',
  price: 0,
  stock: 0,
  enabled: true,
})

Object.assign(draft, { ...row })
```

Element Plus 表单校验是一条必须对齐的链：

```text
draft.name
    ↕
rules.name ↔ prop="name" ↔ v-model="draft.name"
```

最小语法模板：

```vue
<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>
```

```js
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
```

播放下面的流程，观察 `model`、`rules`、`prop` 和提交函数是怎样连接起来的。

把完整案例拆开看，重点只有四件事：

1. `draft` 是弹窗中的临时数据，取消时直接丢弃；
2. `v-model="dialogVisible"` 用同一个布尔值控制弹窗打开和关闭；
3. `model`、`rules`、`prop` 和 `v-model` 的字段名必须一致；
4. `submitting` 是提交锁，保存期间按钮显示加载并阻止重复点击。

| 组件或状态 | 简单说明 |
| --- | --- |
| `el-dialog` | 暂时盖在页面上的操作窗口 |
| `draft` | 用户正在修改、还没正式保存的副本 |
| `el-form` | 把多个输入字段和校验组织到一起 |
| `rules` | 每个字段提交前要通过的检查 |
| `prop` | 把当前表单项和某一条规则对上号 |
| `submitting` | 保存期间上锁，避免连续点击两次 |

课堂演示先把完整流程写在 `App.vue`，方便直接复制运行。确认新增和编辑都正确后，再放回第 4 章的管理端路由骨架，按下面的职责拆分：

::: file-tree title="商品管理模块" icon="colored"

* src/
  * views/admin/
    * **ProductListView.vue** # 保存商品数组，打开新增或编辑弹窗
  * components/product/
    * **ProductFormDialog.vue** # 管理 draft、表单校验和提交锁
    * ProductStatusTag.vue # 用文字和颜色显示在售或下架
      :::

`ProductListView.vue` 拥有已经保存的列表，`ProductFormDialog.vue` 只管理当前草稿。弹窗点击保存时把草稿交回列表，点击取消时直接丢弃草稿。

::: danger 避坑警示：草稿引用污染与校验四要素对齐

1. **绝不能直接引用原对象**：切勿写 `draft = row`。必须使用对象克隆（如 `{ ...row }`），否则用户在弹窗中打字时，即便最终点击“取消”，背后的表格数据也已被实时修改污染！
2. **校验四要素必须严格同名**：`el-form` 的 `:model="draft"`、`:rules="rules"`、`<el-form-item prop="name">` 与内部输入组件的 `v-model="draft.name"` 四处的字段名必须完全保持一致，拼错任何一处校验规则都将静默失效。
   :::

::: tip 🌟 进阶选学：新增与编辑双模式共用及提交防重锁
在成熟中后台业务中，新增与编辑往往复用同一个表单弹窗组件：

* **状态标记**：依靠 `editingId` 区分当前是新增（`null`）还是编辑（具体商品 ID）；
* **动态文案**：根据 `editingId` 动态显示弹窗标题“新增商品”或“编辑商品”；
* **防重复提交锁（submitting）**：在点击保存期间将 `submitting.value = true`，保存按钮呈现 `:loading="submitting"` 并暂时禁用取消，有效避免因网络延迟或手抖狂点导致产生多条重复数据。
  :::

::: warning 客户端校验不是安全边界
浏览器中的校验可以被绕过。它的主要作用是让用户尽早发现输入错误。真正接入后端时，服务端仍然必须再次校验数据。
:::

::: tip 思考问题
问题：为什么不能直接写 `draft = row`，然后让弹窗输入框修改这个对象？点击取消时，原商品会发生什么？
:::

**课堂演示**

**步骤 1：在上一节商品表格上加入新增弹窗。**

为了让取消按钮真正有用，表单中使用的编辑副本叫作 **draft**。课堂案例先完成“新增”，编辑功能留给紧接着的当堂练习。

```vue
<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：验证草稿、校验链和提交锁。**

1. 输入一半后点“取消”，表格不能增加记录；
2. 再次打开弹窗，上一轮未保存内容应该已经清空；
3. 把 `prop="name"` 临时改成错误字段，观察校验不再对应，再立即恢复；
4. 合法保存时连续点击“保存”，列表只能增加一条记录。

完成后应能清楚说明：输入先进入 `draft`，校验通过后才写入 `products`，`submitting` 负责阻止重复提交。

**当堂练习（分层双轨制）**

* **【必做任务（基础通关）】**：在现有代码基础上跑通“新增商品”完整闭环：点击“新增商品”打开弹窗，输入合法名称和价格（测试清空名称时能够正确拦截并显示校验提示），点击“保存”成功将新商品追加进表格并弹出成功消息；点击“取消”关闭弹窗且不产生多余记录。
* **【选做挑战（🌟 自主拓展）】**：引入 `editingId` 将弹窗扩展为编辑模式：为表格每行增加“编辑”按钮，点击后将当前行克隆复制进 `draft`；弹窗标题根据模式动态显示“编辑商品”或“新增商品”；保存时根据 `editingId` 按稳定 `id` 更新对应记录，并验证编辑中途点击“取消”原数据保持不变。

## 5.5 知识点四：可访问性与响应式布局

::: tip 🌟 进阶选学：企业级 Web 可访问性（a11y）与响应式适配
在日常前端开发中，保底的要求是养成规范的工程习惯：**输入框有可见 label、状态不仅依赖颜色（附带明确文字）、危险操作二次确认带具体名称**。
而更高级的“全键盘无鼠化流、无障碍读屏广播（`aria-live`）以及复杂桌面表格向手机端卡片响应式重构”，属于中大型企业或国际化产品的高阶可访问性（a11y）素养，供学有余力的同学自主探索，不作为基础达标强制考核。
:::

**学习目标**

掌握业务页面的基础可访问性与窄屏检查方法，能够对比移动商品卡片和桌面管理表格，并修复明显的不可读、不可点问题。

**语法/概念**

组件库只能提供基础能力，不能保证页面自动适合所有人和所有屏幕。我们仍然要检查：

| 检查项 | 简单说明 | 达标表现 |
| --- | --- | --- |
| 字段标签 | 不要让用户猜输入框是做什么的 | 每个输入框旁有明确 label，不能只放 placeholder |
| 文字状态 | 颜色可能看不清，也可能无法被读屏理解 | 显示“在售、下架、库存紧张”等文字 |
| 键盘操作 | 有些用户不使用鼠标 | Tab 能到达主要按钮，焦点顺序合理 |
| 危险操作 | 删除不能只写“确定吗” | 确认框写清商品名和删除影响 |
| 窄屏布局 | 桌面表格在手机上可能被压得看不懂 | 允许横向查看，或改成更适合手机的卡片 |
| 禁用原因 | 变灰不等于解释 | 按钮禁用，同时显示“库存为 0”或“商品已下架” |

同一组商品数据在不同界面上的重点不同：

* 移动商品卡片关注“快速浏览、立即操作”；
* 桌面管理表格关注“比较多条记录、编辑和管理”；
* 响应式设计不是把桌面表格硬缩小，而是重新安排信息优先级。

最小响应式语法：

```css
.page {
  display: grid;
  grid-template-columns: 360px 1fr;
}

@media (max-width: 760px) {
  .page {
    grid-template-columns: 1fr;
  }
}
```

::: tip 思考问题
问题：库存为 0 时只把按钮变成灰色，用户能不能确定它为什么不能点击？页面还应该补上什么文字？
:::

**课堂演示**

**步骤 1：创建移动商品卡片。**

在 `src/` 下新建 `components/` 文件夹，再创建 `MobileProductCard.vue`。然后把 `App.vue` 替换为下面代码。

::: code-tree title="移动商品卡片与桌面管理表格" entry="src/App.vue" height="700px"

```vue title="src/App.vue" :active
<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>
```

```vue title="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：检查桌面与窄屏。**

运行项目：

```bash
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` 和保存按钮 | 没有提交锁，保存函数被重复调用 |
| 弹窗在窄屏超出页面 | 弹窗宽度和输入组件宽度 | 使用固定像素宽度，没有考虑视口 |
| 表格把整个页面撑宽 | 表格外层容器和列宽 | 没有局部横向滚动，固定列过多 |

::: warning 先根据现象找证据
不要一看到页面不对就重装全部依赖。先看 Console、当前状态变量和对应组件属性，再决定修哪一处。
:::

## 5.7 本章小测

完成下面的小测，重点检查是否能根据业务现象选择正确做法，而不是只背组件名。

{{assessment:vue-element-plus-business-ui-check}}

## 5.8 本章知识自检

在做章末课后任务前，先用课堂案例完成一次自检。

| 自检问题 | 能拿出的证据 |
| --- | --- |
| 能说明组件库能做什么、不能做什么吗？ | 能指出“界面零件”和“业务规则”的区别 |
| 能证明 Element Plus 接入完整吗？ | 组件有样式，Console 没有解析错误 |
| 能区分列表四种状态吗？ | 能切换正常、加载、空数据和失败页面 |
| 能证明删除不会删错行吗？ | 排序或筛选后仍按稳定 `id` 删除目标商品 |
| 能说明校验链吗？ | 能对应 `model`、`rules`、`prop` 和 `v-model` |
| 能证明取消不污染原数据吗？ | 编辑草稿后取消，表格保持不变 |
| 能证明页面适合窄屏吗？ | 缩窄窗口后主要按钮仍能看见并操作 |

{{reflection-checkpoint:vue-element-plus-business-ui-checkpoint}}

## 5.9 课后任务：社团物资登记界面

### 任务目标

社团需要一个桌面端物资管理页，用于查看、登记、编辑和删除物资。使用 `resources/ch05/after-class-starter/` 作为起始项目，独立完成页面；目录、组件名和字段名使用英文。

这个任务综合本章知识，不提供操作流程。完成后的页面要让操作结果清楚可见，在空数据、无效输入和窄屏下也能继续操作。

### 基础通关要求（全员必做）

1. **物资数据表格**：使用 `el-table` 展示物资列表（物资名称、分类、数量、借用状态和操作列），必须使用 `row-key="id"` 绑定稳定行身份。
2. **状态直观表达**：借用状态使用 `el-tag` 标签展示，且必须同时包含文字说明（如“借用中”或“在库”），不能只依赖颜色。
3. **新增物资弹窗**：使用 `el-dialog` 承载新增表单，使用独立的空草稿 `draft` 输入，点击“保存”通过校验后向列表中追加新记录。
4. **表单非空校验**：使用 `el-form` 校验链（`model/rules/prop/v-model` 四要素对齐），名称不能为空且数量不能小于 0，未通过校验时阻断提交并高亮错误。
5. **行级删除二次确认**：点击操作列删除时弹出 `ElMessageBox.confirm` 二次确认（包含具体物资名）；确认后按稳定 `id` 从数组中移除，并弹出 `ElMessage.success` 提示；取消删除不改变数据。
6. **空数据优雅降级**：当物资列表被清空后，自动展示 `el-empty` 空状态并提供“新增物资”入口。

### 进阶拓展任务（自主选做 · 🌟 自主拓展）

1. **新增与编辑共用表单**：使用同一个弹窗实现新增与编辑双模式，使用对象克隆隔离草稿，确保编辑中途点击取消不改动原数据，保存时按 `editingId` 替换目标记录。
2. **响应式卡片流**：在手机或窄屏宽度下（`< 760px`），将物资表格重构为竖向排列的轻量物资卡片展示，提供跨端友好体验。
3. **无障碍键盘交互**：脱离鼠标，全程依靠 Tab 键、Enter 键与 Esc 键完成新增物资的完整输入与弹窗关闭。
4. **组件库选型小结**：撰写简短的组件库选型说明，解释为什么桌面端管理场景应统一使用 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 接口，并处理请求封装、加载和异常状态。
