Skip to content

品牌管理模块-添加新的品牌与表单校验(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 三步写对,再处理清空和复位。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:新增品牌对话框静态搭建(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

常见错误 ​

  1. 现象:el-dialog 贴顶/top 不生效 → 原因:直接依赖 top 或浏览器/版本差异导致样式异常 → 修法:用 margin-top 或后续深度选择器统一调整,并在目标浏览器验证。
  2. 现象:点“添加品牌/编辑”对话框不出现 → 原因:v-model 绑定的布尔值未置 true,或按钮事件未绑定 → 修法:检查 v-model 与按钮 click 是否同时生效。
  3. 现象:新增和修改调用了同一个接口但参数不对 → 原因:未根据数据对象是否含 id 区分新增/更新 → 修法:提交前判断 id,有 id 走更新,无 id 走新增。
  4. 现象:上传图片请求 404/跨域 → 原因:action 未带 /api 代理前缀 → 修法:上传地址补全代理前缀,并在 Network 面板确认请求 URL。
  5. 现象:上传非图片或超大文件仍发请求 → 原因:before-upload 未返回 false,或校验后未给提示 → 修法:格式/大小不合法时提示并 return false。
  6. 现象:上传成功但提交时图片地址为空 → 原因:未在 on-success 中把 response.data 写入 logoURL → 修法:成功回调中同步收集地址,提交前打印参数确认。
  7. 现象:表单校验不触发 → 原因:el-form 缺 model/rules,或 el-form-item 缺 prop → 修法:三件套补齐,且 prop 与 rules 键名、数据字段名完全一致。
  8. 现象:取消/提交后再次打开仍保留上次数据 → 原因:直接给 ref 重新赋值或未清空代理对象字段 → 修法:清空 ref 代理对象内的 name、logoURL 等字段,并在打开对话框前执行。

练习 ​

  1. 给“添加品牌”对话框补全:按钮打开、取消关闭、确定提交前打印收集参数。
  2. 将新增与更新封装为一个提交函数,要求根据 id 自动选择请求,并写出成功/失败分支。
  3. 实现上传前校验:仅允许 png/jpg/jpeg,大小不超过 2MB,不通过时给出消息提示并阻止上传。
  4. 实现上传成功后回填图片地址,并让上传区域在“加号”和“图片预览”之间切换。
  5. 为品牌名称和品牌图片配置表单校验,要求名称必填且长度符合规则,图片地址必填;提交前验证不通过则终止请求。

原视频对照 ​

#视频时长
1完成添加品牌与修改已有品牌对话框静态搭建23:00
2书写添加与更新品牌的API11:47
3收集添加品牌数据-上20:34
4收集添加品牌的数据-下23:57
5完成添加品牌的业务16:45
6表单校验基本使用17:43

小结 ​

  1. 添加与编辑共用对话框时,提交逻辑必须先判断是否存在 id。
  2. 新增接口通常只需名称和图片地址,更新接口还需携带 id。
  3. 上传组件的 action 必须走代理前缀,否则请求地址错误。
  4. before-upload 是上传前拦截格式和大小的关键位置,失败必须返回 false。
  5. 上传成功回调要立即收集服务端返回的图片地址,不能等到提交时再猜。
  6. 表单校验能否生效,取决于 model、prop、rules 三者是否对齐。
  7. 对话框关闭或重新打开前应清空表单数据,避免回显上一次内容。
  8. 提交成功后至少完成三件事:关闭对话框、提示成功、刷新列表。