Skip to content

异常上报(三) ​

本页对应 异常上报,共 7 段视频,约 57 分钟。

学习目标 ​

  • 能够使用定位能力获取上报位置,并将结果绑定到页面展示,未获取成功时显示默认文案。
  • 能够使用 uni-popup、列表和 checkbox-group 实现异常类型多选弹层,并监听 change 事件获取用户选择结果。
  • 能够将多选结果用竖线拼接为字符串,并在点击确定后回显到页面,同时说明重复选择问题的处理思路。
  • 能够使用 v-model 获取异常描述和现场照片上传结果,并通过计算属性从上传结果中提取后端所需的图片 URL。
  • 能够根据接口文档封装并调用上报异常接口,通过 onLoad 获取运输 ID,组装完整参数并在请求成功后跳转页面。
  • 能够在详情页使用 v-if 判断异常信息是否存在,并用 v-for 渲染多条异常记录,确保无异常信息时不展示对应区域。

知识点 ​

1.3 已完成任务列表:状态 6 与分页刷新 ​

司机的运输任务完成回单登记后,任务状态会变成 6,即“已完成”。因此本节的切入点很明确:任务列表接口传状态值 6,就能拿到已完成任务。本节笔记没有配图,以下保留原代码并按知识点补充推导。

说明:转写稿主要讲“上报异常”模块,与本节“已完成任务列表”不是同一功能,不能把异常类型、图片上传等细节硬套进来。可迁移的是调试习惯:先打印接口返回,确认 items/page/pages 字段,再渲染。转写稿中“坚听/称职事件”等同音字,应纠正为“监听/change 事件”。

1.3.1 任务列表:先取状态 6 的数据 ​

为什么需要:已完成页不能复用待提货或在途列表,否则状态不对,数据也不对。
怎么想:接口需要一个状态参数,已完成固定传 6;页面只需要维护列表和空状态。
怎么做:在 onMounted 中请求,成功后将 data.items 赋给 completeList,并用长度判断空列表。

vue
<!-- pages/task/components/complete.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import taskApi from '@/apis/task'

const completeList = ref([])
const isEmpty = ref(false)

onMounted(() => {
  getCompleteList()
})

async function getCompleteList(page = 1, pageSize = 5) {
  const { code, data } = await taskApi.list(6, page, pageSize)
  if (code !== 200) return uni.utils.toast('已完成任务获取失败!')
  completeList.value = data.items || []
  isEmpty.value = completeList.value.length === 0
}
</script>

模板中保留卡片渲染与空状态:

vue
<view v-for="complete in completeList" :key="complete.id" class="task-card">
  <navigator hover-class="none" :url="`/subpkg_task/detail/index?id=${complete.id}`">
    <view class="header">
      <text class="no">任务编号: {{ complete.transportTaskId }}</text>
    </view>
    <view class="body">
      <view class="timeline">
        <view class="line">{{ complete.startAddress }}</view>
        <view class="line">{{ complete.endAddress }}</view>
      </view>
    </view>
  </navigator>
  <view class="footer flex">
    <view class="label">提货时间</view>
    <view class="time">{{ complete.created }}</view>
  </view>
</view>
<view v-if="isEmpty" class="task-blank">无完成货物</view>

易错点:data.items 可能为空,必须写 || [];状态值不能写成字符串 '6' 之外的其他状态;接口失败时不要继续渲染旧数据。

1.3.2 上拉分页:追加而不是覆盖 ​

为什么需要:列表一次只返回一页,上拉加载更多能减少首屏请求量。
怎么想:监听 scrolltolower,每次请求下一页;下一页页码由接口返回的 data.page 加 1 得到;新数据追加到原数组。
怎么做:

vue
<script setup>
const nextPage = ref(1)
const hasMore = ref(true)

function onScrollToLower() {
  if (!hasMore.value) return
  getCompleteList(nextPage.value)
}

async function getCompleteList(page = 1, pageSize = 10) {
  const { code, data } = await taskApi.list(6, page, pageSize)
  if (code !== 200) return uni.utils.toast('获取列表失败,稍后重试!')
  completeList.value = [...completeList.value, ...(data.items || [])]
  nextPage.value = ++data.page
  isEmpty.value = completeList.value.length === 0
  hasMore.value = nextPage.value <= data.pages
}
</script>

