Skip to content

编辑月卡与删除(二) ​

本页对应 编辑月卡与删除,共 6 段视频,约 59 分钟。

学习目标 ​

  • 能够使用作用域插槽在表格操作列中获取当前行数据,并通过打印或解构验证 row 数据结构。
  • 能够基于路由中的 id 调用月卡详情接口,完成两个表单的数据回填,并正确处理日期字段的格式转换。
  • 能够根据 id 是否存在区分新增与编辑状态,在提交时分别调用新增接口或更新接口,并补齐更新接口所需的关联 ID 参数。
  • 能够梳理新增与编辑流程的异同,说明 id 在详情获取、接口调用和页面文案判断中的核心作用。
  • 能够实现单个删除功能:弹出确认提示、调用删除接口、刷新列表并给出操作结果提示。
  • 能够实现批量删除功能:添加表格多选列、监听选中项变化、通过 map 提取 ID 数组并拼接后调用批量删除接口,完成后刷新列表。

知识点 ​

本节要解决的问题 ​

这个页面不是只有一个表单,而是有两个 el-form:车辆信息表单和费用信息表单。点击“确定”时,不能只校验其中一个,也不能两个表单各校验各的、最后谁通过谁提交。业务要求是:两个表单都校验通过,才允许继续走提交逻辑。

本节笔记给出的方案是串行校验:先校验第一个表单,第一个通过之后,再校验第二个表单;第二个也通过,才进入“全部校验通过”的 TODO 分支。

为什么需要这样做?因为两个表单共同组成一次完整业务数据。只校验车辆信息,费用信息可能漏填;只校验费用信息,车辆信息可能不完整。串行校验的好处是顺序清楚、控制简单:第一个不通过就直接结束,不会继续校验第二个,也不会误提交。

先想清楚校验顺序 ​

怎么想这个问题?先看页面结构:两个表单分别用 ref 标记:

vue
<el-form ref="carInfoForm"></el-form>
<el-form ref="feeForm"></el-form>

ref 的作用是让我们在方法里通过 this.$refs.xxx 拿到表单组件实例。Element 的表单实例提供 validate 方法,它接收一个回调,回调参数 valid 表示当前表单是否校验通过。

因此确认按钮的方法 confirmAdd 里,不能写成“先调用第一个 validate,再调用第二个 validate,然后直接提交”。因为 validate 是回调式异步逻辑,直接往下写会让提交代码在校验结果回来之前就执行。正确写法是嵌套:第一个 valid 为 true 时,再进入第二个 validate;第二个 valid 也为 true 时,才写提交。

易错点一:ref 名字必须和 this.$refs 里取的名字完全一致,carInfoForm、feeForm 不能拼错。
易错点二:提交逻辑必须放在第二个 valid 的 if 里面,不能放在外层。
易错点三:不要只写一个表单的 validate,否则另一个表单的必填项不会被拦截。

代码落地 ​

本节笔记里的核心代码要保留,结构如下:

vue
<el-form ref="carInfoForm"></el-form>
<el-form ref="feeForm"></el-form>

methods: {
  confirmAdd() {
    this.$refs.carInfoForm.validate(valid => {
      if (valid) {
        this.$refs.feeForm.validate(valid => {
          if (valid) {
            // 全部校验通过
            // TODO 确定
          }
        })
      }
    })
  }
}

这段代码表达的就是串行校验:第一个表单 carInfoForm 校验通过后,才进入 feeForm 的校验;两个 valid 都为真,才走到“全部校验通过”的注释位置。

结合转写稿里编辑月卡的场景,confirmAdd 这个确认逻辑在新增和编辑时都要用,校验逻辑本身是一模一样的。区别不在校验,而在校验通过之后:编辑状态要调用更新接口,新增状态调用新增接口;编辑提交时还要注意接口参数里需要携带详情数据中的“月卡车辆 ID”和“缴费信息 ID”。所以这里要把“校验”和“提交适配”分开看:校验负责拦住不完整数据,提交分支再根据当前是否有 ID 做区分。

调试与联调时看什么 ​

调试时先确认点击“确定”确实进入 confirmAdd,再确认第一个表单不通过时不会进入第二个回调,第二个表单不通过时不会进入 TODO。联调编辑提交时,转写稿里出现过一个典型问题:校验通过后调更新接口,接口报“月卡信息 ID 不能为空”。原因不是两个表单没校验,而是回填详情时只解构了表单字段,没有把详情返回里的“月卡车辆 ID”“缴费信息 ID”保存下来。后来在回填时补存这两个 ID,再提交才带上。

这说明一个易错点:两个表单校验通过,只代表页面表单字段完整,不代表提交参数一定完整。编辑场景下还要检查详情回填时是否保存了接口要求的附加 ID;新增场景不会调详情接口,也就不会多出这两个 ID,不能因为编辑参数影响新增逻辑。

小结检查清单 ​

  • 两个表单分别有正确的 ref:carInfoForm、feeForm。
  • 确认方法 confirmAdd 中先校验 carInfoForm,通过后再校验 feeForm。
  • 提交或 TODO 必须放在第二个 valid 为真的分支里。
  • 新增和编辑可以共用这套校验逻辑,接口区分放在校验通过之后。
  • 编辑提交前检查详情回填是否保存了接口要求的附加 ID。

先理解目标:确认提交不是“只调一个接口” ​

新增月卡接口封装示意图

这一节要完成的是:用户在新增月卡页面填完车辆信息、费用信息后,点击“确定”,先把两个表单都校验通过,再把两份表单数据合并成接口需要的参数,最后调用新增接口并返回上一页。

为什么需要:新增月卡不是单一表单,页面里至少有车辆信息表单和费用信息表单。如果只校验一个表单就直接提交,另一个表单的必填项、格式错误就会被带到后端。
怎么想:确认提交要按“校验 → 合并 → 二次处理 → 调接口 → 后续跳转”的顺序走。转写稿里也反复提到,新增流程通常是“准备表单、表单校验、二次参数处理、提交接口、后续处理”。
怎么做:先封装接口,再在 confirmAdd 中嵌套校验两个表单。
易错点:不要先合并数据再校验;也不要只校验当前可见的表单。

第一步:封装新增月卡接口 ​

