码上问学问代码之理,学编程之道

第 10 章 · Element Plus 组件库

第 6 章你已经会写组件了,但真到了做项目,总不能让每个表格、每个分页、每个弹出提示都自己从零糊一遍。这一章把 Element Plus 用起来——用现成的表单、表格、分页、轮播图、上传、消息提示,把「古典文学网」的界面在几行标签里搭出来。学会它,你的开发速度至少翻三倍。

为什么要用组件库

别把时间浪费在重造轮子上
先讲为什么「不就是一个按钮吗?我十分钟就能写出来。」——真动手你就知道不止十分钟。一个合格的按钮要考虑:鼠标悬停变色、按下有反馈、禁用状态不吃点击、加载中要转圈并且防重复提交、键盘 Tab 能聚焦、回车能触发、屏幕阅读器能读出来……
这些细节和业务毫无关系,却占了大量时间。组件库把这些「标准化零件」都替你打磨好了:你只管写业务,界面交给它。
避坑Element Plus 只支持 Vue 3。如果你在教程里看到 import ElementUI from 'element-ui'、this.$message、new Vue(),那是 Vue 2 时代的 Element UI,包名不一样、写法也不一样,装错了启动就报错。本课程统一用 element-plus + <script setup>。

安装与按需引入

只打包用到的组件
先讲为什么Element Plus 有 80 多个组件,全套 CSS 加 JS 接近 1MB。可你的项目可能只用其中十个。「按需引入」就是让打包工具只把你真正用到的那几个组件打进产物里,页面首屏能快一大截。做法不靠手写,而是装两个 Vite 插件,让它们在编译时自动帮你 import。
动手做在 hivue 项目根目录装 Element Plus 和两个插件。
终端bash
# 组件库本体(生产依赖)
npm install element-plus

# 两个编译期插件(开发依赖)
npm install -D unplugin-vue-components unplugin-auto-import
终端输出命令行执行结果
运行结果: added 12 packages, and audited 63 packages in 4s hivue@0.0.0 /Users/you/hivue ├── element-plus@2.7.6 ├── unplugin-auto-import@0.17.6 └── unplugin-vue-components@0.27.2

然后改 vite.config.js,把两个插件挂上去(顺手把 @ 别名和 /api 代理也一起配上,后面几章都要用)。

vite.config.jsjs
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [
    vue(),
    // 自动 import:ElMessage / ElMessageBox / ElLoading 这类“函数式”API,不用手写 import
    AutoImport({ resolvers: [ElementPlusResolver()] }),
    // 自动注册:模板里写 <el-button> 就自动引入对应组件与样式
    Components({ resolvers: [ElementPlusResolver()] }),
  ],
  resolve: {
    // 让 '@/views/Home.vue' 指向 src/views/Home.vue
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  server: {
    port: 5173,
    // 把 /api 开头的请求转发给本地 Flask,解决跨域(第 9 章已详述)
    proxy: {
      '/api': { target: 'http://127.0.0.1:5000', changeOrigin: true },
    },
  },
})
页面效果浏览器运行结果
运行结果: # 配置生效后,<template> 里直接写组件标签即可,无需 import # 页面渲染出带 Element Plus 样式的按钮 # 浏览器控制台无 "Failed to resolve component: el-button" 警告
讲透
  • AutoImport 管的是函数式 API(ElMessage、ElMessageBox、ElLoading);Components 管的是模板标签(<el-form>、<el-table>)。两个一起配,才算完整按需引入。
  • 插件会在项目根目录生成 auto-imports.d.ts 和 components.d.ts 两个声明文件,它们只是给编辑器看的类型提示,别删、也别提交到 Git 太纠结。
  • 如果你嫌麻烦,也可以「全量引入」——在 main.js 里 app.use(ElementPlus)。开发快,但产物大。交给学生项目推荐按需引入。
全量引入写法(main.js,仅作对比)js
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'   // 全量样式,约 300KB
import App from './App.vue'

createApp(App).use(ElementPlus).mount('#app')
页面效果浏览器运行结果
运行结果: # 一行都不用 import 组件,随便写 # 代价:无论用不用,全部组件与样式都进了产物

el-form:表单与校验

