Skip to content

在线报修与无感刷新(三) ​

本页对应 在线报修与无感刷新,共 5 段视频,约 1 小时 7 分。

学习目标 ​

  • 能够说出小程序保存网络图片时必须先调用 wx.getImageInfo 获取本地临时路径、再调用保存相册 API 的原因,并独立完成从配置 downloadFile 合法域名到点击按钮成功保存图片的完整实现。
  • 能够画出小程序授权处理流程图(检测授权状态 → 未授权时弹窗引导 → 打开设置页 → 返回后重新检测 → 已授权则调用业务 API),并据此封装出带授权检测的保存图片函数,区分已授权、拒绝、二次拒绝三种分支行为。
  • 能够说明双 token 机制中普通 token 与 refresh token 的有效期差异及各自职责,并在响应拦截器中依据 401 状态码识别 token 过期,发起刷新请求后用返回的新值同步更新应用状态与本地存储。
  • 能够通过修改 config.headers 覆盖原请求头中的过期 token,并重新发起原请求,使用户在页面无感知的情况下看到数据正常渲染。
  • 能够识别 refresh token 同样过期时产生的请求死循环,并通过判断请求 URL 是否为刷新接口来终止递归、清除本地 token 并携带回跳地址重定向到登录页。
  • 能够在调试中通过断网或修改代码快速定位并中止请求死循环,说明此类循环为何属于会导致应用崩溃的严重缺陷。

知识点 ​

访客详情的两条入口 ​

为什么需要:访客详情页既可能由已登录用户从列表进入,也可能由朋友通过分享卡片打开。前者能拿到访客 id,后者拿不到登录态,只能依赖分享链接里的 encryptedData。因此 onLoad 必须同时识别两种参数。

怎么想:先看 URL 里有什么。有 id 走登录态详情接口;有 encryptedData 走分享详情接口。两个请求都遵循“先判参数、再请求、再判 code、最后 setData”的固定节奏。

怎么做:在 onLoad 中解构参数,分别调用两个方法。核心代码如下:

javascript
// visitor_pkg/pages/passport/index.js
Page({
  data: {
    passport: {}
  },
  onLoad({ id, encryptedData }) {
    // 获取访客详情
    this.getPassport(id)
    this.getPassportShare(encryptedData)
  },
  // 获取访客详情(通行证)
  async getPassport(id) {
    // 检测是否存在 id
    if (!id) return
    // 调用接口
    const { code, data: passport } = await wx.http.get('/visitor/' + id)
    // 检测接口是否调用成功
    if (code !== 10000) return wx.utils.toast()
    // 渲染数据
    this.setData({ passport })
  },
  async getPassportShare(encryptedData) {
    // 检测是否存在 encryptedData
    if (!encryptedData) return
    // 调用接口
    const { code, data: passport } = await wx.http.get('/visitor/share/' + encryptedData)
    // 检测接口是否调用成功
    if (code !== 10000) return wx.utils.toast()
    // 渲染数据
    this.setData({ passport })
  },
})

易错点:两个方法都要先判空再发请求,不能把 id 和 encryptedData 混用;接口路径不同,/visitor/ 与 /visitor/share/ 不能写串;code !== 10000 时直接 toast 并返回,避免把错误数据渲染到页面。

登录态按 ID 获取并渲染二维码 ​

为什么需要:详情页要展示通行证二维码、有效时间和房屋信息。二维码有时效性,过期后 validTime 为 -1,页面必须切换到“二维码失效”状态,而不是继续倒计时。

怎么想:接口返回的 passport 直接进入 data;模板用 wx:if 判断 validTime > 0 才展示倒计时组件,否则展示 00:00:00。二维码区域在 validTime === -1 时加半透明蒙层。

怎么做:保留笔记中的渲染代码,注意倒计时 time 要乘 1000,因为接口字段是秒:

xml
<view class="passport">
  <view class="countdown">
    <van-count-down wx:if="{{passport.validTime > 0}}" time="{{passport.validTime * 1000}}" />
    <view wx:else class="van-count-down">00:00:00</view>
    <view class="label">通行证有效时间</view>
  </view>
  <view class="qrcode">
    <image src="{{passport.url}}"></image>
    <view wx:if="{{passport.validTime === -1}}" class="mask">二维码失效</view>
  </view>
  <view class="description">
    <view class="house">{{passport.houseInfo}}</view>
    <view class="tips">将此二维码分享给访客,访客扫码即可开门</view>
  </view>
</view>

易错点:validTime 是秒,倒计时组件按毫秒,漏乘 1000 会显示异常;过期判断用 === -1,不要写成 > 0 的反向逻辑导致蒙层条件错;图片地址是 passport.url,不要误写成固定路径。

分享页通过 encryptedData 获取详情 ​

为什么需要:分享给朋友后,新用户可能未登录,不能按 id 查详情。分享时把 encryptedData 拼到路径后面,接收页再用它换取详情。

怎么想:正常详情数据里带有 encryptedData,分享路径就拼它;分享页请求 /visitor/share/:encryptedData。工具栏的显示条件利用“正常详情有该字段、分享接口不返回该字段”来控制。

怎么做:自定义分享与工具栏条件如下:

javascript
// visitor_pkg/pages/passport/index.js
Page({
  data: {},
  onLoad() {},
  // 自定义分享
  onShareAppMessage() {
    // 获取加密数据
    const { encryptedData } = this.data.passport
    return {
      title: '查看通行证',
      path: '/visitor_pkg/pages/passport/index?encryptedData=' + encryptedData,
      imageUrl: 'https://enjoy-plus.oss-cn-beijing.aliyuncs.com/images/share_poster.png',
    }
  },
})
xml
<view class="toolbar" wx:if="{{passport.encryptedData}}">
  <button class="button-share" open-type="share">
    <text class="enjoy-icon icon-share"></text>
    <text class="text">分享给朋友</text>
  </button>
  <button class="button-save">
    <text class="enjoy-icon icon-save"></text>
    <text class="text">保存到本地</text>
  </button>
</view>

易错点:分享路径参数名必须是 encryptedData,不要写成 encrypteddata;分享页不要再让用户二次分享,工具栏条件要结合接口返回字段核对;onShareAppMessage 里从 this.data.passport 取加密数据,取不到时要先确认详情是否已渲染。

保存到本地与授权处理(转写稿补充) ​

为什么需要:二维码是在线图片,saveImageToPhotosAlbum 不支持网络路径,直接保存会失败。首次保存还会触发相册授权,用户点“拒绝”后必须引导去设置页开启。

怎么想:先 wx.getImageInfo 把网络图下载到本地临时路径,再 wx.saveImageToPhotosAlbum 保存;保存前用 wx.getSetting 检测相册权限,拒绝时先弹提示,再 wx.openSetting 引导。

怎么做:转写稿中的调试顺序是:配置 downloadFile 合法域名 → 点击保存 → 打印 getImageInfo 返回的临时路径 → 把 filePath 传给保存相册 API → 补授权检测。授权检测建议用回调写法,避免 getSetting 的 Promise 写法在打开设置页时出问题。

易错点:downloadFile 域名没配会导致下载失败;保存参数是 filePath,不是网络地址;用户拒绝后不要反复弹窗打扰;无感刷新场景中,刷新 token 的请求自身返回 401 时要先判断并终止,避免死循环。

本节目标与问题 ​

本节要完成的是:在访客详情页点击“保存到本地”,把页面上的二维码图片保存到系统相册。这里不能像网页端那样用一个 a 标签加 href 直接下载,因为小程序对文件与相册权限有专门限制。转写稿里先直接查看保存 API 的文档,发现它要求的是本地文件路径,不支持网络路径,所以必须先把在线图片“取到本地”,再保存。

为什么需要:二维码是在线图片,保存 API 不能直接吃网络地址。
怎么想:先下载/获取图片信息,拿到临时本地路径,再调用保存相册。
怎么做:用 wx.getImageInfo 获取图片信息,用 wx.saveImageToPhotosAlbum 保存。
易错点:把 src 写成网络图片地址后,误以为返回结果就是图片本身;实际要取返回的 path 再保存。

获取图片信息:wx.getImageInfo ​