javascript
/**
 * 新增月卡
 * @data
 * @returns
 */
export function createCardAPI(data) {
  return request({
    url: '/parking/card',
    method: 'POST',
    data
  })
}

为什么需要:接口地址、请求方法、参数位置统一放在 API 文件里,组件只负责收集数据和调用方法,后续维护时不用在页面里到处找 URL。
怎么想:新增月卡是提交一条新数据,所以用 POST,参数放在 data 里。转写稿里说接口要“调起来”,不是“吊起来”,就是要在组件中导入并调用。
怎么做:在 API 文件中导出 createCardAPI,组件里导入后传入处理好的 payload。
易错点:url、method 不要写错;data 是请求体参数,不要和 params 混用。

第二步:两个表单都要校验通过 ​

javascript
this.$refs.carInfoForm.validate(valid => {
  if (valid) {
    this.$refs.feeForm.validate(valid => {
      if (valid) {
        // 全部校验通过
      }
    })
  }
})

为什么需要:两个表单分别维护自己的校验规则,必须都通过才能提交。
怎么想:外层先校验车辆信息表单,内层再校验费用表单;只有内层 valid 也为真,才进入提交逻辑。
怎么做:通过 $refs 拿到两个表单组件实例,调用它们的 validate 方法。
易错点:两个回调里的 valid 同名,注意不要在外层判断后直接提交;也不要漏掉内层校验。转写稿里说编辑和新增在“校验逻辑”上是一样的,说明校验部分可以复用同一套思路。

第三步:合并表单数据,并做二次参数处理 ​

javascript
// 参数处理
const payload = {
  ...this.feeForm,
  ...this.carInfoForm,
  // 单独处理时间
  cardStartDate: this.feeForm.payTime[0],
  cardEndDate: this.feeForm.payTime[1]
}
// 删掉多余字段
delete payload.payTime

为什么需要:接口通常不接收两个“表单对象”,而是接收一个扁平参数对象;而且表单里的 payTime 是数组,接口要的是 cardStartDate 和 cardEndDate 两个字段。
怎么想:转写稿里说的“二次数据处理”,落到这里就是“合并 + 拆时间 + 删多余字段”。先合并两个表单,再单独把 payTime 拆成开始、结束日期,最后把原 payTime 删掉,避免多传无用字段。
怎么做:用扩展运算符合并 this.feeForm 和 this.carInfoForm;用 payTime[0]、payTime[1] 赋给两个日期字段;用 delete payload.payTime 删除旧字段。
易错点:合并顺序要注意,后展开的对象会覆盖前面的同名字段;拆完时间后一定记得删除 payTime;如果 payTime 为空,直接取下标可能报错,提交前应确保校验已拦截。

第四步:提交接口并返回 ​

javascript
await createCardAPI(payload)
this.$router.back()

为什么需要:参数处理完后才能真正提交;提交成功后返回上一页,符合新增页面的常见交互。
怎么想:先调新增接口,成功后再做跳转。转写稿里说“调完之后要做后续处理,可能有提示、可能有回调”,本节笔记只写了返回上一页。
怎么做:把 payload 传给 createCardAPI,然后 this.$router.back()。
易错点:如果使用 await,confirmAdd 或所在回调需要是 async 函数,否则会有语法问题;接口失败时不应直接返回,实际项目里通常还要加提示或 try/catch。

易错点清单 ​

  1. 校验顺序错:两个表单必须都通过再提交。
  2. 参数没处理:不能把 payTime 原样传给接口,要拆成 cardStartDate、cardEndDate。
  3. 忘记删除多余字段:delete payload.payTime 不能漏。
  4. 合并覆盖:...this.feeForm 和 ...this.carInfoForm 的顺序要按字段来源确认。
  5. await 使用位置:笔记代码里在回调中使用 await,实际落地时要保证函数是 async,或改用 Promise 写法。

先明确:重置表单到底重置什么 ​

重置表单示意图

本节笔记只有一句核心:重置表单主要做两件事——清空输入数据、清空校验错误。
为什么需要?因为新增和编辑往往共用同一个表单页面。转写稿里反复强调:新增和编辑的大部分流程是一样的,差异主要在“是否携带 ID、是否回填、提交哪个接口、参数是否多出 ID”。如果从编辑页返回新增页,表单里还残留上一次的数据和红色校验提示,用户就会看到脏数据;如果提交前校验错误没清掉,也会影响下一次操作。

怎么想?不要把“重置”理解成只把某个字段赋空字符串。表单组件通常把“字段值”和“校验状态”放在一起管理,所以要调用表单实例提供的方法,而不是逐个字段手写清空。
怎么做?本节给出的代码就是:

javascript
// 重置表单
resetForm() {
  this.$refs.feeForm.resetFields()
  this.$refs.carInfoForm.resetFields()
}

易错点:$refs 名字必须和模板里的 ref 完全一致;两个表单要分别重置,不能只重置一个;resetFields() 依赖表单项的 prop 和字段绑定,字段没写 prop 时往往达不到预期。

从新增/编辑流程理解重置的时机 ​

转写稿里梳理过:新增流程是点击新增 → 跳转路由 → 准备表单 → 表单校验 → 二次处理参数 → 提交新增接口 → 后续提示;编辑流程是点击编辑 → 携带 ID 跳转 → 准备表单并回填 → 表单校验 → 二次处理 → 提交更新接口 → 后续提示。两者大部分节点一样,重点差异是“携带 ID、回填、接口和参数”。

为什么需要重置?因为编辑页会做数据回填。转写稿说“准备表单的位置,我们需要给他回填”,并且“有 ID 才获取详情”。如果先回填、再重置,就可能把回填值当成表单初始值,或者把刚填的数据清掉;如果新增前不重置,又可能残留上一次编辑的数据。
怎么想?重置应发生在“准备表单”的起点:打开新增前先清干净;打开编辑时先重置,再拿 ID 请求详情并回填。
怎么做?在打开表单/切换新增编辑状态时调用本节 resetForm(),再根据是否存在 ID 决定是否请求详情、是否回填。
易错点:不要在异步回填完成后再调用 resetFields(),否则可能把刚回填的内容重置掉;也不要把“重置”和“回填”顺序写反。