登录 / 注册表单
先讲为什么表单最烦的不是画界面,是校验:用户名不能空、长度 3–20、只能是字母数字下划线、密码要 6 位以上……如果这些判断全写在 submit() 里,代码会变成一堆 if,而且报错信息还得自己控制显示位置。
Element Plus 把「规则」和「界面」拆开了:你用 rules 声明规则,组件负责校验、负责在输入框下面显示红字。
动手做写「古典文学网」的登录表单。三件套要记牢:el-form 用 :model 绑定数据对象、用 :rules 绑定规则;每个 el-form-item 用 prop 指出它负责哪个字段。
src/views/Login.vuevue
<template>
  <el-form ref="formRef" :model="form" :rules="rules" label-width="70px" class="login-form">
    <el-form-item label="用户名" prop="username">
      <el-input v-model="form.username" placeholder="3-20 位字母、数字或下划线" clearable />
    </el-form-item>
    <el-form-item label="密码" prop="password">
      <el-input v-model="form.password" type="password" show-password @keyup.enter="submit" />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" :loading="loading" @click="submit">登录</el-button>
      <el-button @click="$router.push('/register')">去注册</el-button>
    </el-form-item>
  </el-form>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示一张居中登录卡片: [用户名] ___________________ [密码 ] ___________________ [ 登录 ] [ 去注册 ] 点「登录」且用户名为空 → 输入框变红,下方显示「请输入用户名」

对应的 <script setup>:规则用数组声明,每一条规则就是「什么条件下、报什么错」。

src/views/Login.vue(脚本部分)js
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'

const router = useRouter()
const userStore = useUserStore()

const formRef = ref(null)      // 表单实例,用来调 validate()
const loading = ref(false)
const form = reactive({ username: '', password: '' })

// 规则与接口文档 2.1 / 2.2 的校验要求保持一致,前后端都拦,体验最好
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { pattern: /^[A-Za-z0-9_]{3,20}$/, message: '3-20 位字母、数字或下划线', trigger: 'blur' },
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 20, message: '密码长度需为 6-20 位', trigger: 'blur' },
  ],
}

async function submit() {
  // validate() 返回 Promise:全部通过才 resolve,否则 reject
  const valid = await formRef.value.validate().catch(() => false)
  if (!valid) return

  loading.value = true
  try {
    await userStore.login(form)         // 内部调 POST /api/user/login
    ElMessage.success('登录成功')
    router.push('/')                    // 登录后回首页
  } finally {
    loading.value = false
  }
}
页面效果浏览器运行结果
运行结果: # 用户名填 "ab"(不足 3 位)→ 失焦即报「3-20 位字母、数字或下划线」 # 全部合法 → 发请求,成功弹出绿色「登录成功」,跳转首页
避坑校验不生效,九成是这三样之一漏了:① el-form 忘了写 :model;② el-form-item 忘了写 prop(不写 prop,组件根本不知道该用哪条规则,也不会有红字);③ prop 的名字和 rules 里的键、以及 v-model 绑的字段名对不上。三个名字必须一模一样。

el-table:作品列表表格

后台管理的主力
动手做把接口 GET /api/article/list 返回的 data.list 直接丢给 el-table 的 :data,每列用一个 el-table-column 描述。要自定义单元格内容,就用 #default 插槽。
src/views/admin/ArticleTable.vuevue
<template>
  <el-table :data="list" v-loading="loading" stripe border style="width:100%">
    <el-table-column type="index" label="#" width="56" />
    <el-table-column prop="title" label="作品" min-width="160" show-overflow-tooltip />
    <el-table-column prop="author" label="作者" width="110" />
    <el-table-column prop="category_name" label="分类" width="90" />
    <el-table-column prop="views" label="浏览量" width="100" sortable />
    <el-table-column prop="likes" label="点赞" width="90" sortable />
    <el-table-column label="操作" width="150" fixed="right">
      <template #default="{ row }">
        <el-button size="small" @click="$router.push(`/article/${row.id}`)">详情</el-button>
        <el-button size="small" type="danger" @click="remove(row)">下架</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { getArticleList } from '@/api/article'

const list = ref([])
const loading = ref(false)

onMounted(async () => {
  loading.value = true
  const data = await getArticleList({ page: 1, page_size: 10 })
  list.value = data.list       // 拦截器已经把 {code,msg,data} 里的 data 摘出来了
  loading.value = false
})