wx.getImageInfo 通过图片路径读取图片信息。转写稿中调用后,控制台能看到图片宽度、高度、类型,以及一个 tmp 开头的临时本地路径。这个临时路径就是后续保存要用的 filePath。

为什么需要:保存相册 API 需要本地路径,网络图片不能直接保存。
怎么想:把“保存”拆成“先获取本地路径,再写入相册”两步。
怎么做:

javascript
wx.getImageInfo({
  src: 'https://domain.com/path/xxx.jpg',
  success(result) {
    console.log(result)
  }
})

易错点:转写稿里提到的“单录域名”应理解为 download 域名;不配置会导致下载/获取图片信息失败。配置后还要在开发者工具里刷新项目配置再测试。

配置 download 域名 ​

为什么需要:小程序出于安全限制,下载网络资源需要配置合法域名。
怎么想:二维码从哪个域名下载,就把哪个域名加入 download 合法域名。
怎么做:在小程序管理后台“开发管理 → 开发设置”里添加 download 域名,回到开发者工具刷新项目配置。
易错点:只配置了 request 域名不够;转写稿中特意强调“从哪里下就配哪一个路径”,不要配错域名。

保存到相册:wx.saveImageToPhotosAlbum ​

wx.saveImageToPhotosAlbum 将图片保存到相册。第一次调用会弹出授权窗口,用户允许后即可保存;转写稿测试时,模拟器会让人选择文件夹,手机上则直接保存到系统相册,二者有一点差异。

为什么需要:最终目标是把二维码写入用户相册。
怎么想:把上一步拿到的本地 path 传给 filePath。
怎么做:

javascript
wx.saveImageToPhotosAlbum({
  filePath: '待保存的图片路径(通过 wx.getImageInfo 读取到的)',
  success(result) {
    console.log(result)
  }
})

易错点:filePath 不能填网络地址;第一次调用要处理授权弹窗,用户点拒绝后不能继续保存。

组合实现保存二维码 ​

页面结构中先绑定点击事件:

xml
<view class="toolbar" wx:if="{{passport.encryptedData}}">
  <button class="button-share" open-type="share">
    <text class="enjoy-icon icon-share"></text>
    <text class="text">分享给朋友</text>
  </button>
  <button bind:tap="saveQRCode" class="button-save">
    <text class="enjoy-icon icon-save"></text>
    <text class="text">保存到本地</text>
  </button>
</view>

业务代码里组合两个 API:

javascript
// visitor_pkg/pages/passport/index.js
Page({
  data: {},
  onLoad() {},
  // 保存二维码
  async saveQRCode() {
    try {
      // 读取图片信息
      const { path } = await wx.getImageInfo({
        // 二维码的图片路径
        src: this.data.passport.url,
      })
      // 保存图片到相册
      wx.saveImageToPhotosAlbum({ filePath: path })
    } catch (err) {
      wx.utils.toast('保存图片失败,稍后重试!')
    }
  }
})

为什么需要:单独调用任一 API 都不够,必须串起来。
怎么想:getImageInfo 负责“拿到本地路径”,saveImageToPhotosAlbum 负责“写入相册”。
怎么做:用 async/await 让两步顺序执行,并加 try/catch 兜底。
易错点:转写稿里调试时先打印 result,确认 path 存在再保存;不要直接把 src 传给 filePath。

授权流程:拒绝后如何引导 ​

转写稿继续完善了授权处理:首次保存会弹授权窗口,点“允许”后以后可直接保存;如果点“拒绝”,再次保存会失败。此时不能反复弹窗打扰用户,而要先检测授权状态,再引导用户打开设置。

为什么需要:用户可能误点拒绝,需要可恢复路径。
怎么想:先 wx.getSetting 检测相册写入权限;未授权时用 wx.showModal 说明原因,再用 wx.openSetting 打开设置页;用户返回后再次检测,仍拒绝就不继续打扰。
怎么做:转写稿中权限字段是 scope.writePhotosAlbum,因为字段带点,访问时用中括号:authSetting['scope.writePhotosAlbum']。检测到已授权就直接保存;未授权先提示“请允许添加到相册”,再打开设置。
易错点:转写稿提到 getSetting 用 Promise 方式时打开设置界面可能出问题,因此演示中改用回调写法;清除授权数据可在开发者工具“清除缓存 → 清除授权数据”里测试首次弹窗和拒绝分支。不要写成死循环式反复弹窗。