重置与 ID 判断、提交逻辑的配合 ​

转写稿里有一个关键判断:有 ID 代表编辑状态,没有 ID 代表新增状态。这个 ID 会用在三个地方:有 ID 才获取详情;提交时区分更新接口和新增接口;页面文案区分“编辑/新增”。重置表单本身不负责判断新增编辑,但它服务于同一套流程。

为什么需要?如果新增时误调详情接口,转写稿里调试时已经发现“倘若现在是添加,这地方没有参数,走是不是报错了”,所以要用 ID 做条件。重置表单也是同理:新增前要清空旧数据,编辑时要先清空再回填,避免旧状态干扰。
怎么想?把“重置—判断 ID—请求详情—回填—校验—提交”看成一条链。重置是链条起点,不是孤立方法。
怎么做?调用 resetForm() 后,再执行 if (id) { 获取详情并回填 } 这类分支;提交时再根据 ID 走新增或更新。
易错点:不要只清输入值不管校验错误;不要只重置一个表单;不要在没有 ID 的新增场景强行请求详情;回填字段格式要按组件要求处理,转写稿也提醒“后端反过来的数据不能直接给组件用”,日期等字段要转换。

调试时怎么确认重置生效 ​

为什么需要调试?因为重置失败常见表现不是报错,而是“看起来没清干净”。
怎么想?先确认方法有没有被调用,再确认 $refs 是否拿到表单实例,最后确认字段是否绑定了 prop。
怎么做?可以在 resetForm() 里临时打印 this.$refs.feeForm、this.$refs.carInfoForm,确认不是 undefined;再检查模板中两个表单的 ref 名称是否与代码一致。转写稿里排查接口时也用了同样思路:先打印、看请求、对照接口要求,而不是凭感觉改。
易错点:$refs 在组件未挂载时拿不到;拼写错误不会自动提示;如果某个字段初始值不是空,resetFields() 会回到初始值,不一定是空字符串。此时要检查表单初始化数据,而不是误以为方法失效。

小结 ​

本节的重置表单代码很短,但背后要放在新增/编辑共用表单的流程里理解:先重置,清掉旧数据和旧校验;再根据 ID 判断新增还是编辑;编辑再请求详情并回填;最后校验、处理参数、提交对应接口。 记住两个表单分别调用 resetFields(),并重点检查 ref、prop、调用顺序和回填时机。

先看整体流程:编辑与新增大部分相同 ​

编辑功能通用实现流程

编辑月卡和新增月卡在页面上看起来相似,核心流程也高度一致:进入页面、准备表单、表单校验、提交前处理参数、调用接口、成功提示与刷新列表。真正不同的地方主要有三处:编辑需要携带 ID 跳转;进入页面后要用 ID 获取详情并回填表单;提交时要根据 ID 判断调用新增接口还是更新接口,并补齐更新接口要求的额外 ID 参数。

所以学习这一节,不要把所有代码重新背一遍,而要抓住“ID 是区分新增/编辑的状态开关”这条主线。

表格操作栏:用作用域插槽拿到当前行数据 ​

为什么需要:表格右侧有“编辑”“删除”按钮时,点击按钮必须知道操作的是哪一行。仅写一个按钮不够,还要拿到当前行的数据,尤其是行 ID。

怎么想:Table 组件通常提供作用域插槽,把当前行数据暴露给使用方。文档示例里常见 scope.row,其中 scope 是插槽回调的形参,row 是组件内部传过来的对象字段。可以先打印确认,不要死记。

怎么做:

vue
<el-table-column label="操作">
  <template #default="scope">
    <el-button @click="handleEdit(scope.row)">编辑</el-button>
  </template>
</el-table-column>

调试时可以临时写:

js
console.log(scope)

展开后能看到 scope.row 就是当前行对象,里面有 ID、名称等字段。由于组件内部传下来的对象结构类似 { row: 当前行数据 },也可以解构:

vue
<template #default="{ row }">
  <el-button @click="handleEdit(row)">编辑</el-button>
</template>

易错点:scope 只是形参名,可以改;但 row 是组件内部约定的对象键名,不能随意改成别的。若只渲染插槽、不需要行数据,可以不写等号后的参数;一旦要取当前行数据,就要正确接收并解构。

编辑回显:用路由 ID 调详情接口 ​

为什么需要:编辑页不能只显示空表单,必须把原来那条数据填回去,用户才能修改。列表页跳转编辑页时携带 ID,编辑页再拿 ID 请求详情。

怎么想:先确认路由参数来源,例如 route.query.id。进入页面后,只有 ID 存在才说明当前是编辑状态,才有资格请求详情;新增状态没有 ID,不能无条件调用详情接口。

怎么做:

js
const route = useRoute()
const id = computed(() => route.query.id)

const getDetail = async () => {
  const res = await getDetailApi(id.value)
  // 按接口字段回填两个表单对象
}

if (id.value) {
  getDetail()
}

详情返回后,把字段分别赋给表单对象。转写中提到,有些字段在提交和回显时格式相反,例如日期提交时可能需要拼接/格式化,回显时接口给的是起止时间,需要按组件要求转换后再赋值。

易错点:一是在 mounted 里无条件调详情,新增时没有 ID 会报错;二是重复写 route.query.id,可用计算属性缓存;三是详情里除了表单字段,还可能藏着更新接口需要的额外 ID,回填时要一并保存,否则提交更新会提示“ID不能为空”。

提交更新:用 ID 区分接口与参数 ​

为什么需要:同一个确认按钮,在新增和编辑时要做不同的事。新增调新增接口,编辑调更新接口;更新接口还要告诉后端“更新谁”。

怎么想:判断条件仍然是 ID。有 ID 走更新分支,没有 ID 走新增分支。更新接口通常比新增接口多两个参数,例如缴费信息 ID、月卡车辆 ID,这些值应来自详情接口回填时保存的数据。

怎么做:

js
if (id.value) {
  await updateApi(params)
  ElMessage.success('更新成功')
} else {
  await addApi(params)
  ElMessage.success('新增成功')
}

页面标题也可用三元判断:

js
id.value ? '编辑月卡' : '新增月卡'

