主题
新增合同与合同列表(二)
本页对应 新增合同与合同列表,共 8 段视频,约 1 小时 26 分。
学习目标
- 能在 el-table 中配置
type="expand"展开列,并在展开插槽内嵌套第二个 el-table,使用默认插槽(而非旧版 slot-scope)语法渲染企业下属的合同列表。 - 能为 el-table 绑定
expand-change事件,准确说明其两个回调参数(当前行对象、当前展开行数组)的含义,并据此判断展开/收起状态,实现仅在展开时请求接口的网络层优化。 - 能用
map在获取企业列表后为每条数据初始化独立的合同列表存放字段,并在接口返回后按行存入对应数据,保证多个展开行数据互不覆盖。 - 能说明 Vue2 中对象动态新增属性、数组下标赋值不触发视图更新的原因(
Object.defineProperty限制),并能使用$set/Vue.set将动态属性变为响应式。 - 能用 formatter 或“作用域插槽 + 插值表达式调用函数”两种方式把状态码格式化为中文,并在插槽内用 el-tag 渲染带标签的状态模板。
- 能根据当前行合同状态码动态设置退租、删除按钮的
disabled属性,并在调用退租接口成功后使按钮状态随数据变化自动切换。
知识点
先明确:封装接口解决什么问题
为什么需要:退租不是页面里随便写一个 axios 就能长期维护的事。组件里如果直接散落 URL、请求方法、参数拼接,后面接口路径一改、请求头一统一、错误处理一加,就要到处翻代码。把“退租”这个业务动作封装成 outRentAPI,组件只关心“传哪个合同 id”,不关心底层怎么发请求。
怎么想:退租的本质是“对某个合同资源做状态变更”。合同是资源,退租是对该资源的一次更新操作,所以请求方法用 PUT;路径里要能定位到具体合同,所以把 rentId 拼进 URL。
怎么做:在 API 层导出函数,函数内部调用项目统一的 request。本节笔记代码如下:
javascript
/**
* 退租
* @param {合同id} rentId
* @returns
*/
export function outRentAPI(rentId) {
return request({
url: `/park/enterprise/rent/${rentId}`,
method: 'PUT'
})
}易错点:这里的参数是“合同 id”,不是企业 id。转写稿里调试退租时,点击按钮传的是当前行的合同 id,而不是企业列表里的企业 id;如果把两者混用,接口路径就会指向错误资源。另外 URL 必须用反引号做模板拼接,method 写成 'PUT',不要写成小写或漏掉引号。
从业务动作推导请求方法与路径
为什么需要:封装接口不能靠猜。退租按钮点下去后,后端要求“传当前合同 ID”,并把当前合同变成已退租状态;这决定了接口函数必须接收一个 id 参数。
怎么想:先看接口文档或已有约定:退租接口要求传合同 ID,路径形如 /park/enterprise/rent/{rentId},方法是 PUT。因此函数名用 outRentAPI,参数名用 rentId,语义清楚:这是“退租 API”,不是“删除 API”。
怎么做:保持笔记里的结构,不在 API 函数里写页面提示、不写按钮禁用逻辑。页面状态适配属于组件层,接口层只负责发请求并返回结果。
易错点:不要把“退租”和“删除”混成一个接口。转写稿里按钮状态适配提到:生效中时退租可用、删除禁用;已退租后删除可用、退租禁用。接口封装只负责退租请求,按钮是否禁用由组件根据状态码判断,例如已退租状态码为 3 时禁用退租按钮。
在组件中调用封装接口
为什么需要:封装好的函数只有被组件调用才有意义。组件要拿到“当前行”的合同 id,点击退租时把它传给 outRentAPI。
怎么想:表格里的操作按钮通常通过作用域插槽拿到当前行数据,转写稿里写作 scope.row。所以点击事件里应取 scope.row.id 作为合同 id,再调用封装函数。
怎么做:在组件中先导入 outRentAPI,再写一个处理方法,例如接收 id 后调用接口;模板里的退租按钮绑定点击事件,把当前行 id 传进去。调用关系可以概括为:按钮点击 → 组件方法 → outRentAPI(rentId) → request 发 PUT 请求。
易错点:转写稿调试时出现过接口超时,提示“后端服务没有及时返回”,这类问题不要先怀疑封装函数写错,要先看 Network 里请求是否发出、URL 是否正确、后端是否启动。还有一点:按钮的 disabled 是布尔属性,传 true 表示禁用;后面如果是表达式,模板里要按项目语法正确绑定,不能漏掉冒号等细节。
调试过程与封装边界
为什么需要:封装接口后,调试要能区分“请求没发”“URL 错”“参数错”“后端没返回”。转写稿里通过 Network 观察请求,确认点击不同行会带不同合同 id,这说明参数传递方向是对的。
怎么想:先看请求有没有发出,再看请求 URL 里的 id 是否是当前行合同 id,最后看返回是否成功。退租成功后刷新列表,状态会从“生效中”变为“已退租”,这说明接口调用和状态刷新形成了闭环。
怎么做:保持 API 层薄,组件层负责交互和状态展示。若接口返回成功,再按业务需要刷新列表或更新当前行状态;按钮禁用逻辑根据状态码单独适配。
易错点:不要为了“看起来能跑”在 API 函数里硬编码 id;不要把 rentId 写成 enterpriseId;不要忽略 request 的返回结果。转写稿里还提到 ESLint 可能对某些写法报警,如果明确代码逻辑没问题,可按项目规范局部处理校验,但不要随意全局关闭规则。
操作列与退租入口
为什么需要:合同列表最终要落到“能操作”。退租不是单纯改前端显示,而是要通过接口把当前合同状态从“生效中”改成“已退租”,所以操作列必须先有明确入口。
怎么想:操作列通常放在表格最后一列,并固定右侧,避免横向滚动时找不到按钮。退租按钮需要拿到当前行合同 ID,因此用作用域插槽 #default="scope" 取 scope.row.id。
怎么做:在 el-table-column 中放置退租、删除按钮,退租按钮点击时把当前行 ID 传给 outRent。
vue
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button size="mini" type="text" @click="outRent(scope.row.id)">退租</el-button>
<el-button size="mini" type="text" >删除</el-button>
</template>
</el-table-column>易错点:不要写成 @click="outRent(scope.row)" 又混用 ID;本笔记明确传的是 scope.row.id。按钮文案和后续提示要对应,避免“退租”操作却提示“删除”。
退租确认与接口调用
为什么需要:退租会改变业务状态,属于不可轻易撤销的操作,必须先让用户确认,防止误点。
怎么想:用 this.$confirm 弹出确认框,确认后再调用接口。$confirm 返回 Promise,所以用 .then() 接收确认,用 .catch() 接收取消;接口调用是异步的,.then 回调写成 async,内部 await outRentAPI(rentId)。
怎么做:确认框配置确定/取消按钮和 warning 类型,确认后先调退租接口,再刷新列表并提示成功。
js
outRent(rentId) {
this.$confirm('确认退租吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async() => {
// 1. 调用接口
await outRentAPI(rentId)
// 2. 重新拉取列表
this.getEnterpriseListAPI()
this.$message({
type: 'success',
message: '退租成功!'
})
}).catch(() => {
this.$message({
type: 'info',
message: '已取消删除'
})
})
}易错点:await 必须放在 async 函数里;接口失败时要有失败处理思路,不能只写成功提示。取消提示原代码写“已取消删除”,语义上应改为“已取消退租”更准确。
刷新列表与状态同步
为什么需要:退租接口只改后端数据,前端表格不会自动变化;不刷新会出现“接口成功但页面还是生效中”的假象。
怎么想:接口成功后重新拉取企业列表,让当前行状态从“生效中”变为“已退租”。转写稿调试时也是点退租、看接口返回成功、刷新列表后确认状态变化。
怎么做:在 await outRentAPI(rentId) 之后调用 this.getEnterpriseListAPI(),再给成功消息。
易错点:不要先提示成功再调接口,否则接口失败也会提示成功;刷新列表要调用列表接口,不是只改本地字段。
按状态适配按钮显示
为什么需要:退租后“退租”按钮不应还能点,“删除”按钮则应在已退租后可用。按钮状态要跟业务状态一致。
怎么想:根据当前行状态码判断。转写稿中已退租状态码为 3,所以退租按钮在 stators === 3 时禁用;删除按钮则相反。el-button 的 disabled 接收布尔值,绑定表达式前要加冒号。
怎么做:给按钮补 :disabled,字段名以接口实际返回为准。
vue
<el-button
size="mini"
type="text"
:disabled="scope.row.stators === 3"
@click="outRent(scope.row.id)"
>退租</el-button>
<el-button
size="mini"
type="text"
:disabled="scope.row.stators !== 3"
>删除</el-button>易错点:disabled="scope.row.stators === 3" 不加冒号会当字符串,判断不生效;状态字段名不要凭感觉写,按接口文档或调试结果确认。
调试与文案细节
为什么需要:这类功能涉及确认框、异步接口、列表刷新和按钮禁用,单看代码容易漏。
怎么想:先测点击退租是否弹确认,再测接口是否传当前合同 ID,最后看刷新后状态和按钮是否变化。转写稿中通过 Network 观察请求,确认传的是当前合同 ID。
怎么做:点第一行退租,看请求参数和返回;刷新列表看状态;再检查按钮禁用是否符合状态。
易错点:取消分支文案要与操作一致;接口超时或后端异常时不要误判为前端逻辑错误;状态码判断要覆盖 0、1、2、3 等实际枚举。
先建立状态码到语义的映射
为什么需要: 后端返回的合同状态往往是 0/1/2/3 这类状态码,直接渲染不可读,按钮是否禁用也不能靠猜。转写稿中接口文档给出的含义是:0 待生效、1 生效中、2 已到期、3 已退租。只有先把状态码翻译成稳定语义,视图层才好统一判断。
怎么想: 不要在每个按钮、每个标签里各写一套 if,而是先做一张映射表。状态码是“数据”,中文文案和按钮状态是“视图”,适配视图状态的核心就是让视图由同一份数据状态驱动。
怎么做: 可以用对象映射,例如 { 0: '待生效', 1: '生效中', 2: '已到期', 3: '已退租' },再通过 status 取中文。格式化函数接收当前行的 status,返回对应文案。
易错点: 0 是有效状态,不能用 if (status) 判断;状态码比较建议用 ===,避免字符串和数字混用;不要把“已到期”和“已退租”混为一谈,二者对应不同操作。
需要标签样式时,用插槽而不是只靠 formatter
为什么需要: 纯文本状态不够直观,线上效果常用 el-tag 标出状态。转写稿里尝试过 formatter,但 formatter 适合返回字符串,不能直接返回一段标签模板。
怎么想: 只要格式化结果需要“模板结构”,就应想到列组件的默认插槽。插值表达式支持函数调用,渲染的是函数执行后的返回值,因此可以在插槽里调用格式化函数,再把返回值放进 el-tag。
怎么做: 在列内部写 template #default="scope",里面放 el-tag,标签内容写 formatStatus(scope.row.status)。这样既保留状态码到中文的转换,又能得到标签外观。
易错点: 插槽必须写在列组件标签内部,写到外面就不生效;格式化函数要返回文案,不要返回 undefined;不要指望 formatter 返回 <el-tag> 字符串还能被当成组件渲染。
操作按钮按状态适配
为什么需要: 退租和删除是互斥操作:已退租的合同不应再点退租,未退租的合同不应直接删除。否则用户会执行无意义甚至危险的操作。
怎么想: 转写稿中的判断很明确:状态 3 表示已退租。已退租时禁用“退租”,非已退租时禁用“删除”。按钮的 disabled 接收布尔值,所以应把判断表达式绑定给 disabled。
怎么做: 本节笔记中的操作列代码如下,保留原样:
vue
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button size="mini" type="text" :disabled="scope.row.status === 3" @click="outRent(scope.row.id)">退租</el-button>
<el-button size="mini" type="text" :disabled="scope.row.status !== 3">删除</el-button>
</template>
</el-table-column>这里 :disabled="scope.row.status === 3" 表示已退租时退租按钮禁用;:disabled="scope.row.status !== 3" 表示未退租时删除按钮禁用。注意 : 是 v-bind,后面才是表达式。
易错点: 漏写冒号会把表达式当普通字符串,禁用逻辑失效;disabled 要布尔值,不要传 "true" 这类字符串;笔记中删除按钮未绑定点击逻辑,应按原样保留,不要擅自补业务;若后端返回字符串状态码,要先统一类型再比较。
退租后的状态切换要落到数据源
为什么需要: 点击退租调用接口后,合同状态会从“生效中”变成“已退租”。如果只改按钮显示、不改数据源,刷新或再次判断时就会错。转写稿调试时也是先调退租接口,接口成功后刷新列表,看到该行变为已退租,随后按钮显示随之变化。
怎么想: 接口成功只是第一步,必须让当前行数据里的 status 变成 3,或者重新请求列表。视图的 disabled 是根据 status 计算的,数据变了,视图才会正确重算。
怎么做: 退租方法接收当前行 id,调用退租接口;成功后重新拉取列表,或把当前行 status 更新为 3。更新后,操作列会按新状态重新计算两个按钮的禁用状态。
易错点: 只改文案不改 status,按钮不会按预期变化;Vue2 中对对象动态新增属性不是响应式的,若需要新增字段,应提前初始化或用 $set;不要直接改数组下标来期望视图更新。
展开行列表要一行一份,并只在展开时请求
为什么需要: 每个企业展开后都有自己的合同列表。如果共用一个数组,展开第二个企业时会把第一个覆盖。转写稿中通过打印展开事件参数发现:第一个参数是当前行对象,第二个参数是当前所有展开行组成的数组。
怎么想: 一行企业对应一份合同列表,不能多行共用一个;请求时机应只在“展开”时触发,收起时不必再请求。判断是否展开,不能只看第二个参数长度是否大于 0,因为展开第二行再收起第一行时,数组长度仍可能大于 0。
怎么做: 初始化企业列表时,给每一行准备一个专门存合同列表的字段;给表格绑定展开行事件,拿到 row 和 expandedRows,判断 row 是否在 expandedRows 中,存在才请求当前企业的合同列表,并把结果存回该行自己的列表字段。
易错点: 用 expandedRows.length > 0 判断会误判;展开和收起都发请求会增加无谓请求;Vue2 中动态添加字段可能不是响应式的,先初始化字段或用 $set;每个企业的列表必须互相独立。
这一节要解决什么问题
上一阶段完成“新增合同”后,下一步要能查看企业详情和它下面的租赁记录。这个查看动作不能继续挤在列表页里完成,否则页面职责会越来越重:列表负责筛选、分页、操作,详情又负责展示企业信息和合同记录,后续维护会很难受。所以这一节先做“准备路由”:给详情页一个独立路径,把路径映射到详情组件,再把详情页的静态骨架搭起来。
转写稿里这一段的思路也是“先准备静态结构”,先看页面能不能出来,再考虑展开行、接口和渲染。路由准备同样如此:先把入口打通,再补交互。不要一上来就写请求和格式化,否则页面没挂载、路径没通,调试时很难判断问题出在路由、组件还是接口。
路由配置:路径与组件的映射
先看笔记里的路由配置:
javascript
{
path: '/exterpriseDetail',
component: () => import('@/views/park/enterprise/Detail')
}为什么需要这样写?因为详情页是一个独立视图,需要通过 URL 访问,也需要从列表页跳转过来。怎么想?先确定“访问哪个路径”和“渲染哪个组件”这两件事。怎么做?path 写详情页路径,component 用动态 import 指向 Detail 组件。
这里的 component: () => import(...) 是路由懒加载:只有访问该路由时才加载详情组件,而不是首屏一次性加载所有页面。对于后台项目,页面多、体积大,这种写法很常见。
易错点有三个:第一,@ 别名要在项目里配置好,否则路径解析失败;第二,组件路径大小写要和真实文件一致,Detail 不是 detail;第三,path 的拼写要和跳转处保持一致。笔记里是 /exterpriseDetail,实际项目里如果已有命名规范,就按项目现有写法来,关键是“配置处”和“跳转处”不能一个写 exterprise、一个写 enterprise,否则会出现点了没反应或 404。
页面骨架:先静态后交互
路由通了以后,详情组件先准备静态结构。笔记中的模板如下,重点看页面头部、返回按钮、表格容器和列定义:
typescript
<template>
<div class="detail-enterprise">
<header class="add-header">
<el-page-header content="查看企业" @back="$router.back()" />
</header>
<main class="add-main">
<div class="form-container">
<div class="title">租赁记录</div>
<div class="table-container">
<el-table
:data="[]"
style="width: 100%"
border
>
<el-table-column
prop="index"
label="序号"
width="50"
/>
<el-table-column
prop="name"
label="租赁楼宇"
width="180"
/>
<el-table-column
label="租赁起止时间"
width="280"
>
<template #default="{row}">
{{ row.startTime }} - {{ row.endTime }}
</template>
</el-table-column>
<el-table-column
label="租赁合同(点击预览)"
>
<template #default="{row}">
<el-button type="text">
{{ row.contractName }}
</el-button>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="录入时间"
/>
<el-table-column
prop="address"
label="操作"
>
<template #default="{row}">
<el-button type="text">合同下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</main>
</div>
</template>
<script>
export default {
data() {
return {
}
}
}
</script>
<style scoped lang="scss">
.detail-enterprise {
background-color: #f4f6f8;
height: 100vh;
.add-header {
display: flex;
align-items: center;
padding: 0 20px;
height: 64px;
background-color: #fff;
.left {
span {
margin-right: 4px;
}
.arrow{
cursor: pointer;
}
}
.right {
text-align: right;
}
}
.add-main {
background: #f4f6f8;
padding: 20px 130px;
.form-container {
background-color: #fff;
margin-bottom: 20px;
.title {
height: 60px;
line-height: 60px;
padding-left: 20px;
}
}
.table-container{
padding:20px;
}
.preview{
img{
width: 100px;
}
}
}
}
</style>为什么先写静态结构?因为详情页最终要展示租赁记录,表格列、时间区间、合同名称、录入时间、操作列这些信息要先定下来。转写稿里也强调先看文档、先放静态结构、看页面变化,再逐步替换成真正的内嵌表格和接口数据。
怎么想?页面头部用 el-page-header 显示“查看企业”,@back="$router.back()" 负责返回上一页;主体区域先放标题和表格,表格数据先用 :data="[]" 占位。怎么做?列定义先按字段写好:prop 用于直接渲染,起止时间用默认插槽拼接,合同名称和操作按钮也用插槽。易错点是插槽写法 #default="{row}" 不要写错;prop 的字段名要和后续接口字段对应;样式用了 scoped lang="scss",如果项目没装 sass,会编译报错。
调试与验证顺序
准备完路由和组件后,验证顺序建议是:先直接访问 /exterpriseDetail,确认组件能渲染;再从列表页触发跳转,确认路径一致;最后点返回,确认 $router.back() 正常。转写稿里调试静态结构时也是“先写过来看看变化”,出现报错先临时处理,再逐步替换成最终结构。这里同样不要急着接接口:路由没通、页面没挂载时,先查路由配置;页面能出来但表格不显示,再查列定义和插槽。这样排查路径清晰,不会被接口问题干扰。
本节要解决的问题
详情页要展示企业下的租赁记录。数据不能写死在页面里,否则企业一换、合同一增删,页面就失真。因此思路是:先封装“获取企业详情”接口,再在详情组件挂载时请求数据,最后把返回的 form.rent 交给 el-table 渲染。转写稿里反复强调的一点是:列表数据要先想清楚“存在哪里、一行对应一份还是所有行共用一份”,否则后面展开、请求、格式化都会乱。
先封装详情接口
为什么需要:页面只负责展示,请求细节应放到 API 层,便于复用和维护。怎么想:详情接口通常按企业 id 查询,返回企业信息及租赁记录。怎么做:在接口文件里导出 getEnterpriseDetail,组件里只传当前路由的 id。易错点:id 不要写死,笔记里用的是 this.$route.query.id;接口返回后要取 res.data 再赋给 form。
javascript
/**
* 获取详情
* @param {企业id} id
* @returns
*/
export function getEnterpriseDetail(id) {
return request({
url: `/park/enterprise/${id}`,
method: 'GET'
})
}进入页面后取详情数据
为什么需要:表格的 :data 必须来自接口,不能直接写空数组后忘记赋值。怎么想:mounted 是详情页发起请求的合适时机;form 先初始化为空对象,等接口回来再整体替换。怎么做:getDetail 里 await getEnterpriseDetail(...),然后 this.form = res.data。易错点:异步请求未回来时 form.rent 可能不存在,表格会显示空数据,这是正常过程;不要一开始把 form 写成字符串或数组,类型要稳定。
用 el-table 渲染租赁记录
为什么需要:租赁记录包含序号、楼宇、起止时间、合同预览、录入时间、操作等列,普通文本无法表达“时间拼接”和“按钮预览”。怎么想:能直接显示的列用 prop;需要拼接或放按钮的列用自定义插槽。怎么做:外层 :data="form.rent",起止时间用 #default="{row}" 拼 startTime - endTime,合同名称用 el-button type="text" 展示,操作列放“合同下载”。易错点:插槽要写在 el-table-column 内部;解构出来的变量名要和模板里一致;prop="index" 依赖后端返回序号字段,若后端没有该字段,需要另行处理,不能想当然。
vue
<template>
<div class="detail-enterprise">
<header class="add-header">
<el-page-header content="查看企业" @back="$router.back()" />
</header>
<main class="add-main">
<div class="form-container">
<div class="title">租赁记录</div>
<div class="table-container">
<el-table
:data="form.rent"
style="width: 100%"
border
>
<el-table-column
prop="index"
label="序号"
width="50"
/>
<el-table-column
prop="name"
label="租赁楼宇"
width="180"
/>
<el-table-column
label="租赁起止时间"
width="280"
>
<template #default="{row}">
{{ row.startTime }} - {{ row.endTime }}
</template>
</el-table-column>
<el-table-column
label="租赁合同(点击预览)"
>
<template #default="{row}">
<el-button type="text">
{{ row.contractName }}
</el-button>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="录入时间"
/>
<el-table-column
prop="address"
label="操作"
>
<template #default="{row}">
<el-button type="text">合同下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</main>
</div>
</template>
<script>
import { getEnterpriseDetail } from '@/apis/enterprise'
export default {
data() {
return {
form: {}
}
},
mounted() {
this.getDetail()
},
methods: {
async getDetail() {
const res = await getEnterpriseDetail(this.$route.query.id)
this.form = res.data
}
}
}
</script>展开行与内嵌合同列表的推导
转写稿里做的是同一类“列表里再放列表”的问题:用 el-table 的展开行,加一列 type="expand",在插槽里再放一个内嵌 el-table。关键不是死记模板,而是先找“什么时候展开”:给表格绑定 @expand-change,回调第一个参数 row 是当前行对象,第二个参数 rows 是当前展开行对象数组。易错点:事件名不要写成 @expand-change:;不要用一份数组存所有企业的合同列表,因为可以同时展开多行,会互相覆盖;每行要有自己的 rentList。Vue2 里动态新增属性不是响应式的,转写稿用 map 初始化每行字段,或使用 this.$set(row, 'rentList', data)。展开时才请求可用 rows.find(...) 或 findIndex 判断当前行是否在展开数组中,避免收起时也发请求。
状态格式化与插槽模板
转写稿还补了状态显示:formatter 适合把状态码转成纯文本,但它只能返回字符串;如果要在状态外再包 el-tag,就要用插槽加差值表达式调用格式化函数。差值表达式支持函数调用,渲染的是函数返回值。易错点:formatter 不需要手动传参,组件会自动传当前行;插槽要包在列组件内部;按钮的 disabled 要根据状态码判断,表达式要写在引号里,不要漏掉绑定语法。
收尾检查
检查三件事:接口 id 是否来自当前路由;表格 :data 是否绑定到接口返回的列表字段;每行需要独立保存的数据是否提前初始化成响应式字段。这样“渲染列表”才是可维护的,而不是只把静态结构摆出来。
需求背景:合同名称要能点开预览
合同列表里已经有合同名称、合同文件地址等字段。此时如果合同名称只是普通文本,用户看到名称后还要自己复制地址、再找预览入口,体验很差。本节要做的就是把合同名称变成可点击的预览入口:点击后在新窗口打开该合同的在线预览地址。
笔记里给出的关键结论是:
预览地址拼接上合同文件链接地址,在新窗口打开。
也就是说,预览服务地址是前缀,合同文件地址是参数,二者拼成一个完整 URL 后再交给浏览器打开。