先明确本节目标:登录态下取访客列表 ​

为什么需要:访客列表是访客管理模块的入口数据,用户保持登录状态时,页面要能拿到自己的访客记录;接口支持分页,后续还要接滚动加载。接口文档地址见笔记:接口文档地址。

怎么想:先不要一上来做分页,先把“不分页也能取到第一批数据”跑通。页面 onLoad 时调用列表接口,默认 current=1、pageSize=5;接口返回后先判断 code,成功再 setData,同时用 isEmpty 标记空列表。

怎么做:

javascript
// visitor_pkg/pages/list/index.js
Page({
  data: {
    visitorList: [],
    isEmpty: false,
  },
  onLoad() {
    // 获取访客列表
    this.getVisitorList()
  },
  // 访客列表接口
  async getVisitorList(current = 1, pageSize = 5) {
    // 调用接口
    const {
      code,
      data: {rows: visitorList},
    } = await wx.http.get('/visitor', {current, pageSize})
    // 检测接口是否调用成功
    if (code !== 10000) return wx.utils.toast()
    // 渲染数据
    this.setData({
      isEmpty: visitorList.length === 0,
      visitorList,
    })
  },
})

易错点:code !== 10000 时不要继续渲染;isEmpty 要按本次返回的 visitorList.length 判断;分页参数名是 current、pageSize,不要拼错。转写稿里反复出现 token 过期、刷新 token 的调试,这说明列表接口依赖有效登录态;但无感刷新属于统一拦截器内容,本节不展开,先把列表主流程跑通。

列表渲染与空状态 ​

为什么需要:有数据时要展示访客房屋信息、姓名、手机号、访问日期和状态;没有数据时要给用户明确提示和“添加”入口,不能只留白屏。

怎么想:外层用 wx:if="&#123;&#123;!isEmpty&#125;&#125;" 控制列表,wx:else 展示空状态;列表项用 wx:for 和 wx:key="id"。状态字段用 item.status 判断,配合 success、cancel 两个类名。

怎么做:

xml
<block wx:if="{{!isEmpty}}">
  <scroll-view show-scrollbar="{{false}}" enhanced scroll-y>
    <view class="visitor">
      <view class="visitor-title">我的访客</view>
      <view class="visitor-list">
        <van-cell-group
          wx:for="{{visitorList}}"
          wx:key="id"
          border="{{false}}"
          bind:tap="goPassport"
        >
          <van-cell size="large" title="{{item.houseInfo}}">
            <text wx:if="{{item.status === 1}}" class="tag success">生效中</text>
            <text wx:if="{{item.status === 0}}" class="tag cancel">已失效</text>
          </van-cell>
          <van-cell title="访客姓名" border="{{false}}" value="{{item.name}}" />
          <van-cell title="手机号码" border="{{false}}" value="{{item.mobile}}" />
          <van-cell title="访问日期" border="{{false}}" value="{{item.visitDate}}" />
        </van-cell-group>
      </view>
    </view>
  </scroll-view>
</block>
<view wx:else class="blank">
  您还没有访客记录,请点击
  <navigator hover-class="none" class="link" url="/visitor_pkg/pages/form/index">添加</navigator>
</view>

易错点:笔记文字写“0 表示生效中、1 表示已失效”,但示例代码里 status === 1 显示“生效中”、status === 0 显示“已失效”,两者相反。落地时必须以接口文档或真实返回为准核对,不能照抄矛盾处。另外 wx:key 要用唯一字段,空状态里的 navigator 路径不要写错。

分页加载与滚动触底 ​

为什么需要:访客可能很多,一次全量取不现实;接口支持分页,就要在用户滚动到底部时继续取下一页。

怎么想:给 scroll-view 加 bind:scrolltolower="getMoreVisitor",触底时请求下一页;但触底事件可能短时间重复触发,所以笔记要求用节流函数避免无意义请求,节流可用第三方库 miniprogram-licia。