易错点:不要以为参数对象长得一样就能直接复用;更新接口缺少额外 ID 会失败。也不要影响新增逻辑:新增没有调详情,自然不会带那两个 ID。文案提示同样要区分,否则编辑成功却提示“新增成功”。

删除与批量删除:先确认,再取 ID,再刷新 ​

为什么需要:删除是危险操作,应先弹确认框。批量删除还要让用户多选,并把多个 ID 按接口要求拼接。

怎么想:单个删除拿当前行 ID,确认后调删除接口,再刷新列表。批量删除需要给表格加 type="selection" 列,监听 selection-change 拿到选中行对象数组,暂存到 selectedList;点击“批量删除”时再处理,而不是在选中回调里直接删除。

怎么做:

js
const handleSelectionChange = (val) => {
  selectedList.value = val
}

const handleBatchDelete = async () => {
  const ids = selectedList.value.map(item => item.id).join(',')
  await batchDeleteApi(ids)
  getList()
}

易错点:转写中特别纠正了一个问题:不要一选中就调删除接口,逻辑应放到“批量删除”按钮回调里。批量接口参数常用逗号拼接 ID;若团队已有单个删除接口,不要硬改兼容,分开封装更清晰。删除成功后要重新拉列表,否则页面数据不更新。

小结 ​

编辑功能的通用实现可以概括为:表格操作栏用作用域插槽取 row;编辑页用路由 ID 判断状态并请求详情回填;提交时用 ID 区分新增/更新接口,并补齐更新参数;删除前确认,批量删除先收集选中项再映射出 ID。把 ID 这条主线抓住,交互形式换成弹框或新页面,基本逻辑仍然不变。

从“编辑”按钮说起:为什么需要携带 id ​

列表页每一行都有自己的“编辑”按钮。点击某一行的编辑,不能跳到同一个写死的编辑页,而要让编辑页知道“我到底编辑的是哪一条月卡”。因此,按钮点击时要把当前行的 id 取出来,再随路由跳转带过去。

怎么想:表格的操作列通常写在作用域插槽里,文档示例会在按钮上绑定点击事件,并从 scope.row 里取当前行数据。我们可以先照着文档写,再打印 scope.row 验证它是什么。

怎么做:模板中把 scope.row.id 传给 editCard:

vue
<el-button size="mini" type="text" @click="editCard(scope.row.id)">编辑</el-button>
  
editCard(id) {
  this.$router.push({
    path: '/cardAdd',
    query: {
      id
    }
  })
}

易错点:不要写死 id;不要以为 scope.row 是固定写法,scope 只是形参名,可以改,但 row 是内部传下来的对象属性名,不能随便改。老语法 slot-scope="scope" 和新语法 #default="{ row }" 本质一样,都是接收插槽内部传出的数据。

作用域插槽的推导:scope.row 到底是什么 ​

为什么需要理解它?因为后面所有“编辑、删除”都要拿到当前行数据。只会复制文档不够,遇到字段变化就不知道去哪取。

怎么想:转写稿里采用的办法很直接——在操作列里先打印 scope.row。点击第一行后,控制台得到一个对象,展开能看到 id、卡号、金额等字段,正好和当前行对应。由此可知:scope.row 就是当前行的数据对象。

怎么做:文档内部传下来的大致是一个对象,里面有 row 属性,row 的值是当前行数据。所以 scope.row.id 能取到当前行 id;也可以解构写成 { row },之后直接用 row.id。

易错点:scope 是形参,名字可改;row 是对象 key,不能随意改名。解构后不要又写 scope.row,否则会找不到变量。

携带 id 跳转:query 参数怎么写 ​

为什么需要:编辑页要拿 id 去请求详情、做数据回填,并在提交时判断是新增还是编辑。

怎么想:用 this.$router.push 跳转到 /cardAdd,把 id 放在 query 里。转写稿中调试时可以看到地址后面出现类似 ?id=273,编辑页再通过 this.$route.query.id 取到 273。

怎么做:就是笔记中的写法,query: { id } 是 query: { id: id } 的简写。跳转后编辑页用 this.$route.query.id 读取。

易错点:$router 是跳转,$route 是当前路由信息;path 和 query 要分清。不要把 id 只放在页面 data 里,刷新后会丢;路由参数才是稳定来源。

缓存 id:为什么用 computed ​

为什么需要:转写稿里编辑页不止一处用 id:获取详情要传 id,提交时要判断调新增还是更新接口,页面文案也要根据 id 显示“新增”还是“编辑”。如果每次都写 this.$route.query.id,重复且容易写错。

怎么想:id 始终以当前路由参数为准,所以可以做一个计算属性缓存它:

js
computed: {
  id() {
    return this.$route.query.id
  }
}

之后方法里统一用 this.id。

怎么做:在 mounted 或初始化方法里,先判断 if (this.id) 再调详情接口。转写稿调试时发现:如果在 mounted 里无条件调详情,新增状态没有 id 就会报错;加上判断后,只有编辑状态才请求详情。

易错点:计算属性不要和 data 同名;不要用普通 data 手动赋一次 id 就以为永远同步,路由参数变化时 computed 更稳。只有 id 存在才获取详情,这是编辑和新增分流的第一道判断。

检查清单 ​

  1. 编辑按钮是否传了 scope.row.id?
  2. 跳转是否用 query 携带 id?
  3. 编辑页是否从 $route.query.id 取 id?
  4. 是否用 computed 缓存 id,避免多处重复写?
  5. 是否先判断 id 存在,再请求详情?

先明确“回填数据”要解决什么问题 ​

编辑月卡时,页面不是从空表单开始,而是要把这条月卡已有的车辆信息、缴费信息展示出来,让用户在此基础上修改。所以编辑页跳转时会带一个 id,组件拿到 id 后请求详情接口,再把返回数据拆到两个表单对象里。

为什么需要:没有回填,编辑页就是空表单,用户无法知道原来填了什么,也无法只改其中一项。
怎么想:先确认当前是不是编辑状态——有 id 才请求详情;详情返回后,不要整块塞给表单,而要按页面结构拆成“车辆信息表单”和“缴费信息表单”。
怎么做:先封装详情接口,再在组件中根据 id 调接口并赋值。
易错点:不要在 mounted 里无条件请求详情,否则新增页没有 id 也会发请求,接口报错。

