主题
品牌管理模块-添加新的品牌与表单校验(Day3 1/2)
本页对应 day3,共 6 段视频,约 1 小时 53 分。
学习目标
- 能够使用 Element Plus Dialog、Form、FormItem 搭建添加/修改品牌对话框,并实现显示隐藏、标题、底部按钮及表单布局对齐。
- 能够封装添加与更新品牌的 API 函数,根据参数是否包含 id 发起 POST/PUT 请求,并处理请求返回值。
- 能够收集品牌名称与图片上传地址,使用 before-upload 钩子校验图片类型和大小,并通过上传成功回调获取服务器返回的图片地址。
- 能够完成添加品牌提交流程:点击确定发起请求,成功后关闭对话框、提示并刷新品牌列表,失败时给出错误提示,并在打开对话框前重置表单数据。
- 能够为表单配置 model、prop 和 rules,使用 required、min/max、message、trigger 等规则实现品牌名称和图片的表单校验。
知识点
1.1 新增品牌静态搭建:先把对话框、表单和上传区摆出来
为什么需要:添加品牌不是跳转新页面,而是在当前品牌列表页上弹出对话框,保留列表状态,用户填完名称、传完 LOGO 再提交。转写稿里明确说,这个结构既服务“新增”,也为后面的“编辑”复用。
怎么想:先不要写逻辑,只盯接口需要什么:品牌名称 tmName、品牌 LOGO 地址 logoUrl。所以静态结构就是:el-dialog 包 el-form,表单项用 el-form-item,上传用 el-upload。对话框显示隐藏由 v-model 控制,底部按钮放进 footer 插槽。
怎么做:
vue
<el-dialog v-model="dialogVisible" title="添加品牌" width="500px">
<el-form :model="tradeMark" :rules="rules" label-width="100px">
<el-form-item label="品牌名称" prop="tmName">
<el-input v-model="tradeMark.tmName" placeholder="请输入品牌名称" />
</el-form-item>
<el-form-item label="品牌LOGO" prop="logoUrl">
<el-upload
:action="uploadAction"
:show-file-list="false"
:before-upload="beforeUpload"
:on-success="handleSuccess"
>
<img v-if="tradeMark.logoUrl" :src="tradeMark.logoUrl" width="80" />
<el-icon v-else><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</template>
</el-dialog>易错点:转写稿里的“居民插槽”是 footer 插槽;“比放 upload”是 before-upload。label-width 要统一,否则“品牌名称”和“品牌LOGO”对不齐;表单外层 el-form 和表单项 el-form-item 要配套使用。
1.2 书写 API:新增和更新可以合成一个函数
为什么需要:新增走 POST,更新走 PUT,参数几乎一样,区别只是更新多一个 id。如果拆成两个函数,组件里还要判断调用哪个,容易写重复。
怎么想:先看接口文档:新增参数是 tmName、logoUrl;更新参数多 id。所以函数入参统一为品牌对象,内部判断 data.id 是否存在:有 id 走更新,没有走新增。转写稿强调“新增不需要 ID,已有品牌才有 ID”。
怎么做:
ts
export const reqAddOrUpdateTrademark = (data: Trademark) => {
return data.id
? request.put(API.UPDATE, data)
: request.post(API.ADD, data)
}易错点:不要把新增接口也强行传 id;路径按项目 API 常量填,不要手写错。转写稿中口述的“吹刀 mark”对应品牌表单对象,实际命名以项目为准。
1.3 收集新增品牌数据并完成添加
为什么需要:点“确定”时要拿到表单名称和上传成功后的图片地址,组成请求体发给后端;成功后关闭对话框、提示、刷新列表,失败则提示。
怎么想:数据用响应式对象收集,名称由 v-model 绑定;图片地址不能拿本地文件,要拿上传成功后服务器返回的 response.data。上传前用 before-upload 校验类型和大小,上传成功用 on-success 收集地址。提交时调用 1.2 的函数,成功后再重新获取品牌列表。
怎么做:
ts
const tradeMark = reactive<Trademark>({ tmName: '', logoUrl: '' })
const beforeUpload = (file: File) => {
const okType = ['image/png', 'image/jpeg', 'image/gif'].includes(file.type)
const okSize = file.size / 1024 / 1024 < 2
if (!okType) { ElMessage.error('上传文件格式不正确'); return false }
if (!okSize) { ElMessage.error('上传文件大小不能超过2M'); return false }
return true
}
const handleSuccess = (response: any) => {
tradeMark.logoUrl = response.data
}
const confirm = async () => {
try {
await reqAddOrUpdateTrademark(tradeMark)
dialogVisible.value = false
ElMessage.success('添加成功')
getTrademarkList()
} catch (e) {
ElMessage.error('添加失败')
}
}
const resetTradeMark = () => {
tradeMark.tmName = ''
tradeMark.logoUrl = ''
}易错点:转写稿里调试上传时发现请求少了 /app-dev 代理前缀,Network 里看请求能复现;HTTP 200 是成功,304 常是缓存,404 是请求失败,401 是未授权,5xx 是服务端问题。清空表单时,如果 tradeMark 是 reactive 代理对象,不要整体重新赋值,直接清空字段;打开对话框前调用 resetTradeMark(),避免上次内容残留。
表单校验用在哪些地方
为什么需要:后台管理系统里,只要让用户填数据,就不能完全相信用户输入。登录要校验账号密码,注册要校验手机号、密码规则,添加品牌要校验品牌名称不能为空、品牌 LOGO 必须有。否则用户什么都不填就点确定,请求发出去也大概率失败。
怎么想:先把“表单校验”理解成两层:第一层是数据收集,v-model 把输入值收进对象;第二层是规则校验,el-form 根据规则判断能不能提交。转写稿里反复强调,写业务不要只盯页面,要盯接口需要什么参数。添加品牌最终需要 tmName 和 logoURL,所以校验也围绕这两个字段展开。
怎么做:在对话框里用 el-form 包住 el-form-item,每个表单项里放 el-input、el-upload 等输入组件。例如:
vue
<el-form :model="brandForm" :rules="rules">
<el-form-item label="品牌名称" prop="tmName">
<el-input v-model="brandForm.tmName" placeholder="请输入品牌名称" />
</el-form-item>
<el-form-item label="品牌LOGO" prop="logoURL">
<el-upload ... />
</el-form-item>
</el-form>易错点:el-form 是表单外层,el-form-item 是表单项,二者要配合使用;label 是表单项前面的文字,不要和校验字段名混在一起。
三步接入校验:model、prop、rules
为什么需要:el-form 本身不知道你要校验哪个对象、哪个字段、用什么规则。必须把这三件事告诉它。
怎么想:转写稿里总结得很清楚:第一步给 el-form 加 model,告诉表单“数据收集到哪个对象身上”;第二步给每个 el-form-item 加 prop,告诉表单项“你校验的是对象里的哪个字段”;第三步给 el-form 加 rules,写具体规则。
怎么做:
ts
const brandForm = reactive({
tmName: '',
logoURL: ''
})
const rules = {
tmName: [
{ required: true, message: '品牌名称不能为空', trigger: 'blur' },
{ min: 2, max: 5, message: '品牌名称至少2位,最多5位', trigger: 'blur' }
],
logoURL: [
{ required: true, message: '品牌LOGO不能为空', trigger: 'change' }
]
}易错点:prop 的值必须和 model 里的字段名一致,rules 里的 key 也要和 prop 一致。转写稿里专门提醒“单词不要写错,写错就废了”。另外,model 必须是响应式对象,不能写成普通对象。
规则对象怎么写:required、min/max、message、trigger
为什么需要:规则对象决定提示什么、什么时候提示、提示中文还是英文。
怎么想:每个字段对应一个数组,数组里可以放多条规则。required: true 表示必填,表单项文字前会出现五角星;min、max 表示字符个数的最小值和最大值;message 是错误提示;trigger 是触发校验的时机。
怎么做:转写稿调试时先不写 message,提示默认是英文,类似“tmName must be between 2 and 5 characters”。加上 message 后可以改成中文,例如“品牌名称至少2位,最多5位”。trigger 常用两个值:blur 表示失去焦点时校验,change 表示文本发生变化时校验。
易错点:min、max 约束的是字符个数,不是字节数;required 只表示必填,不表示长度;trigger 写错就不会按预期触发。转写稿还提到,change 这种写法不常用,因为它只能约束长度,没法约束“里面写的到底是什么内容”,比如密码要求大小写、特殊符号时就不够用。
调试过程与清除校验结果的提醒
转写稿的调试顺序是:先写 required 看五角星有没有出现;再写 min、max 看失焦后有没有提示;然后补 message 把默认英文改成中文;最后改 trigger 观察失焦触发和输入变化触发的区别。这个顺序适合课堂练习,也适合自己排查“为什么没提示”。
本节笔记最后标注了“清除表单校验结果”。转写稿里没有展开具体清除校验提示的 API,但视频 5 讲清空收集数据时有一个关键点:brandForm 是 reactive 常量,不能直接 brandForm = { ... } 重新赋值,否则会丢失代理对象;正确思路是把代理对象里的字段置空,例如把 tmName、logoURL 清空。易错点:清空 v-model 数据和清除校验提示不是一回事,不能凭感觉写;校验提示的清除要依赖表单组件提供的方法,实际中后续补充。写表单校验时先把 model、prop、rules 三步写对,再处理清空和复位。
课堂画面