<scroll-view
  @scrolltolower="onScrollToLower"
  class="scroll-view"
  refresher-enabled
  scroll-y
>
  ...
</scroll-view>

易错点:分页请求不能直接 completeList.value = data.items,否则旧数据被覆盖;判断还有没有更多数据要用 nextPage <= data.pages,不是用当前页;hasMore 为 false 时直接返回,避免重复请求。

1.3.3 下拉刷新:重新请求第 1 页并清空旧列表 ​

为什么需要:用户希望手动刷新最新完成数据,下拉刷新是移动端常见交互。
怎么想:刷新本质是重新请求第 1 页,因此渲染前要清空原数组,否则会重复追加。
怎么做:监听 refresherrefresh,调用 getCompleteList();在请求方法中判断 page === 1 时清空数组。

vue
<script setup>
async function onScrollViewRefresh() {
  isTriggered.value = true
  await getCompleteList()
  isTriggered.value = false
}

async function getCompleteList(page = 1, pageSize = 5) {
  const { code, data } = await taskApi.list(6, page, pageSize)
  if (code !== 200) return uni.utils.toast('获取列表失败,稍后重试!')
  if (page === 1) completeList.value = []
  completeList.value = [...completeList.value, ...(data.items || [])]
  nextPage.value = ++data.page
  isEmpty.value = completeList.value.length === 0
  hasMore.value = nextPage.value <= data.pages
}
</script>

<scroll-view
  @scrolltolower="onScrollToLower"
  @refresherrefresh="onScrollViewRefresh"
  :refresher-triggered="isTriggered"
  scroll-y
  refresher-enabled
  class="scroll-view"
>
  ...
</scroll-view>

易错点:refresher-triggered 为 true 打开动画,为 false 关闭动画;关闭动画要放在 await getCompleteList() 之后;刷新请求必须走第 1 页并清空旧数据,否则列表会出现重复项。

本节笔记的主题是“打包发布”,但转写稿主要记录的是“上报异常”业务开发,二者并不直接对应。因此下面只保留转写稿中可迁移的调试意识:先打印关键结果、看控制台报错、检查变量是否定义、检查数据类型与事件对象;不把异常上报业务代码硬写成打包步骤。转写稿中的“称职事件”应理解为 change 事件,“拆个box”应理解为 checkbox,“微刚model”应理解为 v-model,“数度/数主”应理解为数组,“便利”应理解为遍历。

打包发布前先明确目标端 ​

为什么需要:uni-app 一套代码可以发布到 H5、小程序、App,但每个端的产物目录、运行环境和限制不同,不能“打完一个包就到处用”。
怎么想:先确定要交付哪个端,再选择 HBuilderX 对应菜单;打包后先看产物目录,再决定本地部署还是上传服务空间。
怎么做:在 HBuilderX 中按平台选择“发行”,打包完成后到 unpackage/dist/build/ 下检查对应目录。
易错点:不要把开发环境能运行当成打包后一定正常;发布后要重新走一遍登录、跳转、上传等关键流程。

H5 端打包与发布 ​

为什么需要:H5 端需要静态资源部署到服务器或服务空间,用户才能通过浏览器访问。
怎么想:HBuilderX 负责生成静态代码,部署由服务器或 uniCloud 服务空间完成。
怎么做:按下图步骤打包并发布 H5 端:

H5 打包步骤一

H5 打包步骤二

H5 打包步骤三

H5 打包步骤四

打包结果位于项目根目录下的 unpackage/dist/build/h5,也可以将打包好的代码上传到服务空间。
易错点:uniCloud 选择的阿里云免费服务空间会提供一个免费域名,但该域名使用有限制,具体说明见官方说明。

H5 打包后登录跳转报错的排查 ​

为什么需要:开发环境正常,不代表打包后所有动态写法都被正确处理;H5 打包后登录成功却无法跳转,必须先定位报错行。
怎么想:先看控制台报错“xxx 不是一个函数”,再回到代码看是否用了动态方法调用;转写稿中的调试习惯也可迁移:先用 console.log 打印关键结果,再看控制台是否提示变量未定义,例如转写稿中 reactive 未定义就是靠控制台提示回到顶部补 import。这里同理,先确认报错行和变量类型,再决定改法。
怎么做:原代码出错位置如下:

vue
<!-- pages/login/components/account.vue -->
<script setup>
  import { ref, reactive } from 'vue'
	
  // 省略中间部分代码...

  // 监听表单提交
  async function onFormSubmit() {
    try {
      
      // 省略中间部分代码...
      
      // 这里的报错说 xxx 不是一个函数
      uni[routeType.value]({url: redirectURL.value})
      
    } catch (err) {
      // ...
    }
  }
</script>

语法本身没有问题,初步怀疑是 uni-app 打包导致动态调用异常。改为显式分支:

vue
<!-- pages/login/components/account.vue -->
<script setup>
  import { ref, reactive } from 'vue'
	
  // 省略中间部分代码...

  // 监听表单提交
  async function onFormSubmit() {
    try {
      
      // 省略中间部分代码...
      
      // 地址重定向或switchTab
      if (routeType.value === 'switchTab') {
        uni.switchTab({ url: redirectURL.value })
      } else {
        uni.redirectTo({ url: redirectURL.value })
      }
      
    } catch (err) {
      // ...
    }
  }
</script>

易错点:不要迷信 uni[变量] 这种动态调用;发布后要重新验证 switchTab 与 redirectTo 的分支条件。

小程序端打包 ​

为什么需要:小程序端需要生成微信开发者工具可识别的代码,再在开发者工具中上传审核。
怎么想:HBuilderX 负责编译到 mp-weixin,微信开发者工具负责预览和上传。
怎么做:按下图步骤打包:

小程序打包步骤一

小程序打包步骤二

代码会打包到 unpackage/dist/build/mp-weixin,并自动打开小程序开发者工具运行打包好的微信小程序;随后在开发者工具中选择上传:

小程序上传步骤一

小程序上传步骤二

易错点:先确认微信开发者工具已登录、项目 appid 正确;上传前要检查预览是否正常。

App 端云打包 ​

为什么需要:App 端发布需要安装包;本地打包要求 Android Studio 或 XCode 环境,对前端成本较高,云打包更合适。
怎么想:先准备图标、启动图、SDKVersion、模块配置,再走云打包。
怎么做:

  1. 配置 App 图标:

配置 App 图标

  1. 配置启动界面(闪屏):

配置启动界面

  1. 指定 SDKVersion,使用 Vue3 开发时最低为 21:

指定 SDKVersion

  1. 配置模块:

配置模块

  1. 云打包:

云打包步骤一

云打包步骤二

云打包步骤三

易错点:Vue3 项目 SDKVersion 不能低于 21;云打包前要确认图标、启动图、模块和证书信息,避免打包后才发现缺配置。

代码示例 ​

代码一:已完成任务列表(pages/task/components/complete.vue) ​

vue
<!-- pages/task/components/complete.vue -->
<script setup>
  import { ref, onMounted } from 'vue'
  import taskApi from '@/apis/task'

  // 已完成任务列表
  const completeList = ref([])
  // 已完成任务列表是否为空
  const isEmpty = ref(false)

  // 生命周期(获取数据)
  onMounted(() => {
    getCompleteList()
  })

  // 已完成任务列表
  async function getCompleteList(page = 1, pageSize = 5) {
    // 状态 6 表示“已完成”
    const { code, data } = await taskApi.list(6, page, pageSize)
    if (code !== 200) return uni.utils.toast('已完成任务获取失败!')
    // 渲染数据
    completeList.value = data.items || []
    isEmpty.value = completeList.value.length === 0
  }
</script>

<template>
  <view class="task-search">
    <view class="search-bar">
      <text class="iconfont icon-search"></text>
      <input class="input" type="text" placeholder="输入任务编号" />
    </view>
    <view class="filter-bar">
      <picker class="picker" mode="date">2023.05.20</picker>
      <text class="text">至</text>
      <picker class="picker" mode="date">结束时间</picker>
      <button disabled class="button">筛选</button>
    </view>
  </view>
  <scroll-view scroll-y refresher-enabled class="scroll-view">
    <view class="scroll-view-wrapper">
      <view
        v-for="complete in completeList"
        :key="complete.id"
        class="task-card"
      >
        <navigator
          hover-class="none"
          :url="`/subpkg_task/detail/index?id=${complete.id}`"
        >
          <view class="header">
            <text class="no">任务编号: {{ complete.transportTaskId }}</text>
          </view>
          <view class="body">
            <view class="timeline">
              <view class="line">{{ complete.startAddress }}</view>
              <view class="line">{{ complete.endAddress }}</view>
            </view>
          </view>
        </navigator>
        <view class="footer flex">
          <view class="label">提货时间</view>
          <view class="time">{{ complete.created }}</view>
        </view>
      </view>
      <view v-if="isEmpty" class="task-blank">无完成货物</view>
    </view>
  </scroll-view>