怎么做:在原来的 scroll-view 上补事件:

xml
<block wx:if="{{!isEmpty}}">
  <scroll-view
  	bind:scrolltolower="getMoreVisitor"
    show-scrollbar="{{false}}"
    enhanced
    scroll-y
  >
    <view class="visitor">
      ....
    </view>
  </scroll-view>
</block>
<view wx:else class="blank">
  您还没有访客记录,请点击
  <navigator hover-class="none" class="link" url="/visitor_pkg/pages/form/index">添加</navigator>
</view>

笔记只给出节流思路和库名,没有给出完整 getMoreVisitor 实现,这里不补造代码。实现时应在 getMoreVisitor 中维护当前页码,请求成功后把新数据追加到 visitorList,并继续更新 isEmpty。

易错点:分页请求不要把旧列表直接覆盖,除非是第一页;触底重复触发要用节流;请求失败要 toast,不要静默;当返回空数组时不要无限请求下一页。转写稿中保存图片、授权、无感刷新等属于相邻业务,本节只聚焦“列表接口—渲染—分页触底”这条主线。

课件图示 ​

课件第10页:多⼀句没有,少⼀句不⾏,用更短时间,教会更实用的技术! ⾼级软件⼈才培训专家 享

课件第3页:多⼀句没有,少⼀句不⾏,用更短时间,教会更实用的技术! ⾼级软件⼈才培训专家 l

课件第8页:多⼀句没有,少⼀句不⾏,用更短时间,教会更实用的技术! ⾼级软件⼈才培训专家 享

课件第9页:多⼀句没有,少⼀句不⾏,用更短时间,教会更实用的技术! ⾼级软件⼈才培训专家 享

课件第11页:多⼀句没有,少⼀句不⾏,用更短时间,教会更实用的技术! ⾼级软件⼈才培训专家 享

课件第4页:多⼀句没有,少⼀句不⾏,用更短时间,教会更实用的技术! ⾼级软件⼈才培训专家 享

课件第7页:多⼀句没有,少⼀句不⾏,用更短时间,教会更实用的技术! ⾼级软件⼈才培训专家 享

代码示例 ​

示例 1:访客详情页(登录态 + 分享态) ​

javascript
// visitor_pkg/pages/passport/index.js
Page({
  data: {
    passport: {}
  },
  onLoad({ id, encryptedData }) {
    // 情况 1:登录状态,根据 id 获取访客详情
    this.getPassport(id)
    // 情况 2:通过分享打开,根据 encryptedData 获取访客详情
    this.getPassportShare(encryptedData)
  },
  // 获取访客详情(通行证)
  async getPassport(id) {
    // 检测是否存在 id
    if (!id) return
    // 调用接口
    const { code, data: passport } = await wx.http.get('/visitor/' + id)
    // 检测接口是否调用成功
    if (code !== 10000) return wx.utils.toast()
    // 渲染数据
    this.setData({ passport })
  },
  // 分享打开时获取访客详情
  async getPassportShare(encryptedData) {
    // 检测是否存在 encryptedData
    if (!encryptedData) return
    // 调用接口
    const { code, data: passport } = await wx.http.get('/visitor/share/' + encryptedData)
    // 检测接口是否调用成功
    if (code !== 10000) return wx.utils.toast()
    // 渲染数据
    this.setData({ passport })
  },
  // 自定义分享
  onShareAppMessage() {
    // 获取加密数据
    const { encryptedData } = this.data.passport
    return {
      title: '查看通行证',
      path: '/visitor_pkg/pages/passport/index?encryptedData=' + encryptedData,
      imageUrl: 'https://enjoy-plus.oss-cn-beijing.aliyuncs.com/images/share_poster.png',
    }
  },
})
xml
<!-- visitor_pkg/pages/passport/index.wxml -->
<view class="passport">
  <view class="countdown">
    <van-count-down wx:if="{{passport.validTime > 0}}" time="{{passport.validTime * 1000}}" />
    <view wx:else class="van-count-down">00:00:00</view>
    <view class="label">通行证有效时间</view>
  </view>
  <view class="qrcode">
    <image src="{{passport.url}}"></image>
    <view wx:if="{{passport.validTime === -1}}" class="mask">二维码失效</view>
  </view>
  <view class="description">
    <view class="house">{{passport.houseInfo}}</view>
    <view class="tips">将此二维码分享给访客,访客扫码即可开门</view>
  </view>
