主题
新增合同与合同列表(一)
本页对应 新增合同与合同列表,共 8 段视频,约 1 小时 8 分。
学习目标
- 能够通过控制弹框
visible属性并监听open、close事件,实现点击“添加合同”打开弹框、点击关闭按钮或遮罩关闭弹框。 - 能够基于弹框默认插槽和
footer具名插槽搭建合同表单结构,并声明表单对象及校验规则,使表单正常渲染和校验。 - 能够封装并调用楼宇下拉列表接口,在弹框打开时获取数据并完成下拉框绑定,且能根据接口情况使用专用接口或传大
pageSize获取全量数据。 - 能够使用上传组件的
http-request自定义上传,构造FormData并按接口要求追加文件和类型字段,调用上传接口后将返回的合同 ID 与 URL 回填到表单对象。 - 能够收集并补全合同表单参数(含企业 ID),将时间范围拆分为开始时间和结束时间后调用新增合同接口完成创建。
- 能够处理新增成功后的后续逻辑:关闭弹框,结合
resetFields与手动重置清空表单数据,并刷新合同列表。
知识点
先明确:本节做的是“准备表单”
为什么需要:添加合同不是只放几个输入框,而是要把楼宇、租赁时间、合同文件都纳入同一个 el-form,后面才能统一校验、统一提交。
怎么想:弹框主体区域用默认插槽放表单,底部用 footer 具名插槽放取消/确定按钮;弹框显示隐藏由 visible 控制,关闭时监听 close 事件。
怎么做:先搭静态表单结构,再补 rentForm 和 rentRules。
易错点:转写稿里的“vibo”应纠正为 visible;“点think”是旧式 .sync 写法,不建议在生产中继续使用;模板里用了 rentForm、rentRules 却没在 data 中声明,会直接报“属性未定义”。

表单模板:默认插槽承载主体
为什么需要:表单是弹框里的核心内容,el-form 通过 model 和 rules 把数据与校验规则关联起来。
怎么想:楼宇用 el-select,租赁起止日期用 daterange,合同用 el-upload;每个 el-form-item 的 prop 必须和 rentForm 字段一致。
怎么做:保留下面模板,注意 v-for="item in []" 只是占位,楼宇数据后面通过接口填充;action="#" 也是占位,真正上传要走自定义上传。
易错点:prop 写错会导致校验不生效;上传组件不能只写 action 就以为完成上传,后续要用 http-request 接管。
vue
<!-- 表单模版 -->
<div class="form-container">
<el-form ref="addForm" :model="rentForm" :rules="rentRules" label-position="top">
<el-form-item label="租赁楼宇" prop="buildingId">
<el-select v-model="rentForm.buildingId" placeholder="请选择">
<el-option
v-for="item in []"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="租赁起止日期" prop="rentTime">
<el-date-picker
v-model="rentForm.rentTime"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="租赁合同" prop="contractId">
<el-upload
action="#"
>
<el-button size="small" type="primary" plain>上传合同文件</el-button>
<div slot="tip" class="el-upload__tip">支持扩展名:.doc .docx .pdf, 文件大小不超过5M</div>
</el-upload>
</el-form-item>
</el-form>
</div>data:表单对象与校验规则
为什么需要:rentForm 是提交时的数据源,rentRules 决定哪些字段必填。
怎么想:楼宇、时间、合同是三个必填项;contractUrl、enterpriseId、type 虽然不在当前表单项里直接操作,但提交接口需要。
怎么做:把下面对象放进 data 的 return 中;buildingId、rentTime 用 change 触发校验,contractId 用必填规则。
易错点:字段名要和接口文档一致;rentTime 是数组,提交前要拆成开始/结束时间;resetFields 在部分 Element UI 版本中可能清不干净,转写稿建议配合手动重置。
js
data(){
return {
rentForm: {
buildingId: null, // 楼宇id
contractId: null, // 合同id
contractUrl: '', // 合同Url
enterpriseId: null, // 企业名称
type: 0, // 合同类型
rentTime: [] // 合同时间
},
rentRules: {
buildingId: [
{ required: true, message: '请选择楼宇', trigger: 'change' }
],
rentTime: [
{ required: true, message: '请选择租赁日期', trigger: 'change' }
],
contractId: [
{ required: true, message: '请上传合同文件' }
]
}
}
}楼宇下拉:打开弹框时请求数据
为什么需要:下拉选项来自楼宇数据,不能写死。
怎么想:弹框打开时才需要下拉数据,所以监听弹框的 open 事件;不要直接在模板里裸调接口,单独封装 openDialog,以后还要做别的事也方便扩展。
怎么做:先封装 getBuildingList,再在 openDialog 中调用;接口可以优先用“可租赁楼宇下拉列表”专用接口,若后端没有,也可复用列表接口并传较大的 pageSize 拿全量。
易错点:字段拼写要检查,转写稿里就出现过 buildingList 绑定拼写错误;接口从不同模块导入时注意路径,避免“模块导入导出”报错。
上传合同:先实现核心上传逻辑
为什么需要:合同文件必须先上传,拿到 contractId 和 contractUrl,提交表单时才有合同信息。
怎么想:el-upload 负责打开本地文件并做基础校验,自定义上传用 http-request;拿到 file 后组装 FormData,再调公共上传接口。
怎么做:new FormData(),按接口文档 append('file', file)、append('type', ...),上传成功后把返回的 id、url 写回 rentForm.contractId、rentForm.contractUrl。
易错点:http-request 回调参数里要通过 .file 取文件;FormData 字段名不能自己瞎起;公共上传接口抽取后要回测旧模块。
提交与成功后续:校验、参数、重置
为什么需要:点确定时不能直接把 rentForm 原样丢给接口,要先校验、再按接口要求组装参数。
怎么想:rentTime 是数组,接口要 startTime、endTime 两个字段;enterpriseId 从当前点击行拿,在打开弹框时写入表单。
怎么做:this.$refs.addForm.validate 通过后,解构 rentForm,补 startTime: rentTime[0]、endTime: rentTime[1],再调添加合同接口;成功后关闭弹框、resetFields 加手动重置、刷新列表。
易错点:模块导入路径错误很常见;关闭弹框是把 visible 从 true 改回 false;重置数据建议“resetFields + 手动置空”配合,避免清不干净。
本节目标与业务背景

