主题
异常上报(三)
本页对应 异常上报,共 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 端:




打包结果位于项目根目录下的 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、模块配置,再走云打包。
怎么做:
- 配置 App 图标:

- 配置启动界面(闪屏):

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

- 配置模块:

- 云打包:



易错点: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 里有没有这个参数,没有就一定是错的。
练习
给已完成列表补一个“加载中 / 没有更多了”的底部状态区,要求上拉时在请求未返回前不重复触发(加锁或
loading标志),并说明你的锁放在哪一层。现有
hasMore用总页数判断。请改成用后端返回的总条数total与当前已加载条数比较,写出两种方案的判断表达式,并说明各自在“数据被并发修改”场景下的表现差异。修复异常类型的重复选择 bug:要求勾选时加入、取消勾选时移除,最终仍以
|拼接;并进一步实现“再次打开弹层时,上次选中的项默认处于勾选状态”。详情页异常信息区块加
v-if后,请再处理三件事:列表key不用索引、无异常时不渲染整个区块容器(包括标题)、异常条数为 1 条时布局不错位。把登录页里的
uni[routeType.value]({ url })抽成一个可复用的跳转函数,内部用显式分支实现,并说明为什么不能改回动态调用。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 神领物流-上报异常-3.mp4 | 04:37 |
| 2 | 神领物流-上报异常-4 | 10:50 |
| 3 | 神领物流-上报异常-5 | 09:45 |
| 4 | 神领物流-上报异常-6 | 08:33 |
| 5 | 神领物流-上报异常-7 | 04:27 |
| 6 | 神领物流-上报异常-8 | 12:00 |
| 7 | 神领物流-上报异常-9 | 07:03 |
小结
- 分页列表要同时维护四个状态:当前页、下一页、是否为空、是否还有更多;缺一个就会在某个边界上出问题。
- “追加”还是“覆盖”只由页码决定,不由调用来源决定;刷新就是第 1 页,上拉就是下一页,判断条件写死比靠调用方约定更可靠。
- 列表的空白态、加载态、无更多态必须显式建模,靠数据“碰巧为空”来兜底,后续加筛选条件时必然返工。
- 表单数据一律以接口文档为准,先按文档把字段骨架摆齐再填值,比边写边凑更省调试时间;字段名自己造一个,后端就静默给你一个空值。
- 组件的“看得见”和“拿得到”是两件事:预览不等于数据,
v-model才是数据通道;凡是上传类组件都要确认结果被回写过。 - 事件语义决定数据结构:拿到的是集合就别自己维护增量,否则重复项、取消勾选这类问题会反复出现。
- 跨端打包会暴露开发期隐藏的问题,凡是依赖运行时动态解析的调用都要改成显式写法;打包验证要放在功能联调之后立刻做,不要留到最后。
- 主链路(能提交、能跳转)优先跑通,交互细节(去重、默认回显、加载锁)留到第二轮补齐,但要在代码里留下明确的待办位置,避免遗忘。