封装详情接口:先确认请求方式和路径参数 ​

本节笔记里的接口封装如下,保留原代码:

javascript
/**
 * 获取月卡详情
 * @data
 * @returns
 */
export function getCardDetailAPI(id) {
  return request({
    url: `/parking/card/detail/${id}`
  })
}

为什么需要:详情接口是编辑页回填的数据来源,单独封装后组件只关心“传 id、拿数据”,不重复写请求路径。
怎么想:转写稿里强调先看接口文档,确认它是 GET 请求,id 直接拼在路径后面,不需要额外参数。
怎么做:把 id 作为函数参数,拼到 url 的路径参数位置。
易错点:不要拿错接口,编辑页要的是“月卡详情”,不是列表接口;路径参数不要漏掉 ${id}。

根据 id 获取详情并触发请求 ​

组件里先写 getDetail,笔记代码如下:

javascript
// 获取详情
async getDetail() {
  const res = await getCardDetailAPI(this.id)
  // 回填车辆信息表单
  const { carInfoId, personName, phoneNumber, carNumber, carBrand } = res.data
  this.carInfoForm = {
    personName, phoneNumber, carNumber, carBrand, carInfoId
  }

  // 回填缴费信息表单
  const { rechargeId, cardStartDate, cardEndDate, paymentAmount, paymentMethod } = res.data
  this.feeForm = {
    rechargeId,
    paymentAmount,
    paymentMethod,
    payTime: [cardStartDate, cardEndDate]
  }
}

mounted() {
  if (this.id) {
    this.getDetail()
  }
}

为什么需要:详情数据不能直接赋给一个表单,因为页面有两个表单对象,字段结构也不同。
怎么想:转写稿里的调试过程是:先把 id 传到接口,打开 Network 看请求是否变成 /detail/273 这类地址,再确认返回数据里有哪些字段;然后按表单逐项解构。
怎么做:用 async/await 拿到 res.data,分别解构车辆信息和缴费信息。车辆信息直接回填 carInfoForm;缴费信息里的日期要组装成 payTime: [cardStartDate, cardEndDate],因为日期组件需要数组。
易错点:mounted 里必须加 if (this.id),只有编辑状态才请求详情;新增页没有 id,不请求详情。转写稿里特别提到,如果无条件调用,新增时会报错。

回填时最容易漏掉的两个 ID ​

转写稿在调试更新接口时发现:提交更新时报“月卡信息ID不能为空”。回头对照详情返回和更新参数,发现少了两个 ID:carInfoId 和 rechargeId。笔记代码里已经把 carInfoId 放进 carInfoForm,把 rechargeId 放进 feeForm。

为什么需要:编辑提交时,后端要知道“更新哪条月卡车辆信息、哪条缴费信息”,所以这两个 ID 必须从详情里存下来。
怎么想:详情接口返回里有这两个字段,回填时不能只解构页面上看得见的字段,还要把隐藏 ID 一起存进表单对象。
怎么做:在解构时加上 carInfoId、rechargeId,并放进对应表单。
易错点:不要以为页面上没显示就不用存;更新接口参数和新增接口参数不完全一样,编辑时往往要多带 ID。

回填与提交的格式差异要反过来处理 ​

转写稿反复提醒:回填日期时,后端返回的是两个独立字段 cardStartDate、cardEndDate,但日期组件要的是数组,所以写成 payTime: [cardStartDate, cardEndDate]。这和提交时“把数组拆成两个字段”正好相反。

为什么需要:后端数据格式和组件数据格式不一定一致,不能直接把后端对象原样丢给组件。
怎么想:先看组件需要什么格式,再看接口返回什么格式,中间做一次转换。
怎么做:回填时组装成组件需要的数组;提交时再按接口要求拆开或格式化。
易错点:不要漏掉格式转换;字段名要和表单对象一致,解构后赋值时不要写错 carInfoForm、feeForm。调试时先看 Network 返回,再检查表单对象,能快速定位是“没请求到”还是“字段没对上”。

代码示例 ​

示例一:新增月卡 —— 双表单串行校验 + 参数处理 + 提交 + 重置 ​

vue
<!-- views/cardAdd/index.vue -->
<template>
  <div class="card-add">
    <el-card shadow="never">
      <div slot="header">{{ id ? '编辑月卡' : '新增月卡' }}</div>

      <!-- ① 车辆信息表单 -->
      <el-form ref="carInfoForm" :model="carInfoForm" label-width="100px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item
              label="车主姓名"
              prop="personName"
              :rules="[{ required: true, message: '请输入车主姓名', trigger: 'blur' }]"
            >
              <el-input v-model="carInfoForm.personName" placeholder="请输入车主姓名" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item
              label="手机号"
              prop="phoneNumber"
              :rules="[{ required: true, message: '请输入手机号', trigger: 'blur' }]"
            >
              <el-input v-model="carInfoForm.phoneNumber" placeholder="请输入手机号" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item
              label="车牌号"
              prop="carNumber"
              :rules="[{ required: true, message: '请输入车牌号', trigger: 'blur' }]"
            >
              <el-input v-model="carInfoForm.carNumber" placeholder="请输入车牌号" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="车辆品牌" prop="carBrand">
              <el-input v-model="carInfoForm.carBrand" placeholder="请输入车辆品牌" />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>

      <!-- ② 缴费信息表单 -->
      <el-form ref="feeForm" :model="feeForm" label-width="100px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item
              label="支付金额"
              prop="paymentAmount"
              :rules="[{ required: true, message: '请输入支付金额', trigger: 'blur' }]"
            >
              <el-input v-model="feeForm.paymentAmount" placeholder="请输入支付金额" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item
              label="支付方式"
              prop="paymentMethod"
              :rules="[{ required: true, message: '请选择支付方式', trigger: 'change' }]"
            >
              <el-select v-model="feeForm.paymentMethod" placeholder="请选择支付方式">
                <el-option label="微信" value="1" />
                <el-option label="支付宝" value="2" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="24">
            <el-form-item
              label="缴费时间"
              prop="payTime"
              :rules="[{ required: true, message: '请选择缴费时间', trigger: 'change' }]"
            >
              <el-date-picker
                v-model="feeForm.payTime"
                type="daterange"
                value-format="timestamp"
                range-separator="至"
                start-placeholder="开始日期"
                end-placeholder="结束日期"
              />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>

      <div class="footer">
        <el-button @click="resetForm">重置</el-button>
        <el-button type="primary" @click="confirmAdd">确定</el-button>
      </div>
    </el-card>
  </div>