在“企业管理”的“添加合同”弹框中,“租赁楼宇”不能手填,必须从楼宇列表中选择。因此本节要完成一条完整链路:找到楼宇数据接口 → 封装 API → 打开弹框时请求 → 把数据绑定到 el-select 下拉框。
为什么需要:合同必须关联具体楼宇,手填容易产生不存在的楼宇 ID,也无法保证数据一致。
怎么想:先不要急着写模板,先确认接口从哪里来、返回什么字段,再决定何时调用。
怎么做:封装 getRentBuildListAPI,在打开弹框时调用,把 res.data 赋给 buildList,模板用 item.id 和 item.name 渲染。
易错点:接口模块找错、打开弹框忘记请求、字段名拼错、只拿到第一页数据。
先找对接口:楼宇数据来自楼宇管理模块
为什么需要:接口文档里“企业管理”下面找不到“获取楼宇”的接口,并不代表没有接口,而是数据源属于另一个模块。
怎么想:添加合同时用的楼宇数据,本质上来自之前做过的“楼宇管理”列表;下拉框只是复用它。转写中提到的“条形可租入楼宇”“可租赁楼宇信息用于下拉列表”,就是在楼宇管理相关文档里找到的专门接口。
怎么做:回到楼宇管理模块找“可租赁楼宇/楼宇列表”接口,确认请求地址和返回字段。本节笔记中最终封装的是:
javascript
/**
* 获取楼宇列表
* @param {*} id
* @returns
*/
export function getRentBuildListAPI() {
return request({
url: '/park/rent/building'
})
}易错点:只在“企业管理”文档里打转;把表格列表接口和下拉框接口混为一谈。实际开发中后端可能给专门的下拉接口,也可能复用列表接口,要以文档为准。
打开弹框时调用接口
为什么需要:楼宇数据只在弹框打开后使用,提前请求没有必要;打开时请求能保证数据较新。
怎么想:弹框有 open 事件,适合做“打开时初始化”。转写中强调:如果打开弹框只做“调接口”这一件事,可以直接绑;但以后可能还要做别的初始化,所以单独写一个方法更稳妥。
怎么做:在 addRent 中先打开弹框,再请求楼宇列表并赋值:
javascript
import { getRentBuildListAPI } from '@/apis/enterprise'
// 打开弹框
async addRent(enterpriseId) {
this.rentDialogVisible = true
// 获取楼宇下拉列表
const res = await getRentBuildListAPI()
this.buildList = res.data
}易错点:忘记 await 导致拿到 Promise;赋值字段写成 buildingList、模板却用 buildList;接口返回结构不是 res.data 时没有看 Network 返回。
渲染下拉列表
为什么需要:接口数据要变成用户可选项,必须通过 el-option 循环渲染。
怎么想:下拉框的值通常提交 id,展示给用户的是 name,所以 :label="item.name"、:value="item.id"。
怎么做:模板中绑定 rentForm.buildingId,循环 buildList:
html
<el-form-item label="租赁楼宇" prop="buildingId">
<el-select v-model="rentForm.buildingId" placeholder="请选择">
<el-option
v-for="item in buildList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>易错点::key 用错;把 item.name 写成 item.username;接口字段是 id/name,模板却按其他字段绑定。转写中调试时发现“有数据但不显示”,最后就是拼写/绑定字段问题,借助 Vue 调试工具检查 buildList 后修正。
处理只返回一页数据的问题
为什么需要:如果沿用表格列表接口,默认可能只返回第一页,转写中第一次只拿到 9 条,而下拉框需要全部可租赁楼宇。
怎么想:表格接口通常有分页参数;下拉框没有分页,应该一次性拿全。口述里的“配置”“big size”实际对应分页参数 page、pageSize(以接口文档为准)。调试时传 page=1、pageSize=100 后,返回条数变多,说明思路成立。
怎么做:优先使用专门的下拉接口 /park/rent/building,它不需要传参,直接返回下拉所需数据;如果只能复用列表接口,就按文档补一个足够大的 pageSize。
易错点:把表格分页逻辑带到下拉框;以为接口坏了,其实只是没传分页参数;换专门接口后仍按旧返回结构取 res.data.list,而它可能直接返回数组或更精简的 id/name 字段。
调试与小结
调试时先看 Network:请求地址是否正确、参数是否发出、返回里是 data 还是 data.list、字段是不是 id/name。如果下拉有数据但不显示,先查模板绑定和拼写;如果条数不够,先查分页。最终效果是:点击“添加合同”打开弹框 → 调楼宇接口 → buildList 有值 → 下拉框可选。这样“获取楼宇下拉框数据”这条链路就完整了。
先明确本节要完成的三件事