</template>

<style>
  /* 样式沿用 pages/task 下已有的 .task-card / .search-bar 等,这里只补滚动区高度 */
  .scroll-view {
    height: calc(100vh - 100px);
  }
</style>

关键行与易错点

  • taskApi.list(6, page, pageSize):第一个参数就是状态值 6,任务完成回成登记后状态变为 6,传 6 才拿到“已完成”列表。
  • onMounted(() => { getCompleteList() }):一定要调用,写成 getCompleteList 只是传引用,不会发请求。
  • data.items || []:接口返回的 items 可能为空,加 || [] 兜底,避免后面取 length 报错。
  • isEmpty.value = completeList.value.length === 0:空态判断写在请求成功之后,模板里用 v-if="isEmpty"(ref 在模板中自动解包,不要写 isEmpty.value)。
  • :key="complete.id" 必须有且唯一;navigator 的 url 用模板字符串拼接 id,注意外层是 :url 而不是 url。
  • scroll-view 必须给固定高度(这里用 calc(100vh - 100px)),否则内容撑不开、既不能滚动也触发不了后续的下拉/上拉事件。

代码二:上拉分页 + 下拉刷新(完整版) ​

vue
<!-- pages/task/components/complete.vue -->
<script setup>
  import { ref, onMounted } from 'vue'
  import taskApi from '@/apis/task'

  // 已完成任务列表
  const completeList = ref([])
  // 列表是否为空
  const isEmpty = ref(false)
  // 下一页页码
  const nextPage = ref(1)
  // 是否还有更多数据
  const hasMore = ref(true)
  // 下拉刷新动画开关
  const isTriggered = ref(false)

  // 生命周期(获取数据)
  onMounted(() => {
    getCompleteList()
  })

  // 监听页面是否滚动到底部(上拉分页)
  function onScrollToLower() {
    if (!hasMore.value) return
    // 获取下一页数据
    getCompleteList(nextPage.value)
  }

  // 监听用户的下拉操作(下拉刷新)
  async function onScrollViewRefresh() {
    isTriggered.value = true
    await getCompleteList()
    // 关闭动画交互
    isTriggered.value = false
  }

  // 获取任务列表
  async function getCompleteList(page = 1, pageSize = 5) {
    const { code, data } = await taskApi.list(6, page, pageSize)
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast('获取列表失败,稍后重试!')
    // 页面为 1 时,清空数组(下拉刷新)
    if (page === 1) completeList.value = []
    // 渲染任务列表(追加)
    completeList.value = [...completeList.value, ...(data.items || [])]
    // 计算下一页页码
    nextPage.value = ++data.page
    // 判断列表是否为空
    isEmpty.value = completeList.value.length === 0
    // 判断还有没有更多的数据
    hasMore.value = nextPage.value <= data.pages
  }
</script>

<template>
  <scroll-view
    @scrolltolower="onScrollToLower"
    @refresherrefresh="onScrollViewRefresh"
    :refresher-triggered="isTriggered"
    scroll-y
    refresher-enabled
    class="scroll-view"
  >
    <view class="scroll-view-wrapper">
      <view
        v-for="complete in completeList"
        :key="complete.id"
        class="task-card"
      >
        <navigator
          hover-class="none"
          :url="`/subpkg_task/detail/index?id=${complete.id}`"
        >
          <view class="header">
            <text class="no">任务编号: {{ complete.transportTaskId }}</text>
          </view>
          <view class="body">
            <view class="timeline">
              <view class="line">{{ complete.startAddress }}</view>
              <view class="line">{{ complete.endAddress }}</view>
            </view>
          </view>
        </navigator>
        <view class="footer flex">
          <view class="label">提货时间</view>
          <view class="time">{{ complete.created }}</view>
        </view>
      </view>
      <view v-if="isEmpty" class="task-blank">无完成货物</view>
    </view>
  </scroll-view>