function remove(row) {
  // 真实项目这里再补一个确认弹窗,见下一节 ElMessageBox
  list.value = list.value.filter((it) => it.id !== row.id)
}
</script>
页面效果组件渲染结果
运行结果: | # | 作品 | 作者 | 分类 | 浏览量 | 点赞 | 操作 | | 1 | 静夜思 | 李白 | 唐诗 | 328 | 42 | [详情][下架] | | 2 | 春望 | 杜甫 | 唐诗 | 256 | 31 | [详情][下架] | | 3 | 念奴娇·赤壁怀古 | 苏轼 | 宋词 | 412 | 58 | [详情][下架] | 点「浏览量」表头 → 表格按浏览量排序;点「详情」→ 跳到作品详情页
讲透
  • prop 决定这一列读对象里的哪个字段;label 是表头文字;width / min-width 控制列宽。
  • sortable 是前端排序(只排当前页数据)。项目里要「全库按浏览量排」,得把 order_by=hot 传给后端接口,别指望表格自己排。
  • #default="{ row }" 是作用域插槽,row 就是当前这一行的数据对象——想在单元格里放按钮、标签、图片,都靠它。
  • v-loading 是 Element Plus 的指令式加载遮罩,比写一个 v-if 转圈优雅得多。

el-pagination:分页

和接口的 page / page_size 对齐
先讲为什么全站有几千篇作品,一次性全查出来会又慢又卡。分页的本质是「每次只问后端要一小撮」——接口文档 1.5 节约定了:你传 page 和 page_size,后端回 { list, total, page, page_size }。el-pagination 做的事,就是把这个 page 变成用户可以点的一排页码。
动手做把分页组件钉在表格下面,用户翻页时重新请求接口。
ArticleList.vue(分页部分)vue
<el-pagination
  v-model:current-page="query.page"
  v-model:page-size="query.page_size"
  :total="total"
  :page-sizes="[5, 10, 20]"
  layout="total, sizes, prev, pager, next, jumper"
  background
  @current-change="loadList"
  @size-change="onSizeChange"
/>

<script setup>
import { ref, reactive } from 'vue'
import { getArticleList } from '@/api/article'

const query = reactive({ page: 1, page_size: 10, category_id: null, keyword: '' })
const list = ref([])
const total = ref(0)

async function loadList() {
  const data = await getArticleList(query)
  list.value = data.list
  total.value = data.total        // total 用来算总页数
}

// 每页条数变了,页码必须回到第 1 页,否则会请求到不存在的页
function onSizeChange() {
  query.page = 1
  loadList()
}

loadList()
</script>
页面效果组件渲染结果
运行结果: 共 6 条 [5 条/页 ▾] ‹ 1 2 › 前往 [1] 页 点第 2 页 → 请求 GET /api/article/list?page=2&page_size=5 后端返回 { "list": [...], "total": 6, "page": 2, "page_size": 5 },表格刷新为第 2 页数据
避坑改了「每页条数」之后一定要把 page 重置为 1。比如你在第 5 页、每页 10 条,现在改成每页 20 条——总共可能只剩 3 页,请求 page=5 就返回空列表,用户会以为数据丢了。这是分页最经典的 bug。

el-carousel:首页轮播图

对接 /api/banner/list
动手做首页顶部要一张会自己切换的图。el-carousel 是容器,每个 el-carousel-item 是一屏,用 v-for 循环后端返回的轮播图数组。
src/views/Home.vue(轮播部分)vue
<el-carousel height="320px" :interval="4000" arrow="always" indicator-position="outside">
  <el-carousel-item v-for="b in banners" :key="b.id" @click="$router.push(b.link)">
    <div class="banner-slide">
      <h3>{{ b.title }}</h3>
      <p>点击进入 →</p>
    </div>
  </el-carousel-item>
</el-carousel>

<script setup>
import { ref, onMounted } from 'vue'
import { getBannerList } from '@/api/home'

const banners = ref([])

onMounted(async () => {
  banners.value = await getBannerList()   // GET /api/banner/list
})
</script>