</view>
<view class="toolbar" wx:if="{{passport.encryptedData}}">
  <button class="button-share" open-type="share">
    <text class="enjoy-icon icon-share"></text>
    <text class="text">分享给朋友</text>
  </button>
  <button bind:tap="saveQRCode" class="button-save">
    <text class="enjoy-icon icon-save"></text>
    <text class="text">保存到本地</text>
  </button>
</view>

要点解释与易错点:

  • onLoad({ id, encryptedData }) 直接从页面参数中解构:带 id 说明是登录态进入,带 encryptedData 说明是分享进入,两个方法都写了,靠方法内部的 if (!id) return / if (!encryptedData) return 各自短路,互不干扰。
  • const { code, data: passport } = await wx.http.get(...) 用了对象解构重命名,data 直接改名为 passport,省一次中间变量;接口约定 code === 10000 才算成功,失败要 return,否则后面的 setData 会把 undefined 写进 data。
  • 倒计时组件 van-count-down 的 time 单位是毫秒,所以 validTime * 1000;当 validTime === -1 表示二维码已失效,此时不展示倒计时(走 wx:else 显示 00:00:00),同时在二维码上叠一层 .mask 蒙层。
  • 分享按钮靠 open-type="share" 触发 onShareAppMessage,不需要自己写点击事件;path 里必须把 encryptedData 拼在 URL 后面,否则被分享的人拿不到数据。
  • 笔记中 wx:if="&#123;&#123;passport.encryptedData&#125;&#125;" 用于控制工具栏显隐(分享打开的场景下不应再允许二次分享),实际业务里请对照接口返回的字段确认这个判断方向是否正确,字段名/取反写错会导致按钮该隐藏时没隐藏。

示例 2:保存二维码到本地相册 ​

javascript
// visitor_pkg/pages/passport/index.js
Page({
  data: {
    passport: {}
  },
  onLoad({ id, encryptedData }) {
    this.getPassport(id)
    this.getPassportShare(encryptedData)
  },
  async getPassport(id) { /* 见示例 1 */ },
  async getPassportShare(encryptedData) { /* 见示例 1 */ },

  // 保存二维码
  async saveQRCode() {
    try {
      // 第一步:读取图片信息(本质是把网络图片下载到本地)
      const { path } = await wx.getImageInfo({
        // 二维码的图片路径
        src: this.data.passport.url,
      })
      // 第二步:保存图片到相册
      await wx.saveImageToPhotosAlbum({ filePath: path })
      wx.utils.toast('保存成功')
    } catch (err) {
      wx.utils.toast('保存图片失败,稍后重试!')
    }
  },
})

要点解释与易错点:

  • wx.saveImageToPhotosAlbum 的 filePath 不支持网络路径,直接把 https://... 丢进去会失败,所以必须先用 wx.getImageInfo 把网络图"下载"成本地临时路径(返回值里的 path,即 tmp 临时路径),再拿这个本地路径去保存。
  • wx.getImageInfo 需要在小程序管理后台 开发管理 → 开发设置 → 服务器域名 里把二维码图片所在的域名加到 downloadFile 合法域名中,否则真机/开发者工具都会报域名不合法。
  • 两个 API 都支持 Promise 风格,所以可以用 await 串起来,并用 try/catch 统一兜底;wx.saveImageToPhotosAlbum 首次调用会弹出授权窗口,用户点"拒绝"后会进入 catch 分支,提示"保存图片失败"。
  • 用户一旦拒绝过相册权限,后续再点保存会一直失败且不再弹窗,需要走授权处理流程:用 wx.getSettings(按转写稿说明,这里用回调写法更稳妥,Promise 写法在打开设置页时会出问题)读取 authSetting['scope.writePhotosAlbum'],未授权时先 wx.showModal 引导(文案"请允许添加到相册",可设 showCancel: false),用户确定后再 wx.openSetting 打开设置页,并在其回调里重新读取授权状态——已授权则继续调用保存,仍未授权就直接结束,不再反复打扰用户。