</template>

<style>
  .scroll-view {
    height: calc(100vh - 100px);
  }
</style>

关键行与易错点

  • 上拉分页必须用 hasMore 拦截:scrolltolower 在滚到底部时会连续触发多次,不加 if (!hasMore.value) return 会重复请求最后一页。
  • 下一页页码取自接口返回:nextPage.value = ++data.page,即“返回的当前页 + 1”,不是本地 page++,也不是 nextPage.value++(写成本地自增会与实际页数脱节)。
  • 追加数据用扩展运算符:completeList.value = [...completeList.value, ...(data.items || [])],也可以用 concat;直接赋值 = 会把上一页数据覆盖掉。
  • if (page === 1) completeList.value = [] 是最容易漏的一行:下拉刷新本质是重新请求第 1 页,不清空就会出现“旧数据 + 新第 1 页”的重复渲染。
  • hasMore.value = nextPage.value <= data.pages:用总页码 pages 判断,注意 data.pages 不是 data.page(笔记里这句注释写成 data.page 是笔误,代码用的是 pages)。
  • 下拉刷新动画由 :refresher-triggered="isTriggered" 控制,true 打开、false 关闭;onScrollViewRefresh 必须声明 async 并 await getCompleteList(),否则请求还没回来就关动画,刷新看起来“闪一下没反应”。
  • if (code !== 200) return uni.utils.toast(...) 只会退出 getCompleteList,不会中断 onScrollViewRefresh 里 await 之后的 isTriggered.value = false,所以失败时动画也能正常收起。

常见错误 ​

1. 上拉加载后列表只剩最后一页

  • 现象:第一次进入有 5 条,上拉一次后变成 5 条新的,前面的数据消失了。
  • 原因:渲染时写成 completeList.value = data.items,用新一页直接覆盖了原数组;分页数据必须做累积。
  • 修法:统一改成 completeList.value = [...completeList.value, ...(data.items || [])],或在请求前用 concat 合并;同时确认 page 参数确实在递增,否则会把第 1 页反复追加成重复数据。

2. 下拉刷新后出现重复数据

  • 现象:下拉刷新一次,列表里同样的任务出现两份。
  • 原因:刷新本质上仍是请求第 1 页,但复用了“追加”逻辑,没有在 page === 1 时清空原数组。
  • 修法:在赋值前加 if (page === 1) completeList.value = [];刷新回调里显式传 1,不要依赖 nextPage 的当前值。

3. 上拉到底后仍不停发请求 / 或提前停止

  • 现象:滚到底部一直发请求,或明明还有数据却不再加载。
  • 原因:hasMore 判断用了 data.page(当前页)而不是 data.pages(总页数);或 onScrollToLower 里漏了 if (!hasMore.value) return。
  • 修法:hasMore.value = nextPage.value <= data.pages,并在上拉入口做前置拦截;另外 nextPage 不要直接写 ++data.page 修改接口返回对象,改成 nextPage.value = data.page + 1 更安全。

4. 下拉刷新动画一直转圈不回收

  • 现象:松手后 loading 动画停不下来,或闪一下就消失但数据还没回来。
  • 原因:没有用 refresher-triggered 绑定状态变量;或置 false 的语句写在 await 之前,请求未结束就关了动画。
  • 修法:定义 isTriggered,回调内先置 true,await 拿到结果后再置 false;确保请求异常时也能走到关闭逻辑(try/finally)。

5. H5 打包后登录成功却跳转失败,控制台报 “xxx is not a function”

  • 现象:本地开发正常,打包部署到服务器后点击登录无反应,报某方法不是函数。
  • 原因:uni[routeType.value]({ url }) 这种动态成员调用的写法在打包压缩后无法被静态分析保留,运行时取不到对应 API。
  • 修法:改为显式分支调用 uni.switchTab(...) 与 uni.redirectTo(...);凡是 uni.xxx 都建议写死方法名,不要用变量拼。

