Skip to content

新增合同与合同列表(二) ​

本页对应 新增合同与合同列表,共 8 段视频,约 1 小时 26 分。

学习目标 ​

  1. 能在 el-table 中配置 type="expand" 展开列,并在展开插槽内嵌套第二个 el-table,使用默认插槽(而非旧版 slot-scope)语法渲染企业下属的合同列表。
  2. 能为 el-table 绑定 expand-change 事件,准确说明其两个回调参数(当前行对象、当前展开行数组)的含义,并据此判断展开/收起状态,实现仅在展开时请求接口的网络层优化。
  3. 能用 map 在获取企业列表后为每条数据初始化独立的合同列表存放字段,并在接口返回后按行存入对应数据,保证多个展开行数据互不覆盖。
  4. 能说明 Vue2 中对象动态新增属性、数组下标赋值不触发视图更新的原因(Object.defineProperty 限制),并能使用 $set / Vue.set 将动态属性变为响应式。
  5. 能用 formatter 或“作用域插槽 + 插值表达式调用函数”两种方式把状态码格式化为中文,并在插槽内用 el-tag 渲染带标签的状态模板。
  6. 能根据当前行合同状态码动态设置退租、删除按钮的 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 后再交给浏览器打开。

Office 在线预览地址拼接示意图

怎么想:前缀地址 + 文件地址 ​

这里不要一上来就写点击事件,而要先看数据。当前行 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=' // 预览地址
  }
}

这段代码的分工很明确:

  1. 外层 el-button type="text" 负责视觉样式,让合同名称看起来像一个文本按钮;
  2. 内层 <a> 才是真正的链接,href 绑定拼接后的预览地址;
  3. target="_blank" 让浏览器在新窗口/新标签页打开;
  4. 标签内容使用 &#123;&#123; row.contractName &#125;&#125;,显示当前行的合同名称;
  5. 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

常见错误 ​

  1. 退租后列表状态没变 / 按钮仍可点
    现象:接口成功但页面仍旧;原因:只调接口未刷新列表,或按钮未按 status 禁用;修法:await outRentAPI 后重新拉列表,退租按钮 :disabled="scope.row.status === 3",删除按钮 :disabled="scope.row.status !== 3"。

  2. 展开多个企业后合同列表互相覆盖
    现象:展开第二行,第一行数据变了;原因:所有行共用一份合同列表;修法:初始化时给每个企业行加独立 rentList,展开时写入 row.rentList。

  3. 展开、收起都重复发请求
    现象:点开请求、合上还请求;原因:expanded-change 未判断当前是展开还是收起;修法:用当前 row 是否存在于 rows 中判断,仅展开时请求。

  4. 动态加字段后页面不更新
    现象:数据已赋值但视图不变;原因:Vue2 对对象动态新增属性、数组下标直接修改监听不到;修法:初始化时预占字段,或用 this.$set / Vue.set。

  5. 想给合同状态加 el-tag,但 formatter 不生效
    现象:只能输出纯文本,标签渲染不出来;原因:formatter 返回值按文本处理;修法:改用作用域插槽,在插槽内放 el-tag,差值表达式调用格式化函数。

  6. 合同预览链接打不开
    现象:点击无反应或地址错误;原因:未拼接预览地址与 contractUrl;修法:href 拼成 ${previewURL}${row.contractUrl},并加 target="_blank"。

练习 ​

  1. 补全退租、删除按钮的禁用逻辑:状态 3 表示已退租,要求退租按钮在已退租时禁用,删除按钮仅在已退租时可用。
  2. 实现“企业列表展开行”:要求每行有独立合同列表,展开时按当前企业 id 请求,收起时不请求。
  3. 用作用域插槽 + el-tag 渲染合同状态,映射:0 待生效、1 生效中、2 已到期、3 已退租。
  4. 给企业详情页补全合同预览:路由带 id,mounted 请求详情,合同名点击后新窗口预览。
  5. 用 map 给企业列表每项初始化 rentList: [],并说明为什么不能等展开时直接 row.rentList = res.data。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
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

小结 ​

  1. 操作类接口成功后要同步刷新列表,避免页面状态与后端不一致。
  2. 行级数据必须按行存储,不能多行共享同一份合同列表。
  3. Vue2 中动态新增响应式字段要预占位或使用 $set。
  4. 展开请求要做展开态判断,按需请求,避免无意义网络开销。
  5. 状态展示按需求选 prop、formatter 或插槽;需要模板标签时优先用插槽。
  6. 操作按钮的可用性应由业务状态驱动,disabled 绑定明确表达式。
  7. 详情页依赖路由参数和详情接口,预览地址必须正确拼接。
  8. 框架能力受原生 JS 限制,对象新增属性、数组下标修改要格外警惕。