怎么想:前缀地址 + 文件地址
这里不要一上来就写点击事件,而要先看数据。当前行 row 里已经有:
row.contractName:展示给用户看的合同名称;row.contractUrl:合同文件链接地址。
预览服务需要一个地址,形如:
text
https://view.officeapps.live.com/op/view.aspx?src=合同文件地址所以 previewURL 只保存前半段:
js
previewURL: 'https://view.officeapps.live.com/op/view.aspx?src='模板中再把 row.contractUrl 拼到后面。这样每个合同行都用同一段预览前缀,只是后面的文件地址不同。
转写稿里做合同列表时反复强调一个排查顺序:先确认当前行数据里有什么字段,再确认模板绑定的是不是当前行,最后验证点击/展开后的结果。预览功能也按这个顺序看:先确认 contractUrl 存在,再确认拼接顺序,最后看新窗口打开的地址是否正确。
怎么做:用 a 标签承载跳转
笔记中的实现如下:
vue
<el-button type="text">
<a :href="`${previewURL}${row.contractUrl}`" target="_blank">
{{ row.contractName }}
</a>
</el-button>
data() {
return {
previewURL: 'https://view.officeapps.live.com/op/view.aspx?src=' // 预览地址
}
}这段代码的分工很明确:
- 外层
el-button type="text"负责视觉样式,让合同名称看起来像一个文本按钮; - 内层
<a>才是真正的链接,href绑定拼接后的预览地址; target="_blank"让浏览器在新窗口/新标签页打开;- 标签内容使用
{{ row.contractName }},显示当前行的合同名称; previewURL放在data()的返回对象中,作为固定前缀。
拼接表达式是:
js
`${previewURL}${row.contractUrl}`注意顺序不能反:previewURL 在前,row.contractUrl 在后。因为 previewURL 已经包含 ?src=,后面直接接文件地址即可。
易错点:链接、字段和样式要分清
第一,真正负责跳转的是 <a>,不是外层 el-button。如果只给按钮写样式、忘记给 a 写 href,就不会有预览跳转。
第二,target="_blank" 要写在 a 标签上。它控制的是链接打开方式,写在按钮上并不能替代链接行为。
第三,拼接时不要漏掉 previewURL 末尾的 ?src=。笔记中的值已经带上了等号,模板里直接接 row.contractUrl,不要再把合同名称拼进去。
第四,row.contractUrl 必须来自当前行。合同列表通常是多行数据,模板里如果错写成别的变量,就会出现“点第一行打开第二行文件”的问题。
第五,previewURL 要放在当前组件的 data() 中,不要放到无关位置;否则模板里访问不到。
和合同列表上下文的关系
转写稿主要展开讲了合同列表的内嵌表格、展开请求、状态格式化和按钮适配。虽然本节笔记只聚焦预览链接,但它依赖的仍是同一个合同列表行数据:每一行有自己的合同名称和合同文件地址,预览链接也应该跟着当前行走。
因此实现时不要把预览地址写死成某一个合同,而是保留 previewURL 作为公共前缀,把变化的 row.contractUrl 留在模板拼接处。这样列表里每一行都能生成自己的预览地址,点击哪个合同名称,就打开哪个合同文件。
代码示例
1. 企业列表页:展开行内嵌合同列表 + 退租 + 按钮状态适配
vue
<!-- views/park/enterprise/index.vue -->
<template>
<div class="enterprise">
<el-table
:data="list"
border
style="width: 100%"
@expand-change="handleExpandChange"
>
<!-- 关键:加一列 type="expand" 即可开启展开行 -->
<el-table-column type="expand">
<template #default="{ row }">
<el-table :data="row.rentList" border style="width: 100%">
<el-table-column prop="name" label="租赁楼宇" width="180" />
<el-table-column label="租赁起止时间" width="280">
<template #default="{ row: rentRow }">
{{ rentRow.startTime }} - {{ rentRow.endTime }}
</template>
</el-table-column>
<el-table-column label="合同状态" width="120">
<template #default="{ row: rentRow }">
<el-tag>{{ statusMap[rentRow.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row: rentRow }">
<el-button
size="mini"
type="text"
:disabled="rentRow.status === 3"
@click="outRent(rentRow.id)"
>退租</el-button>
<el-button
size="mini"
type="text"
:disabled="rentRow.status !== 3"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<el-table-column prop="name" label="企业名称" width="200" />
<el-table-column prop="contact" label="联系人" width="120" />
<el-table-column prop="contactNumber" label="联系电话" width="160" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="mini" type="text" @click="goDetail(row.id)">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import {
getEnterpriseListAPI,
getContractListAPI,
outRentAPI
} from '@/apis/enterprise'
export default {
data() {
return {
list: [],
statusMap: {
0: '待生效',
1: '生效中',
2: '已到期',
3: '已退租'
}
}
},
mounted() {
this.getEnterpriseList()
},
methods: {
async getEnterpriseList() {
const res = await getEnterpriseListAPI({ page: 1, pageSize: 10 })
// 给每一行企业对象预留存放合同列表的位置,保证后续赋值是响应式的
this.list = res.data.rows.map(item => ({ ...item, rentList: [] }))
},
// row:当前点击的那一行;rows:当前所有处于展开状态的行对象数组
async handleExpandChange(row, rows) {
// 只有“展开”时才请求,收起不请求
const isExpand = rows.find(item => item.id === row.id)
if (!isExpand) return
const res = await getContractListAPI(row.id)
// eslint-disable-next-line no-param-reassign
row.rentList = res.data
},
outRent(rentId) {
this.$confirm('确认退租吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
// 1. 调用接口
await outRentAPI(rentId)
// 2. 重新拉取列表
this.getEnterpriseList()
this.$message({
type: 'success',
message: '退租成功!'
})
}).catch(() => {
this.$message({
type: 'info',
message: '已取消删除'
})
})
},
goDetail(id) {
this.$router.push({ path: '/exterpriseDetail', query: { id } })
}
}
}
</script>
<style scoped lang="scss">
.enterprise {
padding: 20px;
background-color: #f4f6f8;
height: 100vh;
}
</style>关键行与易错点
type="expand"是开启展开行的唯一开关,内部再放一个el-table就是内嵌表格;内层插槽变量改名为row: rentRow,避免和外层{ row }同名造成可读性混乱。expand-change的两个参数含义不同:第一个row是当前点击的行对象,第二个rows是当前所有处于展开状态的行对象数组。判断“是否展开”要写成rows.find(item => item.id === row.id),不能简单判断rows.length > 0——因为可以先展开 A 再展开 B,此时收起 A,rows长度仍为 1,会误判。list赋值时必须用map给每一行预留rentList: []。Vue2 底层是Object.defineProperty,动态新增属性不是响应式的,直接写row.rentList = res.data而此前没有该字段,视图不会更新。row.rentList = res.data会触发 ESLint 的no-param-reassign警告,确认逻辑无误时用行注释关掉即可。- 按钮禁用写
:disabled="rentRow.status === 3",后面的表达式必须带冒号,写成disabled="..."只是普通字符串属性,不生效。 $confirm的.catch既会捕获“取消”,也会吞掉then里的接口报错,实际项目里最好区分处理。
2. 企业详情页:渲染租赁记录 + 合同在线预览
vue
<!-- views/park/enterprise/Detail.vue -->
<template>
<div class="detail-enterprise">
<header class="add-header">
<el-page-header content="查看企业" @back="$router.back()" />
</header>
<main class="add-main">
<div class="form-container">
<div class="title">租赁记录</div>
<div class="table-container">
<el-table :data="form.rent" style="width: 100%" border>
<el-table-column prop="index" label="序号" width="50" />
<el-table-column prop="name" label="租赁楼宇" width="180" />
<el-table-column label="租赁起止时间" width="280">
<template #default="{ row }">
{{ row.startTime }} - {{ row.endTime }}
</template>
</el-table-column>
<el-table-column label="租赁合同(点击预览)">
<template #default="{ row }">
<el-button type="text">
<a :href="`${previewURL}${row.contractUrl}`" target="_blank">
{{ row.contractName }}
</a>
</el-button>
</template>
</el-table-column>
<el-table-column prop="createTime" label="录入时间" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="text">合同下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</main>
</div>
</template>
<script>
import { getEnterpriseDetail } from '@/apis/enterprise'
export default {
data() {
return {
form: {},
// 预览地址前缀,直接拼上合同文件链接在新窗口打开
previewURL: 'https://view.officeapps.live.com/op/view.aspx?src='
}
},
mounted() {
this.getDetail()
},
methods: {
async getDetail() {
const res = await getEnterpriseDetail(this.$route.query.id)
this.form = res.data
}
}
}
</script>
<style scoped lang="scss">
.detail-enterprise {
background-color: #f4f6f8;
height: 100vh;
.add-header {
display: flex;
align-items: center;
padding: 0 20px;
height: 64px;
background-color: #fff;
}
.add-main {
background: #f4f6f8;
padding: 20px 130px;
.form-container {
background-color: #fff;
margin-bottom: 20px;
.title {
height: 60px;
line-height: 60px;
padding-left: 20px;
}
}
.table-container {
padding: 20px;
}
}
}
</style>关键行与易错点
:data="form.rent"在接口返回前form是{},此时form.rent为undefined,el-table 会按空数组处理;若担心告警可先声明form: { rent: [] }。- 详情 id 来自路由
query,所以列表页跳转必须写this.$router.push({ path: '/exterpriseDetail', query: { id } }),路由表里对应path: '/exterpriseDetail'。 - 预览是把合同链接拼到
previewURL后面,用<a target="_blank">新窗口打开;注意href用的是模板字符串`${previewURL}${row.contractUrl}`,不要漏掉contractUrl前面的反引号写法。 - 笔记中“操作”列残留了
prop="address",这是从别的 demo 复制过来的,操作列没有对应字段,应去掉prop。 el-button里包<a>是可点击的,但按钮自身的点击事件不会触发跳转,别在这里写@click。
3. 补充:Vue2 动态添加响应式属性($set)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>动态添加响应式属性</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.js"></script>
</head>
<body>
<div id="app">
<p>form.name:{{ form.name }}</p>
<p>form.age:{{ form.age }}</p>
<button @click="changeName">修改已存在的 name</button>
<button @click="addAge">动态添加 age(点语法)</button>
<button @click="addAgeBySet">动态添加 age($set)</button>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
form: { name: '柴鹏' } // age 一开始并不存在
}
},
methods: {
changeName() {
// 已有属性,是响应式的,视图会更新
this.form.name = '乔阳'
},
addAge() {
// 动态新增属性,Vue2 监听不到,视图不更新
this.form.age = 20
},
addAgeBySet() {
// 等价于 Vue.set(this.form, 'age', 20)
this.$set(this.form, 'age', 20)
}
}
})
</script>
</body>
</html>关键行与易错点
- Vue2 响应式底层是
Object.defineProperty,它无法监听对象的属性动态新增,也无法监听数组通过下标直接赋值,所以this.form.age = 20页面上不会出现 20。 this.$set(target, key, value)三个参数:要操作的对象、属性名、属性值;它是全局方法Vue.set的别名,两者完全等价。- 对数组使用时,第二个参数变成下标值,第三个参数是新值,例如
this.$set(this.list, 0, 'new')。 - 易混点:用
v-model绑定一个原本不存在的属性时,视图不会随数据变化更新,但用户输入能被收集回数据——因为v-model编译后内部调用的就是$set,这也是“看起来双向绑定又好像不生效”的原因。 - 本项目里正是利用这一点,在
list赋值时用map
常见错误
退租后列表状态没变 / 按钮仍可点
现象:接口成功但页面仍旧;原因:只调接口未刷新列表,或按钮未按status禁用;修法:await outRentAPI后重新拉列表,退租按钮:disabled="scope.row.status === 3",删除按钮:disabled="scope.row.status !== 3"。展开多个企业后合同列表互相覆盖
现象:展开第二行,第一行数据变了;原因:所有行共用一份合同列表;修法:初始化时给每个企业行加独立rentList,展开时写入row.rentList。展开、收起都重复发请求
现象:点开请求、合上还请求;原因:expanded-change未判断当前是展开还是收起;修法:用当前row是否存在于rows中判断,仅展开时请求。动态加字段后页面不更新
现象:数据已赋值但视图不变;原因:Vue2 对对象动态新增属性、数组下标直接修改监听不到;修法:初始化时预占字段,或用this.$set / Vue.set。想给合同状态加
el-tag,但formatter不生效
现象:只能输出纯文本,标签渲染不出来;原因:formatter返回值按文本处理;修法:改用作用域插槽,在插槽内放el-tag,差值表达式调用格式化函数。合同预览链接打不开
现象:点击无反应或地址错误;原因:未拼接预览地址与contractUrl;修法:href拼成${previewURL}${row.contractUrl},并加target="_blank"。
练习
- 补全退租、删除按钮的禁用逻辑:状态
3表示已退租,要求退租按钮在已退租时禁用,删除按钮仅在已退租时可用。 - 实现“企业列表展开行”:要求每行有独立合同列表,展开时按当前企业
id请求,收起时不请求。 - 用作用域插槽 +
el-tag渲染合同状态,映射:0待生效、1生效中、2已到期、3已退租。 - 给企业详情页补全合同预览:路由带
id,mounted请求详情,合同名点击后新窗口预览。 - 用
map给企业列表每项初始化rentList: [],并说明为什么不能等展开时直接row.rentList = res.data。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 合同列表-准备内嵌的合同列表 | 03:50 |
| 2 | 合同列表-准备合同列表数据渲染合同列表 | 20:48 |
| 3 | 合同列表-添加判断展开时请求 | 09:08 |
| 4 | 补充-$set方法 | 13:02 |
| 5 | 合同列表-格式化显示状态 | 06:23 |
| 6 | 合同列表-通过插槽添加格式化tag模版 | 07:03 |
| 7 | 合同列表-根据不同状态适配操作按钮 | 08:28 |
| 8 | 重点内容总结 | 18:04 |
小结
- 操作类接口成功后要同步刷新列表,避免页面状态与后端不一致。
- 行级数据必须按行存储,不能多行共享同一份合同列表。
- Vue2 中动态新增响应式字段要预占位或使用
$set。 - 展开请求要做展开态判断,按需请求,避免无意义网络开销。
- 状态展示按需求选
prop、formatter或插槽;需要模板标签时优先用插槽。 - 操作按钮的可用性应由业务状态驱动,
disabled绑定明确表达式。 - 详情页依赖路由参数和详情接口,预览地址必须正确拼接。
- 框架能力受原生 JS 限制,对象新增属性、数组下标修改要格外警惕。