</template>

<script>
import { createCardAPI } from '@/api/card'

export default {
  name: 'CardAdd',
  data() {
    return {
      carInfoForm: { personName: '', phoneNumber: '', carNumber: '', carBrand: '' },
      feeForm: { paymentAmount: '', paymentMethod: '', payTime: [] }
    }
  },
  methods: {
    // 串行校验:第一个表单通过后再校验第二个
    confirmAdd() {
      this.$refs.carInfoForm.validate(valid => {
        if (valid) {
          this.$refs.feeForm.validate(async valid => {
            if (valid) {
              // 全部校验通过
              // 参数处理:两个表单合并
              const payload = {
                ...this.feeForm,
                ...this.carInfoForm,
                // 单独处理时间:日期区间数组拆成两个字段
                cardStartDate: this.feeForm.payTime[0],
                cardEndDate: this.feeForm.payTime[1]
              }
              // 删掉多余字段
              delete payload.payTime
              await createCardAPI(payload)
              this.$message.success('新增成功')
              this.$router.back()
            }
          })
        }
      })
    },
    // 重置表单:清空输入数据 + 清空校验错误
    resetForm() {
      this.$refs.feeForm.resetFields()
      this.$refs.carInfoForm.resetFields()
    }
  }
}
</script>

<style scoped>
.footer {
  margin-top: 10px;
  text-align: right;
}
</style>

要点

  • this.$refs.carInfoForm.validate(valid => {...}) 是回调式校验:valid 为 true 才进内层,这就是“串行校验”。两个表单是两条独立数据(carInfoForm / feeForm),所以必须各自 validate 一次,不能合并。
  • 提交前要做二次参数处理:...this.feeForm, ...this.carInfoForm 合并成一个 payload,payTime 是 [开始, 结束] 数组,后端要两个字段,所以拆成 cardStartDate / cardEndDate 后必须 delete payload.payTime,否则会多传一个数组字段。
  • 易错点:笔记里 await createCardAPI(payload) 写在 validate 回调里,但回调没标 async,直接 await 会报语法错误 —— 内层回调必须写成 async valid => {}。
  • 易错点:resetFields() 只对写了 prop 的 el-form-item 生效;漏写 prop 的项既不会清空、也不会清校验红字。
  • 日期选择器要加 value-format="timestamp",保证 payTime 拿到的是时间戳(和接口要求的数字格式一致),否则拿到的是 Date 对象。
  • 配套接口(@/api/card.js):
js
export function createCardAPI(data) {
  return request({ url: '/parking/card', method: 'POST', data })
}

示例二:编辑月卡 —— 携带 id 跳转、详情回填、按 id 区分接口与文案 ​

vue
<!-- views/cardAdd/index.vue(编辑场景复用同一页面) -->
<template>
  <div class="card-add">
    <el-card shadow="never">
      <!-- 文案随 id 变化:有 id 是编辑,没有是新增 -->
      <div slot="header">{{ id ? '编辑月卡' : '新增月卡' }}</div>

      <el-form ref="carInfoForm" :model="carInfoForm" label-width="100px">
        <el-form-item label="车主姓名" prop="personName">
          <el-input v-model="carInfoForm.personName" />
        </el-form-item>
        <el-form-item label="手机号" prop="phoneNumber">
          <el-input v-model="carInfoForm.phoneNumber" />
        </el-form-item>
        <el-form-item label="车牌号" prop="carNumber">
          <el-input v-model="carInfoForm.carNumber" />
        </el-form-item>
        <el-form-item label="车辆品牌" prop="carBrand">
          <el-input v-model="carInfoForm.carBrand" />
        </el-form-item>
      </el-form>

      <el-form ref="feeForm" :model="feeForm" label-width="100px">
        <el-form-item label="支付金额" prop="paymentAmount">
          <el-input v-model="feeForm.paymentAmount" />
        </el-form-item>
        <el-form-item label="支付方式" prop="paymentMethod">
          <el-select v-model="feeForm.paymentMethod">
            <el-option label="微信" value="1" />
            <el-option label="支付宝" value="2" />
          </el-select>
        </el-form-item>
        <el-form-item label="缴费时间" prop="payTime">
          <el-date-picker
            v-model="feeForm.payTime"
            type="daterange"
            value-format="timestamp"
            range-separator="至"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
          />
        </el-form-item>
      </el-form>

      <div class="footer">
        <el-button @click="resetForm">重置</el-button>
        <el-button type="primary" @click="confirmAdd">确定</el-button>
      </div>
    </el-card>
  </div>
</template>

<script>
import { createCardAPI, updateCardAPI, getCardDetailAPI } from '@/api/card'

export default {
  name: 'CardAdd',
  data() {
    return {
      carInfoForm: { personName: '', phoneNumber: '', carNumber: '', carBrand: '', carInfoId: '' },
      feeForm: { paymentAmount: '', paymentMethod: '', payTime: [], rechargeId: '' }
    }
  },
  computed: {
    // 缓存路由参数,避免到处 this.$route.query.id
    id() {
      return this.$route.query.id
    }
  },
  mounted() {
    // 只有编辑状态(有 id)才去请求详情
    if (this.id) {
      this.getDetail()
    }
  },
  methods: {
    // 根据 id 获取详情并回填
    async getDetail() {
      const res = await getCardDetailAPI(this.id)

      // 回填车辆信息表单(注意把 carInfoId 也存下来,更新接口要传)
      const { carInfoId, personName, phoneNumber, carNumber, carBrand } = res.data
      this.carInfoForm = {
        personName, phoneNumber, carNumber, carBrand, carInfoId
      }

      // 回填缴费信息表单(注意把 rechargeId 也存下来)
      const { rechargeId, cardStartDate, cardEndDate, paymentAmount, paymentMethod } = res.data
      this.feeForm = {
        rechargeId,
        paymentAmount,
        paymentMethod,
        // 后端给的是两个独立字段,组件要的是数组 —— 与提交时的处理正好相反
        payTime: [cardStartDate, cardEndDate]
      }
    },
    confirmAdd() {
      this.$refs.carInfoForm.validate(valid => {
        if (valid) {
          this.$refs.feeForm.validate(async valid => {
            if (valid) {
              const payload = {
                ...this.feeForm,
                ...this.carInfoForm,
                cardStartDate: this.feeForm.payTime[0],
                cardEndDate: this.feeForm.payTime[1]
              }
              delete payload.payTime

              // 用 id 区分:有 id 调更新接口,没有调新增接口
              if (this.id) {
                await updateCardAPI(payload)
                this.$message.success('更新成功')
              } else {
                await createCardAPI(payload)
                this.$message.success('新增成功')
              }
              this.$router.back()
            }
          })
        }
      })
    },
    resetForm() {
      this.$refs.feeForm.resetFields()
      this.$refs.carInfoForm.resetFields()
    }
  }
}
</script>