代码示例
示例一:新增品牌对话框静态搭建(el-dialog + el-form + el-upload)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>添加品牌 - 对话框静态搭建</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue/dist/index.iife.min.js"></script>
<style>
/* el-upload 默认没有边框,这里自己补一个头像上传框的样式 */
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}
.avatar-uploader .avatar {
width: 100px;
height: 100px;
display: block;
object-fit: cover;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
text-align: center;
line-height: 100px;
}
</style>
</head>
<body>
<div id="app">
<el-button type="primary" @click="openDialog">添加品牌</el-button>
<!-- 对话框:显示隐藏由 v-model 控制 -->
<el-dialog
v-model="showDialog"
title="添加品牌"
width="500px"
style="margin-top: 100px"
>
<!-- 对话框里放表单:label-width 写在 el-form 上,所有表单项统一继承 -->
<el-form :model="trademarkParams" label-width="100px" style="width: 80%">
<el-form-item label="品牌名称">
<el-input v-model="trademarkParams.tmName" placeholder="请你输入品牌名称" />
</el-form-item>
<el-form-item label="品牌LOGO">
<el-upload
class="avatar-uploader"
action="/app-dev/api/product/upload"
:show-file-list="false"
>
<!-- 有图片地址就展示图片,没有就展示加号 -->
<img v-if="trademarkParams.logoUrl" :src="trademarkParams.logoUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-form>
<!-- 底部按钮用 footer 具名插槽 -->
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="showDialog = false">确定</el-button>
</template>
</el-dialog>
</div>
<script>
const { createApp, ref } = Vue;
const app = createApp({
setup() {
// 控制对话框显示与隐藏的属性,默认 false(隐藏)
const showDialog = ref(false);
// 收集新增品牌的数据
const trademarkParams = ref({ tmName: '', logoUrl: '' });
const openDialog = () => {
showDialog.value = true;
};
return { showDialog, trademarkParams, openDialog };
}
});
app.use(ElementPlus);
// 图标必须注册,否则 <Plus /> 渲染不出来
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.mount('#app');
</script>
</body>
</html>关键行与易错点
v-model="showDialog":对话框显示与隐藏必须靠它,值是布尔值,true显示、false隐藏,不要写成字符串。title="添加品牌":对话框标题是title属性,不写就没有左上角标题。style="margin-top: 100px":top属性在某些浏览器/版本下不生效(豪哥实测过的坑),直接用margin-top顶下来更稳。label-width="100px"写在el-form上会被所有el-form-item继承,两个表单项的左侧文字才能对齐;单独写在一个 item 上就只对它生效。style="width: 80%"写在el-form上,让整个表单别顶到对话框边缘,输入框也跟着变窄。el-upload必须带action(上传请求地址);show-file-list="false"关掉文件名字列表,否则下面会多出一行文件名。- 图片与加号是
v-if / v-else二选一,判断依据是trademarkParams.logoUrl是否为空串。 - 取消/确定按钮必须放在
<template #footer>里,名字必须是footer,写错插槽名按钮不会出现。
示例二:收集新增品牌数据并完成添加(含上传校验、成功/失败处理)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>添加品牌 - 收集数据并完成添加</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue/dist/index.iife.min.js"></script>
<style>
.avatar-uploader .el-upload { border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; }
.avatar-uploader .avatar { width: 100px; height: 100px; display: block; object-fit: cover; }
.avatar-uploader-icon { font-size: 28px; color: #8c939d; width: 100px; height: 100px; text-align: center; line-height: 100px; }
</style>
</head>
<body>
<div id="app">
<el-button type="primary" @click="openAdd">添加品牌</el-button>
<el-table :data="tableData" border style="margin-top: 20px">
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="tmName" label="品牌名称" />
<el-table-column prop="logoUrl" label="品牌LOGO">
<template #default="{ row }">
<img :src="row.logoUrl" style="width: 80px; height: 80px" />
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showDialog" title="添加品牌" width="500px" style="margin-top: 100px">
<el-form :model="trademarkParams" label-width="100px" style="width: 80%">
<el-form-item label="品牌名称">
<el-input v-model="trademarkParams.tmName" placeholder="请你输入品牌名称" />
</el-form-item>
<el-form-item label="品牌LOGO">
<el-upload
class="avatar-uploader"
action="/app-dev/api/product/upload"
:show-file-list="false"
:before-upload="beforeUpload"
:on-success="handleUploadSuccess"
>
<img v-if="trademarkParams.logoUrl" :src="trademarkParams.logoUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</template>
</el-dialog>
</div>
<script>
const { createApp, ref } = Vue;
const { ElMessage } = ElementPlus;
// ===== 对应项目 api/product/trademark 里的封装 =====
// 新增:POST .../save 更新:PUT .../update
// 两个接口参数差不多,唯一区别是有没有 id,所以合成一个函数
async function reqAddOrUpdateTrademark(data) {
const isUpdate = !!data.id; // 有 id 说明是更新已有品牌
const url = isUpdate
? '/app-dev/api/product/trademark/baseTrademark/update'
: '/app-dev/api/product/trademark/baseTrademark/save';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error('请求失败');
return res.json();
}
// 获取已有品牌列表(昨天写好的接口)
async function reqGetTrademarkList(page = 1, limit = 5) {
const res = await fetch(`/app-dev/api/product/trademark/${page}/${limit}`);
if (!res.ok) throw new Error('请求失败');
return res.json();
}
const app = createApp({
setup() {
const showDialog = ref(false);
// 收集新增品牌的数据:tmName + logoUrl,新增时没有 id
const trademarkParams = ref({ tmName: '', logoUrl: '' });
const tableData = ref([]);
const getTrademarkList = async () => {
const result = await reqGetTrademarkList();
tableData.value = result.data.records;
};
// 清空收集的数据:只能逐个属性改,不能整个对象重新赋值
const resetForm = () => {
trademarkParams.value.tmName = '';
trademarkParams.value.logoUrl = '';
};
// 每次打开对话框之前先清空
const openAdd = () => {
resetForm();
showDialog.value = true;
};
// 上传之前的钩子:约束文件类型和大小
const beforeUpload = (file) => {
const isImage = ['image/jpeg', 'image/png', 'image/gif'].includes(file.type);
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage({ type: 'error', message: '上传文件格式不正确' });
return false; // 返回 false,上传请求就不发了
}
if (!isLt2M) {
ElMessage({ type: 'error', message: '上传文件大小不能超过 2M' });
return false;
}
return true;
};
// 上传成功的钩子:把服务器返回的远程图片地址收集起来
const handleUploadSuccess = (response) => {
trademarkParams.value.logoUrl = response.data;
};
// 点击确定:发请求完成添加
const confirm = async () => {
try {
await reqAddOrUpdateTrademark(trademarkParams.value);
showDialog.value = false; // 1. 关闭对话框
ElMessage({ type: 'success', message: '添加品牌成功' }); // 2. 成功提示
await getTrademarkList(); // 3. 重新获取列表
} catch (e) {
ElMessage({ type: 'error', message: '添加品牌失败' }); // 失败也要提示
}
};
getTrademarkList();
return {
showDialog, trademarkParams, tableData,
openAdd, beforeUpload, handleUpload常见错误
- 现象:
el-dialog贴顶/top不生效 → 原因:直接依赖top或浏览器/版本差异导致样式异常 → 修法:用margin-top或后续深度选择器统一调整,并在目标浏览器验证。 - 现象:点“添加品牌/编辑”对话框不出现 → 原因:
v-model绑定的布尔值未置true,或按钮事件未绑定 → 修法:检查v-model与按钮click是否同时生效。 - 现象:新增和修改调用了同一个接口但参数不对 → 原因:未根据数据对象是否含
id区分新增/更新 → 修法:提交前判断id,有id走更新,无id走新增。 - 现象:上传图片请求 404/跨域 → 原因:
action未带/api代理前缀 → 修法:上传地址补全代理前缀,并在 Network 面板确认请求 URL。 - 现象:上传非图片或超大文件仍发请求 → 原因:
before-upload未返回false,或校验后未给提示 → 修法:格式/大小不合法时提示并return false。 - 现象:上传成功但提交时图片地址为空 → 原因:未在
on-success中把response.data写入logoURL→ 修法:成功回调中同步收集地址,提交前打印参数确认。 - 现象:表单校验不触发 → 原因:
el-form缺model/rules,或el-form-item缺prop→ 修法:三件套补齐,且prop与rules键名、数据字段名完全一致。 - 现象:取消/提交后再次打开仍保留上次数据 → 原因:直接给
ref重新赋值或未清空代理对象字段 → 修法:清空ref代理对象内的name、logoURL等字段,并在打开对话框前执行。
练习
- 给“添加品牌”对话框补全:按钮打开、取消关闭、确定提交前打印收集参数。
- 将新增与更新封装为一个提交函数,要求根据
id自动选择请求,并写出成功/失败分支。 - 实现上传前校验:仅允许
png/jpg/jpeg,大小不超过 2MB,不通过时给出消息提示并阻止上传。 - 实现上传成功后回填图片地址,并让上传区域在“加号”和“图片预览”之间切换。
- 为品牌名称和品牌图片配置表单校验,要求名称必填且长度符合规则,图片地址必填;提交前验证不通过则终止请求。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 完成添加品牌与修改已有品牌对话框静态搭建 | 23:00 |
| 2 | 书写添加与更新品牌的API | 11:47 |
| 3 | 收集添加品牌数据-上 | 20:34 |
| 4 | 收集添加品牌的数据-下 | 23:57 |
| 5 | 完成添加品牌的业务 | 16:45 |
| 6 | 表单校验基本使用 | 17:43 |
小结
- 添加与编辑共用对话框时,提交逻辑必须先判断是否存在
id。 - 新增接口通常只需名称和图片地址,更新接口还需携带
id。 - 上传组件的
action必须走代理前缀,否则请求地址错误。 before-upload是上传前拦截格式和大小的关键位置,失败必须返回false。- 上传成功回调要立即收集服务端返回的图片地址,不能等到提交时再猜。
- 表单校验能否生效,取决于
model、prop、rules三者是否对齐。 - 对话框关闭或重新打开前应清空表单数据,避免回显上一次内容。
- 提交成功后至少完成三件事:关闭对话框、提示成功、刷新列表。