示例 3:访客列表(含分页触底加载) ​

javascript
// visitor_pkg/pages/list/index.js
Page({
  data: {
    visitorList: [],
    isEmpty: false,
  },
  onLoad() {
    // 获取访客列表
    this.getVisitorList()
  },
  // 访客列表接口
  async getVisitorList(current = 1, pageSize = 5) {
    // 调用接口
    const {
      code,
      data: { rows: visitorList },
    } = await wx.http.get('/visitor', { current, pageSize })
    // 检测接口是否调用成功
    if (code !== 10000) return wx.utils.toast()
    // 渲染数据
    this.setData({
      isEmpty: visitorList.length === 0,
      visitorList,
    })
  },
})
xml
<!-- visitor_pkg/pages/list/index.wxml -->
<block wx:if="{{!isEmpty}}">
  <scroll-view
    bind:scrolltolower="getMoreVisitor"
    show-scrollbar="{{false}}"
    enhanced
    scroll-y
  >
    <view class="visitor">
      <view class="visitor-title">我的访客</view>
      <view class="visitor-list">
        <van-cell-group
          wx:for="{{visitorList}}"
          wx:key="id"
          border="{{false}}"
          bind:tap="goPassport"
        >
          <van-cell size="large" title="{{item.houseInfo}}">
            <text wx:if="{{item.status === 1}}" class="tag success">生效中</text>
            <text wx:if="{{item.status === 0}}" class="tag cancel">已失效</text>
          </van-cell>
          <van-cell title="访客姓名" border="{{false}}" value="{{item.name}}" />
          <van-cell title="手机号码" border="{{false}}" value="{{item.mobile}}" />
          <van-cell title="访问日期" border="{{false}}" value="{{item.visitDate}}" />
        </van-cell-group>
      </view>
    </view>
  </scroll-view>
</block>
<view wx:else class="blank">
  您还没有访客记录,请点击
  <navigator hover-class="none" class="link" url="/visitor_pkg/pages/form/index">添加</navigator>
</view>

要点解释与易错点:

  • 列表接口返回的是分页结构,数据在 data.rows 里,所以要写成 data: { rows: visitorList } 二次解构,直接取 data 会拿到整个分页对象。
  • 空列表时用 isEmpty: visitorList.length === 0 标记,模板里 wx:if="&#123;&#123;!isEmpty&#125;&#125;" 渲染 scroll-view,wx:else 渲染引导文案 + navigator 跳转添加页,两个分支互斥,不要漏掉 wx:else。
  • 状态文案对应关系:status === 1 是"生效中"(success 类名),status === 0 是"已失效"(cancel 类名),写反了样式和语义都会错。
  • 分页要监听 bind:scrolltolower(滚动到底部)再请求下一页;这个事件可能在短时间内被重复触发,转写稿中明确要用节流(可用第三方库 miniprogram-licia)避免重复请求。
  • wx:key="id" 必须用列表项里真实唯一的字段,用 index 会导致列表更新时节点复用错乱。