6. 复选框选了没反应,或事件回调里拿不到值

  • 现象:勾选后 console.log 打不出内容,或 detail.value 是空数组/undefined。
  • 原因:@change 监听写在了 checkbox 上而不是 checkbox-group 上;或 checkbox 没有绑定 :value,组件不知道该上报什么。
  • 修法:事件挂在 group 上,每项补 :value="item.title";取值走 e.detail.value。

7. 反复勾选同一项,提交的字符串里出现重复项

  • 现象:在同一个选项上点了三次,最终 join('|') 的结果里它出现三次。
  • 原因:change 每次触发都无条件 push,没有判断该项是否已在数组中,也没处理“取消勾选”的移除。
  • 修法:push 前用 indexOf 判断,已存在则 splice 移除;或直接用 e.detail.value 这份完整集合覆盖本地数组,避免自行维护增量。

8. 图片“看着上传成功”但提交时字段为空

  • 现象:页面上能看到图片,接口返回 200,但后端收到的图片字段是空的。
  • 原因:只给组件传了初始值做预览,没有 v-model,上传成功的结果没被组件回写;另外后端只要 URL 字符串,而组件给的是对象数组。
  • 修法:补上 v-model 拿到结果数组,再用计算属性取出 url 作为真正提交的字段;上传组件同时限制 type="image" 避免视频混入。

9. 提交接口报错,排查发现少了任务 ID

  • 现象:参数都填了,接口仍失败或 404。
  • 原因:ID 不在表单里,需要从页面地址上取;且字段名往往不是简单的 id,而是 transportTaskId 这类长名。
  • 修法:onLoad(options) 中按地址栏参数名取值赋给 ref,提交时带上;调试前先看 URL 里有没有这个参数,没有就一定是错的。

练习 ​

  1. 给已完成列表补一个“加载中 / 没有更多了”的底部状态区,要求上拉时在请求未返回前不重复触发(加锁或 loading 标志),并说明你的锁放在哪一层。

  2. 现有 hasMore 用总页数判断。请改成用后端返回的总条数 total 与当前已加载条数比较,写出两种方案的判断表达式,并说明各自在“数据被并发修改”场景下的表现差异。

  3. 修复异常类型的重复选择 bug:要求勾选时加入、取消勾选时移除,最终仍以 | 拼接;并进一步实现“再次打开弹层时,上次选中的项默认处于勾选状态”。

  4. 详情页异常信息区块加 v-if 后,请再处理三件事:列表 key 不用索引、无异常时不渲染整个区块容器(包括标题)、异常条数为 1 条时布局不错位。

  5. 把登录页里的 uni[routeType.value]({ url }) 抽成一个可复用的跳转函数,内部用显式分支实现,并说明为什么不能改回动态调用。

原视频对照 ​

#视频时长
1神领物流-上报异常-3.mp404:37
2神领物流-上报异常-410:50
3神领物流-上报异常-509:45
4神领物流-上报异常-608:33
5神领物流-上报异常-704:27
6神领物流-上报异常-812:00
7神领物流-上报异常-907:03

小结 ​

  • 分页列表要同时维护四个状态:当前页、下一页、是否为空、是否还有更多;缺一个就会在某个边界上出问题。
  • “追加”还是“覆盖”只由页码决定,不由调用来源决定;刷新就是第 1 页,上拉就是下一页,判断条件写死比靠调用方约定更可靠。
  • 列表的空白态、加载态、无更多态必须显式建模,靠数据“碰巧为空”来兜底,后续加筛选条件时必然返工。
  • 表单数据一律以接口文档为准,先按文档把字段骨架摆齐再填值,比边写边凑更省调试时间;字段名自己造一个,后端就静默给你一个空值。
  • 组件的“看得见”和“拿得到”是两件事:预览不等于数据,v-model 才是数据通道;凡是上传类组件都要确认结果被回写过。
  • 事件语义决定数据结构:拿到的是集合就别自己维护增量,否则重复项、取消勾选这类问题会反复出现。
  • 跨端打包会暴露开发期隐藏的问题,凡是依赖运行时动态解析的调用都要改成显式写法;打包验证要放在功能联调之后立刻做,不要留到最后。
  • 主链路(能提交、能跳转)优先跑通,交互细节(去重、默认回显、加载锁)留到第二轮补齐,但要在代码里留下明确的待办位置,避免遗忘。