主题
编辑月卡与删除(二)
本页对应 编辑月卡与删除,共 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。
易错点清单
- 校验顺序错:两个表单必须都通过再提交。
- 参数没处理:不能把
payTime原样传给接口,要拆成cardStartDate、cardEndDate。 - 忘记删除多余字段:
delete payload.payTime不能漏。 - 合并覆盖:
...this.feeForm和...this.carInfoForm的顺序要按字段来源确认。 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 存在才获取详情,这是编辑和新增分流的第一道判断。
检查清单
- 编辑按钮是否传了
scope.row.id? - 跳转是否用
query携带 id? - 编辑页是否从
$route.query.id取 id? - 是否用 computed 缓存 id,避免多处重复写?
- 是否先判断 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。
常见错误
串行校验写成"同步判断" 现象:只填了第一个表单就能提交,第二个表单的红字压根没出现。 原因:
this.$refs.feeForm.validate()是回调/Promise 式 API,返回值不是布尔值,if (this.$refs.feeForm.validate())永远为真。 修法:要么两层回调嵌套,要么统一async+await this.$refs.a.validate()(await一个 reject 的 Promise 会抛错,需try/catch或.catch(() => false))。confirmAdd里写了await但方法没加async现象:控制台报SyntaxError: await is only valid in async function,或请求发出后this.$router.back()先执行、页面已跳走。 原因:confirmAdd()声明时漏了async。 修法:async confirmAdd() { ... };且$router.back()放在await之后。合并两个表单时字段被覆盖 / 脏字段带进接口 现象:接口报某字段为空,或提交上去的姓名是
undefined。 原因:{ ...this.feeForm, ...this.carInfoForm }中后展开的对象里存在同名 key(如都带id、remark)会覆盖前者;payTime这种只给组件用的字段也被一并提交。 修法:显式挑字段构造 payload,不要无脑展开;必须展开时先合并再delete payload.payTime,并检查两边有无同名字段。编辑回填后,日期选择器空白 现象:编辑页金额、车牌都回显了,只有缴费时间空着。 原因:后端返回
cardStartDate / cardEndDate两个独立字段,而el-date-picker(range 类型)的v-model要的是数组,且格式要与value-format一致。 修法:回填时payTime: [cardStartDate, cardEndDate],并确认组件上写了value-format,与提交时拆分逻辑严格对称。编辑提交报"月卡信息ID不能为null" 现象:新增正常,编辑点确定接口 400。 原因:回填时只解构了展示字段,
carInfoId、rechargeId没存进表单对象,更新接口比新增多要这两个 ID。 修法:解构时把两个 ID 一并取出放进对应表单对象;提交时随 payload 一起带上。新增页也去调详情接口 现象:点"新增"进页面,Network 里多一条
detail/undefined的 404。 原因:mounted里无条件调getDetail()。 修法:if (this.id) this.getDetail();id用computed从$route.query.id取一次,避免三处重复写$route.query.id。批量删除逻辑挂错事件 现象:表格里刚勾选第一行,数据就被删了。 原因:把
deleteBatchAPI的调用写在了selection-change回调里。 修法:selection-change只负责this.selectedList = val,真正的接口调用放到"批量删除"按钮的click回调里。批量删除参数格式不对 现象:接口报参数类型错误,或只删掉了第一条。 原因:把
selection-change拿到的对象数组直接传给了接口。 修法:this.selectedList.map(item => item.id).join(','),拼成逗号分隔字符串再传。resetFields()清不掉数据 现象:点了重置,输入框还留着上次填的内容,或红字校验提示不消失。 原因:el-form-item漏写prop(resetFields靠prop定位字段);或回填时用this.carInfoForm = {...}整体替换了对象引用,resetFields记录的初始值已失效。 修法:每个el-form-item补prop且与model字段名一致;回填用Object.assign(this.carInfoForm, data),实在不行就手动重置为初始对象再clearValidate()。
练习
把
confirmAdd改造成"任意一个表单校验失败即终止"的写法,要求用async/await实现,且校验失败时不能弹出任何接口请求(用 Network 面板验收)。现有
payload用扩展运算符合并两个表单。请改成显式字段白名单写法,并说明:如果将来feeForm新增一个只用于界面展示的字段,两种写法分别会有什么后果。新增页和编辑页共用同一个组件。请在不新增路由的前提下,让页面标题、提交按钮文案、成功提示文案三处都根据
id自动切换,并说明三处判断依据为什么必须是同一个来源。批量删除目前是"先存选中行、再点按钮删除"。请改造为:未选中任何行时点击"批量删除"直接
this.$message.warning('请先选择要删除的月卡')并 return;同时保证删除成功后列表刷新且选中状态清空。给编辑页的
getDetail加异常兜底:接口失败时提示错误并留在当前页,不允许出现"表单空着但用户可以点确定提交"的情况。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 编辑月卡-补充作用域插槽 | 09:03 |
| 2 | 编辑月卡-使用id完成数据回显 | 11:52 |
| 3 | 编辑月卡-通过id判断调用不同接口完成更新 | 09:58 |
| 4 | 梳理新增和编辑流程区别和核心重点 | 07:40 |
| 5 | 单个删除功能实现 | 05:43 |
| 6 | 批量删除月卡 | 15:31 |
小结
- 新增与编辑的流程差异只有三处:是否携带
id跳转、是否回填、调新增接口还是更新接口;其余校验、数据处理、后续提示完全复用。 id是贯穿全流程的唯一状态开关,判断"是否有 id"即可判断"是否编辑",务必用computed缓存一次,不要在多处散写$route.query.id。- 提交与回填是严格互逆的一对操作:提交时把
payTime数组拆成两个字段,回填时必须拼回数组,任何一侧漏改都会导致日期显示异常。 - 编辑接口的参数比新增多出
carInfoId、rechargeId,这两个值只能从详情接口拿,因此回填时就必须存下来,不能等到提交时再想办法。 - 表单校验是异步的,凡是"校验通过后再做某事"的逻辑,都要保证
await/回调嵌套的层级正确,且方法本身声明为async。 - 表格操作栏依赖作用域插槽,
row是内部固定的 key,插槽变量名可随意改,但解构出来的row不能改名。 - 批量类操作的标准三段式:
selection-change只收集、按钮click才执行、执行前先做空选校验;参数用map提取再join拼接。 - 危险操作(单个/批量删除)一律先
MessageBox二次确认,成功后再重新拉取列表,保证界面与后端状态一致。