<style scoped>
.banner-slide {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #8b5a2b, #c99a63);
  color: #fff;
  cursor: pointer;
}
</style>
页面效果组件渲染结果
运行结果: 浏览器显示一张高 320px 的轮播区,每 4 秒自动切换: [ 品读古典文学,涵养中国心灵 ] [ 点击进入 → ] ● ○ ○ ← 底部指示器,左右有箭头可手动切换
讲透
  • height 必须显式给,否则容器高度为 0,什么都看不见——这是轮播图最常见的「空白」原因。
  • :interval 是自动切换间隔(毫秒),arrow="always" 让左右箭头常驻,indicator-position 控制小圆点位置。
  • 真实的项目里,el-carousel-item 内会放 <img>。数据里 b.image 是 /static/images/banner1.jpg 这样的后端静态资源路径,前端拼上后端域名才能显示(第 12 章讲生产环境怎么配)。

el-upload:头像上传

action / headers / before-upload
el-upload 的完整链路:选文件 → 校验 → 带凭证上传 → 拿回 URL 回显① 选择文件before-upload类型/大小校验② 自动发请求action=上传接口headers=带 token③ Flask 存盘返回 {url}④ on-success回显头像上传必须带 token:el-upload 自己发请求,不走你的 axios 实例——拦截器帮不上忙所以 headers 要手动配:headers: { Authorization: 'Bearer ' + userStore.token }before-upload 返回 false = 拦下不合格的文件,不浪费一次请求
先讲为什么上传看着简单,其实有三个坑:① 文件要发给哪个地址?② 上传头像必须登录,请求头里得带 token,谁来带?③ 用户选了个 20MB 的 exe,是不是也直接传上去了?
el-upload 用三个 prop 正好对应这三件事:action、:headers、:before-upload。
动手做写用户中心的头像上传。接口是 POST /api/user/avatar,multipart/form-data,限 jpg/png/gif 且 ≤ 2MB。
src/components/AvatarUpload.vuevue
<template>
  <el-upload
    class="avatar-uploader"
    :action="uploadUrl"
    :headers="uploadHeaders"
    :show-file-list="false"
    :before-upload="beforeUpload"
    :on-success="onSuccess"
    :on-error="onError"
    name="file"
  >
    <img v-if="userStore.userInfo?.avatar" :src="userStore.userInfo.avatar" class="avatar" />
    <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
  </el-upload>
</template>