<style scoped>
.footer {
  margin-top: 10px;
  text-align: right;
}
</style>

要点

  • computed: { id() { return this.$route.query.id } } 把路由参数缓存成计算属性,页面里三处(请求详情、判断接口、判断文案)都用同一个 this.id,不用反复穿透原型链访问 $route.query。
  • mounted 里必须 if (this.id) this.getDetail()。若直接无条件调用,新增场景下 id 是 undefined,请求会拼成 /parking/card/detail/undefined 直接报错。
  • 回填时必须多解构两个 ID:carInfoId、rechargeId。更新接口要求带上这两个 ID 告诉后端“更新谁”,漏了就报“月卡信息 ID 不能为空”。它们是在详情回填时顺手存进表单对象的,新增流程不会调详情接口,所以自然不会多传。
  • 回填的日期是反向格式化:接口给的是 cardStartDate / cardEndDate 两个独立字段,组件要的是数组,所以 payTime: [cardStartDate, cardEndDate] —— 跟提交时的拆分逻辑正好相反。
  • 配套接口(@/api/card.js):
js
export function getCardDetailAPI(id) {
  return request({ url: `/parking/card/detail/${id}` })
}

export function updateCardAPI(data) {
  return request({ url: '/parking/card', method: 'PUT', data })
}
  • 列表页跳转时携带 id:
js
editCard(id) {
  this.$router.push({ path: '/cardAdd', query: { id } })
}

示例三:列表页 —— 单个删除 + 批量删除(selection 列) ​

vue
<!-- views/cardList/index.vue -->
<template>
  <div class="card-list">
    <div class="toolbar">
      <el-button type="danger" size="small" @click="batchDel">批量删除</el-button>
    </div>

    <!-- 注意:selection-change 绑在 el-table 上,不是绑在列上 -->
    <el-table :data="list" border @selection-change="handleSelectionChange">
      <!-- 复选框列 -->
      <el-table-column type="selection" width="55" />
      <el-table-column prop="carNumber" label="车牌号" />
      <el-table-column prop="personName" label="车主姓名" />
      <el-table-column prop="phoneNumber" label="手机号" />
      <el-table-column label="操作" width="180">
        <!-- #default="{ row }" 等价于老写法 slot-scope="scope" 里的 scope.row -->
        <template #default="{ row }">
          <el-button size="mini" type="text" @click="editCard(row.id)">编辑</el-button>
          <el-button size="mini" type="text" @click="delCard(row.id)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
import { getCardListAPI, delCardAPI, batchDelCardAPI } from '@/api/card'

export default {
  name: 'CardList',
  data() {
    return {
      list: [],
      selectList: [] // 暂存每次选中的完整行对象
    }
  },
  mounted() {
    this.getList()
  },
  methods: {
    async getList() {
      const res = await getCardListAPI()
      this.list = res.data.rows
    },
    editCard(id) {
      this.$router.push({ path: '/cardAdd', query: { id } })
    },

    // 选择项变化:value 是选中行对象组成的数组
    handleSelectionChange(value) {
      this.selectList = value
    },

    // 单个删除
    delCard(id) {
      this.$confirm('确认删除该月卡吗?', '提示', { type: 'warning' })
        .then(async () => {
          await delCardAPI(id)
          this.$message.success('删除成功')
          this.getList()
        })
        .catch(() => {})
    },

    // 批量删除:把对象数组映射成 id 数组,再用逗号拼接
    batchDel() {
      if (!this.selectList.length) {
        return this.$message.warning('请先选择要删除的月卡')
      }
      this.$confirm('确认删除选中的月卡吗?', '提示', { type: 'warning' })
        .then(async () => {
          const ids = this.selectList.map(item => item.id).join(',')
          await batchDelCardAPI(ids)
          this.$message.success('批量删除成功')
          this.getList()
        })
        .catch(() => {})
    }
  }
}
</script>

<style scoped>
.toolbar {
  margin-bottom: 10px;
}
</style>

要点

  • 复选框只要加一列 <el-table-column type="selection" width="55" /> 即可,不用手写 el-checkbox。
  • @selection-change 必须绑在 el-table 上,绑到列上无效。回调参数 value 是选中行的完整对象数组,不是 id 数组 —— 所以要在 handleSelectionChange 里先 this.selectList = value 暂存,因为点击“批量删除”时已经拿不到那个回调参数了。
  • 易错点:删除接口的调用逻辑必须写在“批量删除按钮”的点击回调里,不能写在 selection-change 回调里 —— 否则一勾选就直接删了。
  • 数据转换用 map:个数不变、每项做一个固定映射,selectList.map(item => item.id) 得到 id 数组,再 .join(',') 拼成 1001,1002,1003 的字符串交给接口。
  • 批量删除后要重新拉列表 this.getList(),否则页面上还是旧数据。
  • 配套接口:单个删除和批量删除建议写成两个函数,不要硬塞进一个:
js
export function delCardAPI(id) {
  return request({ url: `/parking/card/${id}`, method: 'DELETE' })
}

export function batchDelCardAPI(ids) {
  return request({ url: `/parking/card/${ids}`, method: 'DELETE' })
}
  • 作用域插槽补充:<template #default="{ row }"> 中的 row 是形参,名字可以随便改;但后端传下来的对象里字段名就叫 row,所以解构时 { row } 这个 key 不能改名。等价的老写法是 slot-scope="scope" + scope.row。