常见错误 ​

  1. 保存二维码失败,提示不支持网络路径
    现象:点击“保存到本地”后无图片或直接报错。
    原因:把 passport.url 这类网络地址直接传给 wx.saveImageToPhotosAlbum。
    修法:先用 wx.getImageInfo 取到本地临时 path,再保存;整个流程加 try/catch 并给失败提示。

  2. 真机或工具提示 download 域名不合法
    现象:wx.getImageInfo 调用失败,保存流程中断。
    原因:小程序后台未配置 downloadFile 合法域名,或工具项目配置未刷新。
    修法:在开发设置中补充图片资源域名,回到开发者工具刷新项目配置并重新编译。

  3. 首次拒绝相册授权后,再点保存一直无反应
    现象:用户误点“拒绝”后,保存按钮像失效一样。
    原因:只调用保存 API,没有检测授权状态,也没有拒绝后的引导与复查。
    修法:先检测相册授权;拒绝时弹窗说明并引导 openSetting;从设置返回后复查,仍拒绝则停止打扰。

  4. 分享页仍然出现“分享给朋友”,或“保存到本地”被一起隐藏
    现象:分享打开的页面还能二次分享,或保存入口消失。
    原因:显隐条件写反,或用整块 toolbar 控制,误伤保存按钮。
    修法:按 encryptedData 语义只控制分享按钮;保存按钮按业务需要保留,条件写清楚并分别验证两种入口。

  5. 访客列表状态标签显示反了
    现象:生效中显示成“已失效”,已失效显示成“生效中”。
    原因:接口状态值与模板中的 status === 0/1、success/cancel 映射不一致。
    修法:以接口文档为准统一状态常量或映射函数,模板只消费映射后的结果。

  6. 滚动触底时分页接口被重复请求,列表出现重复数据
    现象:快速滑动时连续发请求,列表重复或页码错乱。
    原因:scrolltolower 短时间重复触发,且没有请求锁、节流和“无更多”保护。
    修法:加节流与 loading 锁;新数据追加而非覆盖;无更多数据时停止请求。

  7. Token 过期刷新进入死循环
    现象:网络面板不断请求刷新接口,页面卡死或应用崩溃。
    原因:refresh token 也返回 401 时,仍继续走刷新逻辑。
    修法:在刷新前判断当前请求是否为刷新接口;若是且仍 401,清理本地 token 并重定向登录,终止刷新。

  8. 刷新 Token 后重发请求,页面仍然空白
    现象:刷新接口成功,但原业务数据没有渲染。
    原因:重发时未覆盖原请求的鉴权头,或没有 return 重发请求的结果。
    修法:基于原配置替换 Authorization 为新 token,并 return 重发请求,让业务层拿到最终数据。

练习 ​

  1. 补全保存二维码的授权分支
    要求:已授权时直接保存;首次拒绝时弹窗说明并引导打开设置;设置返回后复查;仍拒绝则不再重复打扰。写出关键伪代码或流程说明。

  2. 修正分享页工具栏显隐
    要求:普通登录入口显示“分享给朋友”和“保存到本地”;通过分享链接打开的页面隐藏“分享给朋友”,但保留“保存到本地”。写出 WXML 条件并说明判断依据。

  3. 实现访客列表分页防抖与追加
    要求:监听 scrolltolower,加入节流、请求锁、页码递增、数据追加、空态和无更多提示;验证快速连续滑动不会重复请求。

  4. 验证无感刷新的两条路径
    要求:先模拟 access token 失效、refresh token 正常,确认自动刷新并重发;再模拟两者都失效,确认不进入死循环,清理 token 并跳转登录,登录后能回跳原页面。

  5. 检查访客详情二维码的过期态
    要求:分别构造 validTime > 0 和 validTime === -1,验证倒计时、失效蒙层、保存按钮和分享入口是否符合业务预期,并记录异常点。

原视频对照 ​

#视频时长
1访客详情-小程序保存图片12:10
2访客详情-授权API处理流程17:12
3无感刷新-过期token续期16:57
4无感刷新-重发请求08:35
5无感刷新-完善刷新token过期情况12:05

小结 ​

  1. 访客详情必须区分“登录态按 ID 获取”和“分享态按 encryptedData 获取”两条数据通道,页面初始化要按参数分流。
  2. 二维码保存不是直接写网络图片,而是“先取本地临时路径,再写相册”,域名配置和授权处理缺一不可。
  3. 授权流程的重点不是弹窗本身,而是拒绝后的引导、设置返回后的复查,以及用户仍拒绝时停止打扰。
  4. 分享页的权限边界要落到 UI 上:不允许二次分享时只隐藏分享入口,不能误伤保存等其他操作。
  5. 列表分页要同时处理触底频率、请求锁、数据追加、空态和无更多态,否则很容易重复请求或重复渲染。
  6. 无感刷新必须同时覆盖“刷新成功重发”和“刷新失败终止”两条路径,尤其要防止 401 递归。
  7. 重发请求要复用原配置并替换鉴权头,且返回重发结果,否则业务层拿不到最终数据。
  8. 网络请求、图片读取、相册保存和授权相关能力都要有失败兜底,避免用户侧静默失败。