第 6 章你已经会写组件了,但真到了做项目,总不能让每个表格、每个分页、每个弹出提示都自己从零糊一遍。这一章把 Element Plus 用起来——用现成的表单、表格、分页、轮播图、上传、消息提示,把「古典文学网」的界面在几行标签里搭出来。学会它,你的开发速度至少翻三倍。
# 组件库本体(生产依赖)
npm install element-plus
# 两个编译期插件(开发依赖)
npm install -D unplugin-vue-components unplugin-auto-import
然后改 vite.config.js,把两个插件挂上去(顺手把 @ 别名和 /api 代理也一起配上,后面几章都要用)。
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 },
},
},
})
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')
<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>:规则用数组声明,每一条规则就是「什么条件下、报什么错」。
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
}
}
<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>
<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>
<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>
<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>
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,否则出错时遮罩永远不消失
}
}
<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>
图标单独装在 @element-plus/icons-vue 里,按需 import;主题色则通过覆盖 CSS 变量一次性搞定。
# 图标是一个独立的包,需要单独安装
npm install @element-plus/icons-vue
/* 覆盖 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 样式之后引入,否则会被覆盖 */
<!-- 第 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'
骨架完全一样,换的只是「字段名」和「接口路径」。 */
第 3 题的要点:三个组件的职责是固定的——表单负责「收集条件」,表格负责「展示结果」,分页负责「告诉后端要第几页」。项目换了、字段换了,这套分工不变。