本节的目标不是只放一个上传按钮,而是让“合同文件”成为表单里可提交的有效字段。笔记已经列出三步:把文件处理为 FormData、上传并保存返回的合同文件 id 和 url、上传成功后单独校验表单字段。
为什么需要:合同上传最终要服务于“新增合同”的表单提交,后端需要的是合同文件 id、地址等字段,而不是一个孤立的本地文件。怎么想:把上传拆成“选文件 → 组参数 → 调接口 → 回填表单 → 触发校验”。怎么做:先写 el-upload,再写 uploadHandle。易错点:只把文件传上去,却忘记把返回的 id/url 写回 rentForm,后面提交表单就会缺参数。
用 el-upload 接管文件选择
vue
<el-form-item label="租赁合同" prop="contractId">
<el-upload
action="#"
:http-request="uploadHandle"
>
<el-button size="small" type="primary" plain>上传合同文件</el-button>
<div slot="tip" class="el-upload__tip">支持扩展名:.doc .docx .pdf, 文件大小不超过5M</div>
</el-upload>
</el-form-item>为什么需要:el-upload 能帮我们打开本地文件选择框,并拿到用户选中的文件对象。怎么想:本节要自定义上传,所以关键不是让组件默认发请求,而是绑定 :http-request,把上传动作交给我们自己的 uploadHandle。怎么做:模板里保留 action="#" 作为占位,真正上传由 uploadHandle 完成。易错点:忘记写 :http-request 时,组件可能按默认方式走 action;另外回调参数不是直接一个 File,转写稿里也强调要先拿到 fileData.file。提示文案只是展示,若要真正限制扩展名和 5M,还需要额外的文件校验逻辑,本节先保证核心上传链路。
把 fileData 处理成 FormData
js
// 上传合同
async uploadHandle(fileData) {
// 1. 处理FormData
const { file } = fileData
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'contract')为什么需要:上传接口通常要求 multipart/form-data,不能直接把 File 当普通 JSON 字段传。怎么想:转写稿里说的“放闭对象/方对”实际就是 FormData;先 new FormData(),再按接口文档 append 字段。怎么做:从 fileData 中解构出 file,追加 file 字段,再追加 type,值为 contract。易错点:append 的字段名不能自己起,必须按接口文档;type 的值也不要拼错,转写稿里提到的 Conject/contractstract 都是口误或同音问题,代码应以 contract 为准;忘记解构 file,把整个 fileData append 进去也会导致参数不对。
上传并回填 contractId 与 contractUrl
js
// 2. 上传并赋值
const res = await uploadAPI(formData)
const { id, url } = res.data
this.rentForm.contractId = id
this.rentForm.contractUrl = url为什么需要:上传接口返回的 id 和 url 才是后续提交合同时真正要用的字段。怎么想:先调 uploadAPI(formData),再从响应里解构 id/url,分别写入 rentForm.contractId 和 rentForm.contractUrl。怎么做:转写稿的调试过程是先在 uploadHandle 里打印参数,确认能拿到 file;再在 Network 里看上传请求的 FormData 是否包含 file 和 type=contract;请求成功后看返回数据里的 id/url,再观察 rentForm 是否被赋值。易错点:只保存 url 不保存 id;返回结构不是 res.data 时直接解构会报错;uploadAPI 若是公共接口,要注意从公共 API 文件正确导入,转写稿里就出现过模块导入路径写错的问题。
上传成功后单独校验 contractId
js
// 单独校验表单
this.$refs.addForm.validate('contractId')
}为什么需要:上传也是一种表单交互形式。用户选完文件后,contractId 才从空变成有值,如果不单独校验,页面上该字段的必填提示可能不会及时消失。怎么想:只校验当前受影响的字段,而不是每次上传都整表校验。怎么做:调用表单实例的 validate,传入字段名 'contractId'。易错点:ref 名字要和表单上的 ref 一致,字段名要和 prop="contractId" 一致;validate 是异步的,实际项目中可结合 await 或回调处理失败情况,但本节笔记的核心写法就是这一句。整体上要记住转写稿强调的顺序:先把上传接口调通、把数据回填好,再补文件规格校验等附加逻辑。
本节目标与整体思路
这一节要完成“提交表单创建合同”。重点不是把表单对象原样丢给接口,而是按照后端接口要求,先把数据处理好再提交。为什么需要这样做?因为页面上的表单字段和接口字段经常不完全一致:例如表单里的租赁时间是一个数组 rentTime,接口却要 startTime 和 endTime 两个字段;企业 ID 也不是用户在表单项里输入的,而是点击“添加合同”时从当前企业行带过来的。
所以整体思路分三步:先封装创建合同接口;再在打开弹框时补充 enterpriseId;最后在确认提交时解构表单数据,把时间数组拆成起止时间,再调用接口。易错点是直接提交 this.rentForm,导致接口字段缺失或时间格式不对。
封装创建合同接口
为什么需要先封装接口?因为请求地址、请求方法、参数位置要统一管理,业务组件里只关心“传什么数据”。怎么想?看接口文档:创建合同是 POST 请求,地址为 /park/enterprise/rent,参数放在请求体 data 中。怎么做?在 API 文件中新增:
javascript
/**
* 创建合同
* @param {*}
* @returns
*/
export function createRentAPI(data) {
return request({
url: '/park/enterprise/rent',
method: 'POST',
data
})
}易错点:method 不要写成 GET;参数不要放到 params;导出后要在业务组件中正确引入。转写稿里调试时出现过“模块导入导出”的报错,本质是 API 引入路径或导出位置不对,看到类似报错要先检查 import 是否从正确文件导入。
打开弹框时补充企业 ID
为什么需要单独补企业 ID?因为 enterpriseId 不是用户在表单里选择的,它来自当前点击的企业行。怎么想?点击“添加合同”时,当前行的 row.id 就是企业 ID;打开弹框的同时把它存进 rentForm,提交时就能一起带走。怎么做?
vue
<el-button size="mini" type="text" @click="addRent(row.id)">添加合同</el-button>
// 添加合同
addRent(id) {
this.dialogVisible = true
// 把企业id存入表单对象
this.rentForm.enterpriseId = id
}易错点:只打开弹框却忘记赋值,提交时 enterpriseId 就是 undefined;row 的作用域要确认在表格插槽内;不要用错字段名。调试时可以在 addRent 里打印或看 Vue 调试工具中的 rentForm.enterpriseId,确认点击不同企业行时值会变化。
确认提交时处理参数
为什么不能直接提交表单对象?因为接口要的是 startTime、endTime,而表单里是 rentTime 数组;同时只提交接口需要的字段更清晰。怎么想?先从 rentForm 中解构普通字段,再单独取 rentTime[0] 和 rentTime[1] 作为起止时间。怎么做?
javascript
// 确认提交
confirmAdd() {
this.$refs.addForm.validate(async valid => {
if (valid) {
const { buildingId, contractId, contractUrl, enterpriseId, type } = this.rentForm
await createRentAPI({
buildingId, contractId, contractUrl, enterpriseId, type,
startTime: this.rentForm.rentTime[0],
endTime: this.rentForm.rentTime[1]
})
// 更新列表
this.getExterpriseList()
// 关闭弹框
this.rentDialogVisible = false
}
})
}易错点:表单校验未通过仍提交;rentTime 为空时取下标会出错;字段名拼写错误;await 外层回调要写成 async;弹框变量名要统一,笔记中前面出现 dialogVisible,提交处是 rentDialogVisible,实际项目里要确认使用同一个控制变量。调试时打开 Network 看请求体是否包含 buildingId、contractId、contractUrl、enterpriseId、type、startTime、endTime,尤其检查时间是否是接口要求的格式。转写稿还提醒:成功后除了刷新列表、关闭弹框,实际项目里通常还要重置表单,避免下次打开残留旧数据;可按项目需要配合 resetFields 和手动清空处理。
先明确本节目标:把展开行的静态骨架搭出来
本节笔记的核心不是接口、不是表单校验,而是“准备 Table 静态结构”。也就是说,先在页面上把主表格中的“展开行”以及展开后要显示的嵌套表格骨架搭好,后续再往里绑定真实数据。
为什么需要:很多业务表格一行并不能展示完整信息。例如合同列表里,一行代表一份合同,但合同下面可能对应多条租赁楼宇明细。若把明细都塞进主表,列会非常拥挤;若跳到详情页,又增加操作成本。展开行可以让用户按需查看子表格。
怎么想:el-table 支持嵌套内容,关键在 el-table-column 上添加 type 属性,并指定值为 expand。这个列不是普通数据列,而是展开控制列;展开后显示什么,由该列的默认插槽决定。
怎么做:在外层表格列配置中加入展开列,并在 template #default 中放一个内层 el-table。笔记代码如下,需原样保留:
vue
<el-table-column type="expand">
<template #default>
<el-table>
<el-table-column label="租赁楼宇" width="320" prop="buildingName" />
<el-table-column label="租赁起始时间" prop="startTime" />
<el-table-column label="合同状态" prop="status" />
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="mini" type="text">退租</el-button>
<el-button size="mini" type="text">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>易错点:type="expand" 要写在 el-table-column 上,不是写在 el-table 上;展开内容要放在默认插槽中,不要误写成具名插槽;本节代码里的内层 el-table 还没有写 :data,这是“静态结构”阶段的正常状态,数据绑定应在后续步骤完成。转写稿里多次强调“先准备结构,再调接口、再做附加逻辑”,这里同样适用:先把展开列和内层表格列搭出来,再考虑数据来源。
展开列与普通列的区别:先想清楚“这一列控制什么”
为什么需要:如果不理解 type="expand" 的语义,容易把它当成普通展示列,结果写了 prop 却没有展开效果。展开列控制的是“展开/收起”交互,不负责直接展示某个字段。
怎么想:普通列通过 prop 读取行数据字段;展开列通过插槽提供展开区域。因此,展开列内部可以放任意结构,本节放的是另一个 el-table。
怎么做:外层表格中保留展开列;内层表格继续使用 el-table-column 定义子表列。子表列中的 prop="buildingName"、prop="startTime"、prop="status" 对应后续子数据字段。操作列使用 #default="scope" 拿到当前行作用域,再放“退租”“删除”按钮。
易错点:不要把展开列写成普通列后再期望它自动展开;内层表格的列 prop 要与后续数据字段一致。转写稿中多次出现“字段拼写不对导致页面不显示”的调试过程,这里也要注意:静态结构阶段虽然不报错,但后续绑定时 buildingName、startTime、status 写错就会出现空白。
嵌套表格的结构准备:先静态、后动态
为什么需要:嵌套表格比单层表格多了层级关系。若一上来就同时处理接口、分页、操作按钮,很容易在结构问题上反复排查。
怎么想:按转写稿中“先实现核心逻辑,再实现附加逻辑”的思路,本节先完成三件事:外层展开列、内层表格、内层列定义。数据绑定、退租/删除事件、接口调用都后置。
怎么做:将上面的代码放入外层 el-table 的列配置区域。内层 el-table 暂时不写数据源,等后续拿到合同下的楼宇明细后再绑定。操作列中的按钮先保留静态结构,事件后续再加。
易错点:模板标签要闭合;插槽名不要写错;内层表格不要误放到外层表格之外。转写稿里提到“模板里用了某个绑定但没定义会报错”“模块导入导出位置不对会报错”,这类问题在表格结构阶段同样要养成检查习惯:先看模板层级,再看插槽,再看字段名。转写稿中的“弹框”应写作“弹窗”,“插槽”不要写成同音错字;这些术语统一后,阅读和排查会更清晰。
小结:本节交付的是可继续开发的静态骨架
本节完成后,应得到一个带展开列的外层表格结构:点击展开后,内部出现一个子表格,子表格包含租赁楼宇、租赁起始时间、合同状态和操作列。此时不追求数据完整和交互完整,而是保证结构正确、列定义清晰、后续可以继续绑定子表数据和按钮事件。这样再进入数据请求与交互实现时,排查范围会更小。
本节要解决的问题
企业列表页里,每一行企业都可能有多份租赁合同。合同数据不适合跟着企业列表一次性全部查出来:企业多、合同更多,首屏会变慢。更合理的做法是:企业列表正常加载,用户展开某一行时,再按该企业的 id 去请求合同列表。本节就围绕这个“按需加载”完成三件事:封装接口、给每行准备合同容器、展开时请求并绑定。
转写稿里反复强调过一个思路:先把核心接口调通,再做附加交互;改动代码后要回测影响范围。这里同样适用——先保证 getRentListAPI 能拿到数据,再处理展开/收起的判断。
封装获取合同列表接口
为什么需要:业务组件里直接写 request({ url: ... }) 会把接口地址散落在页面中,后续改路径、加参数、统一处理错误都不方便。把接口函数放到 API 层,组件只关心“传哪个企业 id”。
怎么想:合同列表依赖企业 id,接口路径是 /park/enterprise/rent/${id},所以函数接收 id,用模板字符串拼到 URL 上。
怎么做:导出 getRentListAPI,内部调用项目已封装的 request。
js
/**
* 获取合同列表
* @param {*} id
* @returns
*/
export function getRentListAPI(id) {
return request({
url: `/park/enterprise/rent/${id}`
})
}易错点:id 不能为空;模板字符串要用反引号;不要自己臆造返回结构,先调用一次看 res.data 到底是什么。笔记中后续直接写 row.rentList = res.data,说明本节约定接口返回的 data 就是合同数组。
给每一行补充合同列表容器
为什么需要:展开行里的内层表格写的是 :data="row.rentList"。如果初始数据没有 rentList,未展开时可能是 undefined,渲染和后续赋值都不稳定。
怎么想:企业列表接口返回的是企业行数据,合同列表是“行里再挂一个列表”。初始化时给每条企业数据补一个空数组,展开后再把接口结果塞进去。
怎么做:在 getExterpriseList 里用 map 返回新对象,保留原字段并追加 rentList: [],同时不要漏掉 total。
js
async getExterpriseList() {
const res = await getEnterpriseListAPI(this.params)
this.exterpriseList = res.data.rows.map((item) => {
return {
...item,
rentList: [] // 每一行补充存放合同的列表
}
})
this.total = res.data.total
}易错点:代码里的 exterpriseList 按笔记原样保留,实际可理解为 enterprise 列表,不要因为拼写随意改动导致模板对不上;map 要返回新对象,不要只写 item.rentList = [] 后忘记返回。
展开时再请求合同数据
为什么需要:只有用户展开某一行时才需要该企业的合同;收起时不需要。这样减少无效请求。
怎么想:el-table 的 @expand-change 在展开和收起时都会触发,参数是当前行 row 和当前展开行数组 rows。要判断这次是展开还是收起:如果 rows 里能找到当前 row.id,说明是展开,才请求。
怎么做:模板绑定事件,内层表格绑定 row.rentList;方法里先 find 判断,再请求并赋值。
vue
<el-table style="width: 100%" :data="exterpriseList"
@expand-change="expandHandle">
<el-table-column type="expand">
<template #default="{row}">
<el-table :data="row.rentList">
<el-table-column label="租赁楼宇" width="320" prop="buildingName" />
<el-table-column label="租赁起始时间" prop="startTime" />
<el-table-column label="合同状态" prop="status" />
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="mini" type="text">续租</el-button>
<el-button size="mini" type="text">退租</el-button>
<el-button size="mini" type="text">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>js
async expandHandle(row, rows) {
console.log('展开或关闭', row, rows)
const isExpend = rows.find(item => item.id === row.id)
if (isExpend) {
const res = await getRentListAPI(row.id)
// eslint-disable-next-line require-atomic-updates
row.rentList = res.data
}
}易错点:不要一进方法就请求,否则收起也会请求;find 比较时注意 id 类型一致;row.rentList = res.data 前先确认 res.data 是数组。本节没有做“已加载缓存”,重复展开同一行会再次请求,这是当前实现的特点;若后续要优化,可另加标记,但不要混进本节代码。require-atomic-updates 注释是因为异步后修改 row 触发了 lint 提示,不是运行时错误。
调试顺序与小结
调试时按笔记里的 console.log('展开或关闭', row, rows) 先看事件是否触发、rows 里有没有当前行,再看接口是否发出、返回是否为数组,最后看内层表格是否绑定到 row.rentList。这比一上来怀疑表格组件更有效。整体流程就是:封装 getRentListAPI → 初始化补 rentList: [] → expand-change 判断展开 → 请求并赋值。先保证这条链路通,再考虑重复请求、加载状态等附加优化。
代码示例
示例一:新增合同弹框(打开/关闭 + 表单 + 楼宇下拉 + 上传 + 提交)
vue
<template>
<div class="enterprise-container">
<!-- 企业列表 -->
<el-table :data="exterpriseList" style="width: 100%">
<el-table-column label="企业名称" prop="name" />
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button size="mini" type="text" @click="addRent(row.id)">添加合同</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加合同弹框 -->
<el-dialog
title="添加合同"
:visible="rentDialogVisible"
width="580px"
@open="openDialog"
@close="closeDialog"
>
<div class="form-container">
<el-form ref="addForm" :model="rentForm" :rules="rentRules" label-position="top">
<el-form-item label="租赁楼宇" prop="buildingId">
<el-select v-model="rentForm.buildingId" placeholder="请选择">
<el-option
v-for="item in buildList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="租赁起止日期" prop="rentTime">
<el-date-picker
v-model="rentForm.rentTime"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="租赁合同" prop="contractId">
<el-upload action="#" :http-request="uploadHandle">
<el-button size="small" type="primary" plain>上传合同文件</el-button>
<div slot="tip" class="el-upload__tip">
支持扩展名:.doc .docx .pdf, 文件大小不超过5M
</div>
</el-upload>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="confirmAdd">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import {
getRentBuildListAPI,
createRentAPI,
getEnterpriseListAPI
} from '@/apis/enterprise'
import { uploadAPI } from '@/apis/common'
export default {
name: 'Enterprise',
data() {
return {
exterpriseList: [],
total: 0,
rentDialogVisible: false, // 控制弹框打开和关闭
buildList: [], // 楼宇下拉列表
rentForm: {
buildingId: null, // 楼宇id
contractId: null, // 合同id
contractUrl: '', // 合同Url
enterpriseId: null, // 企业名称
type: 0, // 合同类型
rentTime: [] // 合同时间
},
rentRules: {
buildingId: [{ required: true, message: '请选择楼宇', trigger: 'change' }],
rentTime: [{ required: true, message: '请选择租赁日期', trigger: 'change' }],
contractId: [{ required: true, message: '请上传合同文件' }]
}
}
},
created() {
this.getExterpriseList()
},
methods: {
async getExterpriseList() {
const res = await getEnterpriseListAPI(this.params)
this.exterpriseList = res.data.rows.map((item) => {
return {
...item,
rentList: [] // 每一行补充存放合同的列表
}
})
this.total = res.data.total
},
// 打开弹框,并把企业id存入表单对象
addRent(id) {
this.rentDialogVisible = true
this.rentForm.enterpriseId = id
},
// 弹框打开时拉取楼宇下拉数据
async openDialog() {
const res = await getRentBuildListAPI()
this.buildList = res.data
},
// 关闭弹框 + 重置数据
closeDialog() {
this.rentDialogVisible = false
this.$refs.addForm.resetFields() // 清除校验痕迹
this.rentForm = { // 手动清空,配合 resetFields 使用
buildingId: null,
contractId: null,
contractUrl: '',
enterpriseId: null,
type: 0,
rentTime: []
}
},
// 上传合同
async uploadHandle(fileData) {
// 1. 处理 FormData
const { file } = fileData
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'contract')
// 2. 上传并赋值
const res = await uploadAPI(formData)
const { id, url } = res.data
this.rentForm.contractId = id
this.rentForm.contractUrl = url
// 单独校验表单字段
this.$refs.addForm.validate('contractId')
},
// 确认提交
confirmAdd() {
this.$refs.addForm.validate(async (valid) => {
if (valid) {
const { buildingId, contractId, contractUrl, enterpriseId, type } = this.rentForm
await createRentAPI({
buildingId,
contractId,
contractUrl,
enterpriseId,
type,
startTime: this.rentForm.rentTime[0],
endTime: this.rentForm.rentTime[1]
})
// 更新列表
this.getExterpriseList()
// 关闭弹框
this.rentDialogVisible = false
}
})
}
}
}
</script>关键行
:visible="rentDialogVisible":el-dialog只用visible一个布尔值控制显隐,不要用已淘汰的.sync语法。@open="openDialog":弹框打开时才请求楼宇下拉数据,比created里请求更省接口调用;单独包一个openDialog而不是直接把接口绑上去,是为了以后还要在打开时做别的事。@close="closeDialog":点右上角 × 和点空白遮罩都会触发close事件,是子传父的关闭通知,必须由父组件把rentDialogVisible置回false,否则关不掉。addRent(row.id):enterpriseId从当前行数据取,表单里没有对应输入项,只能在这里补。formData.append('file', file)+formData.append('type', 'contract'):字段名和值以接口文档为准,错一个字母都传不上去。
易错点
el-upload必须写:http-request="uploadHandle"才会走自定义上传;uploadHandle的参数是{ file },要解构出file再append。- 上传成功后只调
validate('contractId')单独校验这一项,因为其他项用户可能还没填。 rentTime是数组,提交时要拆成startTime: rentTime[0]、endTime: rentTime[1],不能直接丢给后端。resetFields()存在清不掉的情况(组件库已知问题),要配合手动把rentForm重置为空对象,两者都写。createRentAPI、uploadAPI、getRentBuildListAPI来自不同文件,导入路径写错会报“模块导入导出”错误,改完记得回测之前用过uploadAPI的模块。
示例二:合同列表展开行(type="expand" + expand-change 按需加载)
vue
<template>
<div class="enterprise-container">
<el-table
style="width: 100%"
:data="exterpriseList"
@expand-change="expandHandle"
>
<el-table-column label="企业名称" prop="name" />
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button size="mini" type="text" @click="addRent(row.id)">添加合同</el-button>
</template>
</el-table-column>
<!-- 展开行:嵌套一层合同表格 -->
<el-table-column type="expand">
<template #default="{ row }">
<el-table :data="row.rentList">
<el-table-column label="租赁楼宇" width="320" prop="buildingName" />
<el-table-column label="租赁起始时间" prop="startTime" />
<el-table-column label="合同状态" prop="status" />
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="mini" type="text">续租</el-button>
<el-button size="mini" type="text">退租</el-button>
<el-button size="mini" type="text">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import { getEnterpriseListAPI, getRentListAPI } from '@/apis/enterprise'
export default {
name: 'Enterprise',
data() {
return {
exterpriseList: [],
total: 0
}
},
created() {
this.getExterpriseList()
},
methods: {
// 初始化时为每一行补充空合同列表
async getExterpriseList() {
const res = await getEnterpriseListAPI(this.params)
this.exterpriseList = res.data.rows.map((item) => {
return {
...item,
rentList: [] // 每一行补充存放合同的列表
}
})
this.total = res.data.total
},
// 展开 / 收起时触发
async expandHandle(row, rows) {
const isExpend = rows.find((item) => item.id === row.id)
if (isExpend) {
const res = await getRentListAPI(row.id)
// eslint-disable-next-line require-atomic-updates
row.rentList = res.data
}
}
}
}
</script>关键行
<el-table-column type="expand">:type指定为expand即可开启展开行,展开区域内容用#default="{ row }"拿到当前行。@expand-change="expandHandle":展开或收起都会触发,回调参数是(row, rows),rows是当前所有处于展开状态的行。rows.find(item => item.id === row.id):用它在rows里找当前行,找到说明是“展开”,找不到说明是“收起”,只有展开时才请求接口。row.rentList = res.data:直接改行对象上的属性,嵌套表格的:data="row.rentList"就会渲染出来。
易错点
- 初始化列表时必须给每行补
rentList: [],否则嵌套el-table的data是undefined会报错。 - 一定要做展开/收起的判断,否则每次收起也会白调一次接口。
- 内层表格的
:data是row.rentList,不是外层的exterpriseList,写错会渲染不出数据。 - 展开行拿到的数据是异步的,赋值后如果界面没更新,先检查接口返回字段名(如
buildingName、startTime)是否与prop拼写一致。
常见错误
1. 弹框点右上角叉号或点击遮罩层关不掉
- 现象:点「添加合同」能打开,但点 ✕ 和点空白区域都没反应,只有改代码里的
visible初值才生效。 - 原因:只把
visible绑成了单向属性,没有接收组件内部抛出的关闭事件,子组件改了状态父组件不知道。 - 修法:在
el-dialog上同时绑定@close,在回调里把控制显隐的字段置回false;打开和关闭走同一份状态,不要各写一套。
2. 上传完合同,表单仍提示「请上传合同文件」,提交被拦住
- 现象:文件上传成功、
contractId已赋值,但红字提示不消失,点确定一直卡在校验。 - 原因:
el-upload不是表单原生控件,赋值不会触发el-form-item的校验;而且validate('contractId')这种写法本身不成立(validate只接收回调,单字段校验要用validateField)。 - 修法:上传成功回调里手动触发单字段校验
this.$refs.addForm.validateField('contractId');同时确认prop、rules里的字段名、v-model绑定的字段三者拼写完全一致。
3. 楼宇下拉框是空的,但接口明明返回了数据
- 现象:
res.data里有十几条数据,el-option一条都渲染不出来。 - 原因:模板里
:label="item.name"/:value="item.id"与接口实际字段名大小写或驼峰不一致(如写成item.Name、item.buildingId)。 - 修法:先用 devtools 看组件 data 里
buildList是否有值、再看单条对象结构,字段名照抄接口返回;确认是「数据没进来」还是「字段名对不上」,这两类问题修法完全不同。
4. 下拉列表只有第一页 10 条,后面的楼宇选不到
- 现象:楼宇总数 28 条,下拉里只能看到前 10 条。
- 原因:直接复用了带分页的表格列表接口,且没传分页参数,后端按默认第一页返回。
- 修法:优先换后端提供的「可租赁楼宇」专用下拉接口(无分页、字段精简);若只能用列表接口,显式传一个足够大的
pageSize,并在代码里注释说明这是权宜之计。
5. 上传接口报错或后端收不到文件
- 现象:调上传接口 400/500,或返回成功但文件为空。
- 原因:一是把
http-request回调的参数直接当File用(它其实是个对象,真正的文件在file属性上);二是没包FormData直接传对象;三是append的字段名或type的值拼错。 - 修法:
const { file } = fileData取出文件,new FormData()后按接口文档逐个append,字段名和值逐字母对照文档,别自己起名。
6. 提交时后端报「开始时间不能为空」,前端明明选了日期
- 现象:日期控件里选了区间,提交后时间字段为空或格式不对。
- 原因:
daterange拿到的是数组,接口要的是startTime/endTime两个独立字段,直接把整个表单对象丢过去。 - 修法:提交前做一层显式参数转换,从
rentTime[0]/rentTime[1]取值映射成两个字段,其余字段按接口要求挑拣,不要整对象透传。
7. 提交后企业 id 为 null,合同挂不到企业名下
- 现象:合同创建成功但列表里查不到,或接口提示企业 id 必填。
- 原因:企业 id 不是用户在这个弹框里填的,表单初始化时没有来源。
- 修法:在点击「添加合同」时把当前行的 id 带进方法,写入表单对象的
enterpriseId;凡是「不在表单里填但接口要」的字段,都要在打开弹框那一刻补齐。
8. 抽走公共上传接口后,之前写好的新增企业页面挂了
- 现象:控制台报模块找不到 /
xxx is not a function,且是别的页面报的。 - 原因:把接口从业务 api 文件挪到 common 后,只改了新页面的引用路径,旧页面还指向老位置。
- 修法:全局搜一遍该接口的引用点统一改;这类「移动公共代码」的改动必须回测所有引用方,看到导入导出类报错先查路径而不是查逻辑。
9. 弹框关了再打开,上次填的数据和校验红字还在
- 现象:第二次打开弹框,楼宇、日期、文件名都还是上一次的。
- 原因:只把
visible置 false,表单数据对象和校验状态都没动;另外只调resetFields在部分版本上清不干净。 - 修法:关闭时两件事一起做——
resetFields()清校验痕迹 + 手动把表单数据对象整体替换成初始空对象;上传组件若还有文件列表也要一并清空。
练习
练习 1|上传前置校验 给合同上传加上「仅支持 .doc / .docx / .pdf,且不超过 5M」的拦截:不合格时给出提示并阻止请求发出。完成后回答:这个判断放在 before-upload 和放在 http-request 回调开头,行为上有何差别?哪个更合适?
练习 2|完整的关闭重置 实现「关闭弹框 → 再次打开时表单完全干净」:楼宇为空、日期为空、上传区域无文件名、无校验红字。写完后连续操作三轮(成功提交一次、中途取消一次、上传后又关闭一次)验证是否都干净。
练习 3|下拉数据源改造 把当前「复用列表接口 + 大 pageSize」的写法换成后端提供的专用下拉接口,对比两次请求的参数和返回字段差异。再思考:如果楼宇数量涨到 500 条,一次性全量返回下拉是否还合适?给出你的替代方案。
练习 4|展开行的请求时机 合同列表挂在展开行里。请实现:只在展开时请求数据;同一行反复展开/收起不重复请求;但要保证用户点「续租」「删除」后重新展开能拿到最新数据。写出你的缓存与失效策略。
练习 5|参数转换函数 把提交前的参数处理抽成一个纯函数 buildRentParams(rentForm),负责时间拆分与字段挑选。要求:不依赖组件实例、可直接用几组输入输出验证。写出至少 3 组测试用例(含 rentTime 为空、含多余字段的情况)。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 新增合同-打开和关闭弹框 | 08:38 |
| 2 | 新增合同-准备表单结构 | 06:06 |
| 3 | 新增合同-准备楼宇下拉框数据 | 11:46 |
| 4 | 新增合同-不同接口下的下拉列表实现 | 06:49 |
| 5 | 新增合同-实现合同的核心上传逻辑 | 14:46 |
| 6 | 补充-上传也是一种表单交互的形式 | 03:51 |
| 7 | 新增合同-完成核心添加业务逻辑 | 12:29 |
| 8 | 新增合同-完成新增成功之后的后续逻辑处理 | 03:39 |
小结
- 弹框、展开行这类「容器型」交互,先打通开关和触发时机,再往里填内容;一次性写完整个弹框,出问题时定位成本会成倍上升。
- 凡是「点确定才提交」的数据,最终都必须落在表单对象上。上传只是取值方式不同,不是特例——想清楚这一点,就不会出现「上传成功但提交没数据」。
- 表单结构服务于交互,接口参数服务于后端,两者之间要有一层显式的转换代码。省掉这层转换,时间字段、字段命名这类问题会在联调时集中爆发。
- 复用接口前先看它的语义:分页列表接口不是下拉数据源。凑合能用可以,但要在代码里留下痕迹,否则下一个接手的人会重新踩一遍。
- 校验不生效时按固定顺序排查:
prop名 →rules字段名 →v-model字段名 → 该控件是否原生表单控件。非原生控件必须手动触发校验。 - 关闭弹框永远是两件事:关视图 + 清数据。只做前一件,第二次打开必然是脏的。
- 抽取或移动公共代码后,必须回测所有引用方。模块导入导出类报错几乎都来自这类改动,看到就查路径,别查逻辑。
- 实现顺序上,先让接口调通(核心链路),再补校验、样式、重置这些附加逻辑。核心链路不通时,再精致的校验也没有意义。