常见错误 ​

  1. 串行校验写成"同步判断" 现象:只填了第一个表单就能提交,第二个表单的红字压根没出现。 原因:this.$refs.feeForm.validate() 是回调/Promise 式 API,返回值不是布尔值,if (this.$refs.feeForm.validate()) 永远为真。 修法:要么两层回调嵌套,要么统一 async + await this.$refs.a.validate()(await 一个 reject 的 Promise 会抛错,需 try/catch 或 .catch(() => false))。

  2. confirmAdd 里写了 await 但方法没加 async 现象:控制台报 SyntaxError: await is only valid in async function,或请求发出后 this.$router.back() 先执行、页面已跳走。 原因:confirmAdd() 声明时漏了 async。 修法:async confirmAdd() { ... };且 $router.back() 放在 await 之后。

  3. 合并两个表单时字段被覆盖 / 脏字段带进接口 现象:接口报某字段为空,或提交上去的姓名是 undefined。 原因:{ ...this.feeForm, ...this.carInfoForm } 中后展开的对象里存在同名 key(如都带 id、remark)会覆盖前者;payTime 这种只给组件用的字段也被一并提交。 修法:显式挑字段构造 payload,不要无脑展开;必须展开时先合并再 delete payload.payTime,并检查两边有无同名字段。

  4. 编辑回填后,日期选择器空白 现象:编辑页金额、车牌都回显了,只有缴费时间空着。 原因:后端返回 cardStartDate / cardEndDate 两个独立字段,而 el-date-picker(range 类型)的 v-model 要的是数组,且格式要与 value-format 一致。 修法:回填时 payTime: [cardStartDate, cardEndDate],并确认组件上写了 value-format,与提交时拆分逻辑严格对称。

  5. 编辑提交报"月卡信息ID不能为null" 现象:新增正常,编辑点确定接口 400。 原因:回填时只解构了展示字段,carInfoId、rechargeId 没存进表单对象,更新接口比新增多要这两个 ID。 修法:解构时把两个 ID 一并取出放进对应表单对象;提交时随 payload 一起带上。

  6. 新增页也去调详情接口 现象:点"新增"进页面,Network 里多一条 detail/undefined 的 404。 原因:mounted 里无条件调 getDetail()。 修法:if (this.id) this.getDetail();id 用 computed 从 $route.query.id 取一次,避免三处重复写 $route.query.id。

  7. 批量删除逻辑挂错事件 现象:表格里刚勾选第一行,数据就被删了。 原因:把 deleteBatchAPI 的调用写在了 selection-change 回调里。 修法:selection-change 只负责 this.selectedList = val,真正的接口调用放到"批量删除"按钮的 click 回调里。

  8. 批量删除参数格式不对 现象:接口报参数类型错误,或只删掉了第一条。 原因:把 selection-change 拿到的对象数组直接传给了接口。 修法:this.selectedList.map(item => item.id).join(','),拼成逗号分隔字符串再传。

  9. resetFields() 清不掉数据 现象:点了重置,输入框还留着上次填的内容,或红字校验提示不消失。 原因:el-form-item 漏写 prop(resetFields 靠 prop 定位字段);或回填时用 this.carInfoForm = {...} 整体替换了对象引用,resetFields 记录的初始值已失效。 修法:每个 el-form-item 补 prop 且与 model 字段名一致;回填用 Object.assign(this.carInfoForm, data),实在不行就手动重置为初始对象再 clearValidate()。

练习 ​

  1. 把 confirmAdd 改造成"任意一个表单校验失败即终止"的写法,要求用 async/await 实现,且校验失败时不能弹出任何接口请求(用 Network 面板验收)。

  2. 现有 payload 用扩展运算符合并两个表单。请改成显式字段白名单写法,并说明:如果将来 feeForm 新增一个只用于界面展示的字段,两种写法分别会有什么后果。

  3. 新增页和编辑页共用同一个组件。请在不新增路由的前提下,让页面标题、提交按钮文案、成功提示文案三处都根据 id 自动切换,并说明三处判断依据为什么必须是同一个来源。

  4. 批量删除目前是"先存选中行、再点按钮删除"。请改造为:未选中任何行时点击"批量删除"直接 this.$message.warning('请先选择要删除的月卡') 并 return;同时保证删除成功后列表刷新且选中状态清空。

  5. 给编辑页的 getDetail 加异常兜底:接口失败时提示错误并留在当前页,不允许出现"表单空着但用户可以点确定提交"的情况。

原视频对照 ​

#视频时长
1编辑月卡-补充作用域插槽09:03
2编辑月卡-使用id完成数据回显11:52
3编辑月卡-通过id判断调用不同接口完成更新09:58
4梳理新增和编辑流程区别和核心重点07:40
5单个删除功能实现05:43
6批量删除月卡15:31

小结 ​

  1. 新增与编辑的流程差异只有三处:是否携带 id 跳转、是否回填、调新增接口还是更新接口;其余校验、数据处理、后续提示完全复用。
  2. id 是贯穿全流程的唯一状态开关,判断"是否有 id"即可判断"是否编辑",务必用 computed 缓存一次,不要在多处散写 $route.query.id。
  3. 提交与回填是严格互逆的一对操作:提交时把 payTime 数组拆成两个字段,回填时必须拼回数组,任何一侧漏改都会导致日期显示异常。
  4. 编辑接口的参数比新增多出 carInfoId、rechargeId,这两个值只能从详情接口拿,因此回填时就必须存下来,不能等到提交时再想办法。
  5. 表单校验是异步的,凡是"校验通过后再做某事"的逻辑,都要保证 await/回调嵌套的层级正确,且方法本身声明为 async。
  6. 表格操作栏依赖作用域插槽,row 是内部固定的 key,插槽变量名可随意改,但解构出来的 row 不能改名。
  7. 批量类操作的标准三段式:selection-change 只收集、按钮 click 才执行、执行前先做空选校验;参数用 map 提取再 join 拼接。
  8. 危险操作(单个/批量删除)一律先 MessageBox 二次确认,成功后再重新拉取列表,保证界面与后端状态一致。