<script setup>
import { computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// action 就是后端上传接口的完整地址(开发环境 /api 会被 Vite 代理到 Flask)
const uploadUrl = '/api/user/avatar'

// 关键:把 token 放进请求头,否则后端 @login_required 会返回 401
const uploadHeaders = computed(() => ({
  Authorization: `Bearer ${userStore.token}`,
}))

// 在文件真正发出去之前拦一道:类型和大小
function beforeUpload(file) {
  const allow = ['image/jpeg', 'image/png', 'image/gif']
  if (!allow.includes(file.type)) {
    ElMessage.error('只允许上传 jpg/png/gif 图片')
    return false      // 返回 false = 终止上传
  }
  if (file.size / 1024 / 1024 > 2) {
    ElMessage.error('图片大小不能超过 2MB')
    return false
  }
  return true
}

function onSuccess(res) {
  if (res.code !== 200) return ElMessage.error(res.msg)
  userStore.userInfo.avatar = res.data.avatar   // 立刻刷新页面上的头像
  ElMessage.success('头像上传成功')
}

function onError() {
  ElMessage.error('上传失败,请稍后重试')
}
</script>

<style scoped>
.avatar { width: 100px; height: 100px; border-radius: 50%; object-fit: cover; }
.avatar-uploader-icon { font-size: 28px; width: 100px; height: 100px; border: 1px dashed #d9d9d9; border-radius: 50%; }
</style>
页面效果组件渲染结果
运行结果: 点击圆形占位 → 打开系统文件选择框 选一张 3MB 的 jpg → 红色提示「图片大小不能超过 2MB」,不发起请求 选一张 200KB 的 png → 请求 POST /api/user/avatar 接口返回 { "code": 200, "msg": "上传成功", "data": { "avatar": "/static/upload/avatar/3_20261012.png" } } 页面头像立即换成新图,并弹出绿色「头像上传成功」
避坑el-upload 默认不走你封装的那个 axios 实例——它内部用的是自己的 XMLHttpRequest,所以第 9 章写的请求拦截器不会自动给上传请求加 token。必须像上面那样手动传 :headers。忘了这一步,表现就是「别的接口都好好的,只有上传头像提示请先登录(401)」。

反馈:Message / MessageBox / Loading

函数式 API,三行搞定
先讲为什么用户点了按钮,总得知道「成了没有」——成功了弹个绿条,失败了弹个红条,删除前问一句「确定吗」,加载时盖一层遮罩。这三种反馈用的场景不同,Element Plus 分别给了 消息提示、确认框、加载遮罩。
它们的共同点是:不在模板里写标签,而是在 JS 里直接调函数,调完自动出现、自动消失。
动手做给「删除我的书评」加上确认框,给点赞加上轻提示,给列表加上整页 loading。
src/views/ArticleDetail.vue(反馈部分)js
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { deleteComment } from '@/api/comment'

// ① ElMessage:一闪而过的轻提示,适合「操作成功 / 失败」
async function onLikeComment(id) {
  await likeComment({ comment_id: id })
  ElMessage.success('点赞成功')
  ElMessage.warning('你已经点过赞了')     // 警告用黄色
  ElMessage.error('操作失败,请稍后重试')  // 错误用红色
}

// ② ElMessageBox:需要用户「确认」的弹窗,会返回 Promise
async function onDelete(id) {
  try {
    await ElMessageBox.confirm('确定要删除这条书评吗?删除后不可恢复', '提示', {
      confirmButtonText: '确定删除',
      cancelButtonText: '取消',
      type: 'warning',
    })
  } catch {
    return            // 用户点了「取消」,Promise reject,直接返回
  }
  await deleteComment(id)          // DELETE /api/comment/<id>
  ElMessage.success('删除成功')
}

// ③ ElLoading:手动开、手动关。适合「整页/整块区域」的等待
async function refreshAll() {
  const inst = ElLoading.service({ text: '正在加载…', background: 'rgba(255,255,255,.7)' })
  try {
    await loadDetail()
    await loadComments()
  } finally {
    inst.close()       // 一定要放在 finally,否则出错时遮罩永远不消失
  }
}
页面效果浏览器运行结果
运行结果: 点「点赞」→ 页面顶部弹出绿色条「点赞成功」,2 秒后自动消失 点「删除」→ 居中弹出确认框:确定要删除这条书评吗?[取消] [确定删除] 点取消 → 什么都不发生;点确定 → 调接口、弹「删除成功」 切页时 → 出现半透明遮罩 + 「正在加载…」,数据到位后消失
讲透
  • 这三个都是函数式组件:import 进来直接调,不写在 <template> 里。
  • ElMessageBox.confirm 的「取消」是抛异常(Promise reject),所以必须 try / catch,否则控制台会飘一片红色 Uncaught (in promise)。
  • 最佳实践是别每个页面都手写 ElMessage。第 9 章的响应拦截器里已经统一处理了失败提示;页面里只补「成功」和「需要确认」的交互就够了,避免同一个错误弹两次。

布局、图标与主题

搭出后台壳子,再换个古典配色
动手做用 el-container 系列搭「左菜单 + 上顶栏 + 主内容区」的经典后台布局。嵌套规则很简单:el-container 里放 aside+container 就是左右分栏,放 header+main 就是上下。
src/layouts/AdminLayout.vuevue
<template>
  <el-container class="admin">
    <el-aside width="200px">
      <el-menu :default-active="$route.path" router>
        <el-menu-item index="/admin/article">
          <el-icon><Document /></el-icon><span>作品管理</span>
        </el-menu-item>
        <el-menu-item index="/admin/user">
          <el-icon><User /></el-icon><span>用户管理</span>
        </el-menu-item>
      </el-menu>
    </el-aside>

    <el-container>
      <el-header height="56px">古典文学网 · 管理后台</el-header>
      <el-main>
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import { Document, User } from '@element-plus/icons-vue'
</script>

<style scoped>
.admin { height: 100vh; }
.el-header { display: flex; align-items: center; background: #fff; border-bottom: 1px solid #eee; }
</style>
页面效果组件渲染结果
运行结果: ┌──────────┬─────────────────────────────┐ │ 作品管理 │ 古典文学网 · 管理后台 │ │ 用户管理 ├─────────────────────────────┤ │ │ (router-view 渲染的子页面) │ └──────────┴─────────────────────────────┘ 点左侧菜单 → 主内容区切换页面,当前菜单项高亮

图标单独装在 @element-plus/icons-vue 里,按需 import;主题色则通过覆盖 CSS 变量一次性搞定。

安装图标 + 覆盖主题色bash
# 图标是一个独立的包,需要单独安装
npm install @element-plus/icons-vue
终端输出命令行执行结果
运行结果: added 1 package in 1s
src/styles/theme.csscss
/* 覆盖 Element Plus 的 CSS 变量,全局换成“古典”的赭石色系 */
:root {
  --el-color-primary: #8b5a2b;
  --el-color-primary-light-3: #a97b4f;
  --el-color-primary-light-5: #c39a76;
  --el-color-primary-light-7: #dcc0a6;
  --el-color-primary-light-9: #f3e9df;
  --el-border-radius-base: 6px;
  --el-font-size-base: 14px;
}

/* 在 main.js 里 import './styles/theme.css' 即可全局生效 */
/* 注意:主题文件要在 element-plus 样式之后引入,否则会被覆盖 */
样式效果浏览器渲染结果
运行结果: # 所有 el-button type="primary" 由默认蓝色变为赭石色 # 输入框、分页当前页、菜单激活项同步变色
讲透
  • el-aside / el-header / el-main 都只是带默认内边距的语义容器,需要多高多宽自己用 CSS 定。
  • el-menu 加 router 属性后,index 就是路由路径,点菜单等于 router.push;:default-active 绑当前路由,刷新后菜单不会「掉高亮」。
  • 改主题优先覆盖 CSS 变量,别去 !important 硬盖组件内部类名——后者一升级就崩。深度定制(换整套配色)才需要 SCSS 的 @forward 方案。
动手练习
1写一个「注册」表单,字段为用户名、密码、昵称、邮箱。要求:用户名 3–20 位字母数字下划线、密码 6–20 位、邮箱格式校验(用 type: 'email')。提交时先校验再发请求。
2把作品列表做成「表格 + 分页」的组合:表格展示标题/作者/分类/浏览量,底部分页每页 5 条。要求切换每页条数时页码回到第 1 页。
3迁移练习:「红影赏析」的电影列表也要一个搜索框和分页。请说明:用 el-form(inline 模式)放搜索框、用 el-table 展示、用 el-pagination 分页,三个组件的 :data / v-model / @current-change 分别该绑什么?和「古典文学网」有什么不同?
参考答案
练习答案:注册表单 + 分页要点vue
<!-- 第 1 题:注册表单的 rules -->
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { pattern: /^[A-Za-z0-9_]{3,20}$/, message: '3-20 位字母、数字或下划线', trigger: 'blur' },
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 20, message: '密码长度需为 6-20 位', trigger: 'blur' },
  ],
  nickname: [{ max: 20, message: '昵称不超过 20 字', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: ['blur', 'change'] },
  ],
}

<!-- 第 2 题:分页与每页条数 -->
<el-pagination
  v-model:current-page="query.page"
  v-model:page-size="query.page_size"
  :total="total"
  :page-sizes="[5, 10, 20]"
  layout="total, sizes, prev, pager, next"
  @current-change="loadList"
  @size-change="() => { query.page = 1; loadList() }"
/>

/* 第 3 题:电影列表与文学列表的差别
   ① el-form 用 :inline="true",搜索框只绑 keyword,不需要 rules
   ② el-table 的 :data 绑的是电影数组 movies(字段改为 title / director / rating)
   ③ el-pagination 的 @current-change 仍然绑 loadMovies,
      只是请求参数从 category_id 换成 genre,order_by 换成 'rating'
   骨架完全一样,换的只是「字段名」和「接口路径」。 */
页面效果组件渲染结果
运行结果: # 第 1 题:邮箱填 "abc" → 提示「邮箱格式不正确」;全部合法才请求 /api/user/register # 第 2 题:共 6 条,每页 5 条 → 底部显示 共 6 条 ‹ 1 2 ›;从第 2 页改成每页 20 条 → 自动回到第 1 页 # 第 3 题:结论 —— 表格/分页/表单的用法是「同一套骨架换字段」,会了文学网就会了红影

第 3 题的要点:三个组件的职责是固定的——表单负责「收集条件」,表格负责「展示结果」,分页负责「告诉后端要第几页」。项目换了、字段换了,这套分工不变。

本章小结