主题
上拉分页与延迟提货(一)
本页对应 上拉分页与延迟提货,共 7 段视频,约 1 小时 3 分。
学习目标
- 能够区分页面滚动与区域滚动,并在小程序页面中配置
scroll-view的scroll-y/scroll-x、固定高度、scrolltolower、refresher-enabled、refresher-triggered等属性,验证区域滚动、触底触发和下拉动画效果。 - 能够实现任务通知列表上拉分页:监听
scrolltolower事件,维护nextPage与hasMore状态,依据后端返回的pages判断是否继续请求,并使用展开运算符合并新旧列表数据,验证滚动到底部可加载下一页且无更多数据时停止请求。 - 能够实现任务通知下拉刷新:监听
refresherrefresh事件,重新请求第一页数据,在请求第一页时重置列表数据,并使用await控制refresher-triggered关闭下拉动画,验证刷新后仅保留第一页数据。 - 能够完成公告列表页面开发:引入
messageAPI,在onMounted中调用封装方法获取公告数据,进行返回状态判断与失败提示,将items渲染到模板中,验证页面加载后请求成功并正确显示公告条数与字段。 - 能够正确使用
:class与class共存及v-if控制未读标识,验证已读状态变化时类名或红点按条件显示与隐藏,并避免静态类名导致未读样式无法去除。
知识点
功能背景与页面入口
为什么需要:司机接到运输任务后,可能因自身情况需要调整到现场提货的时间;但延迟不能随意改,必须提交申请并说明原因,所以要有“延迟提货”功能。
怎么想:入口放在【任务详情】页,只有任务状态为 status === 1 时才显示底部工具栏;跳转时要把当前任务 ID 和原计划提货时间带到延迟提货页。
怎么做:
vue
<!-- pages/subpkg_task/detail.vue -->
<view class="toolbar" v-if="taskDetail.status === 1">
<navigator
:url="`/subpkg_task/delay/index?id=${taskDetail.id}&planDepartureTime=${taskDetail.planDepartureTime}`"
hover-class="none"
class="button secondary"
>
延迟提货
</navigator>
<navigator
:url="`/subpkg_task/pickup/index?id=${taskDetail.id}`"
hover-class="none"
class="button primary"
>
提货
</navigator>
</view>易错点:status 判断不能漏;URL 中两个参数名要和接收端一致;路径别写错。
获取地址参数
为什么需要:延迟时间要基于“原计划提货时间”拼出完整日期,提交接口又需要任务 ID。
怎么想:用 onLoad(query) 接收页面参数,分别存入 ref。
怎么做:
vue
<!-- subpkg_task/delay/index.vue -->
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const planDepartureTime = ref('')
const id = ref('')
onLoad((query) => {
planDepartureTime.value = query.planDepartureTime
id.value = query.id
})
</script>易错点:参数名写成 planDepartureTime、id,不要和入口处不一致;query 可能为空,调试时先确认地址参数是否真的带到。
延迟时间选择
为什么需要:用户要选择延迟到几点,并且延迟不能超过原计划 2 小时,通常仍在同一天。
怎么想:用 picker 的 mode="time" 只选时分;change 事件里拿到 ev.detail.value,再和原计划时间中的日期拼接。
怎么做:
vue
<script setup>
const delayTime = ref('')
function onPickerChange(ev) {
const [date] = planDepartureTime.value.split(' ')
delayTime.value = `${date} ${ev.detail.value}`
}
</script>
<template>
<picker @change="onPickerChange" class="time-picker" mode="time">
<text v-if="!delayTime">不可超过2个小时</text>
<text v-else>{{ delayTime + ':00' }}</text>
</picker>
</template>易错点:不能只拿 ev.detail.value 当时分用,必须拼原计划日期;显示时补 :00,否则日期解析可能不完整。
延迟原因与字数统计
为什么需要:延迟提货必须说明原因,同时限制字数,避免输入过长。
怎么想:用 v-model 绑定 textarea,用 computed 统计长度。
怎么做:
vue
<script setup>
import { ref, computed } from 'vue'
const delayReason = ref('')
const wordsCount = computed(() => delayReason.value.length)
</script>
<template>
<textarea
class="textarea"
v-model="delayReason"
placeholder-style="color: #818181"
placeholder="请输入延迟提货原因"
></textarea>
<text :class="{ error: wordsCount > 50 }" class="words-count">
{{ wordsCount }}/50
</text>
</template>易错点:字数统计要用 computed,不要手写固定值;超过 50 字要给出错误样式。
表单验证
为什么需要:提交前必须拦截非法数据,避免延迟超过 2 小时或原因为空。
怎么想:延迟时间用时间差判断:end - start > 0 && end - start < 7200 * 1000;原因要求 > 0 且 <= 50;按钮禁用状态由两个验证结果共同决定。
怎么做:
vue
<script setup>
const delayTimeValid = computed(() => {
const start = new Date(planDepartureTime.value)
const end = new Date(delayTime.value)
return end - start > 0 && end - start < 7200 * 1000
})
const delayReasonValid = computed(() => {
return wordsCount.value <= 50 && wordsCount.value > 0
})
// 注意:这里实际表示“禁用提交”
const enableSubmit = computed(() => {
return !delayReasonValid.value || !delayTimeValid.value
})
</script>
<template>
<button :disabled="enableSubmit" class="button">提交</button>
</template>易错点:enableSubmit 这个名字容易误解,实际参与 :disabled,含义是“禁用”;时间差单位是毫秒,7200 * 1000 不能少;边界值要按“不超过 2 小时”处理。
提交数据
为什么需要:把延迟时间和原因真正提交给后端,完成后回到任务列表。
怎么想:组装 id、delayTime、delayReason,调用封装好的 taskApi.delay,判断返回 code === 200 再跳转。
怎么做:
javascript
// apis/task.js
export default {
delay(data) {
if (!data.id) return
return uniFetch.put('/driver/tasks/delay', data)
},
}vue
<script setup>
import taskApi from '@/apis/task'
async function onSubmitForm() {
const formData = {
id: id.value,
delayTime: delayTime.value,
delayReason: delayReason.value,
}
const { code } = await taskApi.delay(formData)
if (code !== 200) return uni.utils.toast('提交数据失败,稍后重试!')
uni.reLaunch({ url: '/pages/task/index' })
}
</script>
<template>
<button @click="onSubmitForm" :disabled="enableSubmit" class="button">
提交
</button>
</template>易错点:没有 id 时接口方法直接返回;请求方法按文档用 put;不要只看页面提示,调试时按转写稿中“先确认数据、再看网络请求和返回 code”的节奏排查,先看参数是否正确,再看接口是否成功。
本节目标与整体思路
司机到现场提货完成后,需要把回单凭证和现场货品照片上传到系统,再提交表单。本节用 uniCloud 云空间保存图片,用 uni-file-picker 选择并上传图片,最后把云空间返回的图片地址提交给后端。
为什么需要:提货结果不能只靠文字描述,单据和货品照片是后续核对、异常追溯的依据。
怎么想:先开通并关联云空间,再让上传组件把图片传到云空间,页面只保存图片地址,提交时把地址数组发给后端。
怎么做:按“云空间配置 → 上传组件 → 数据验证 → 提交表单”的顺序完成。
易错点:云空间没关联、AppID 没重新获取、项目没重启,都会导致上传失败;提交前还要确认数组里只保留后端需要的 url。
1.2.1 开通并关联 uniCloud
为什么需要:uni-file-picker 上传图片时要有云空间承接文件,否则图片没有存储位置。
怎么想:先用前面课程注册好的账号登录 uniCloud 控制台,再新建服务空间和开发环境,最后把服务空间关联到项目。
怎么做:
- 使用已注册账号登录 uniCloud 控制台。

- 新建服务空间。

- 新建 uniCloud 开发环境。

- 关联服务空间。


- 获取 AppID。

- 重新启动项目,!!!非常重要!!!
易错点:关联服务空间后不重启项目,页面仍可能读不到云环境;AppID 要确认填到正确位置。转写稿里反复强调“先简单测一测,再往下写”,这里同样适用:先确认云空间关联成功,再写上传逻辑,不要一上来就排查组件代码。
1.2.2 使用 uni-file-picker 上传图片
为什么需要:手写文件选择、上传、回显比较繁琐,uni-file-picker 可以直接完成选择、上传和回显。
怎么想:两个上传区域分别对应“提货凭证”和“货品照片”,各自用 ref 保存文件列表;限制图片类型和数量。
怎么做:
vue
<!-- subpkg_task/pickup/index.vue -->
<script setup>
import { ref } from 'vue'
// 提货凭证图片
const receiptPictrues = ref([])
// 货品图片
const goodsPictrues = ref([])
</script>
<template>
<view class="page-container">
<view class="receipt-info">
<uni-file-picker
v-model="receiptPictrues"
file-extname="jpg,webp,gif,png"
limit="3"
title="请拍照上传回单凭证"
></uni-file-picker>
<uni-file-picker
v-model="goodsPictrues"
file-extname="jpg,webp,gif,png"
limit="3"
title="请拍照上传货品照片"
></uni-file-picker>
</view>
<button disabled class="button">提交</button>
</view>
</template>title:提示上传内容。limit:限制上传数量。file-extname:限制文件类型。v-model:用于回显上传后的图片列表。@success、@fail:上传成功、失败回调,了解即可。
易错点:两个上传区要分别绑定不同数组,不能共用一个 ref;否则凭证和货品照片会混在一起。
1.2.3 用计算属性做提交验证
为什么需要:两类图片都至少要有 1 张,否则提交的数据不完整。
怎么想:提交按钮是否可用,取决于两个图片数组是否都大于 0。用 computed 自动跟随数据变化。
怎么做:
vue
<script setup>
import { ref, computed } from 'vue'
// 省略图片 ref 定义...
// 数据验证
const enableSubmit = computed(() => {
return goodsPictrues.value.length > 0 && receiptPictrues.value.length > 0
})
</script>
<template>
<view class="page-container">
<!-- 省略上传组件 -->
<button :disabled="!enableSubmit" class="button">提交</button>
</view>
</template>易错点:不要用普通变量手写判断,否则图片变化后按钮状态不会自动更新;条件应是“两个都大于 0”。
1.2.4 准备表单数据并提交
为什么需要:后端只接收任务 ID 和图片地址列表,不能把上传组件的完整文件对象直接提交。
怎么想:任务 ID 从地址参数取;图片数组通过 map 二次处理,只保留 url。接口调用单独封装,页面只负责组装数据。
怎么做:
vue
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import taskApi from '@/apis/task'
// 任务ID
const id = ref('')
// 提货凭证图片
const receiptPictrues = ref([])
const cargoPickUpPictureList = computed(() => {
return receiptPictrues.value.map(({ url }) => ({ url }))
})
// 货品图片
const goodsPictrues = ref([])
const cargoPictureList = computed(() => {
return goodsPictrues.value.map(({ url }) => ({ url }))
})
onLoad((query) => {
id.value = query.id
})
// 提交表单数据
async function onSubmitForm() {
const formData = {
id: id.value,
cargoPickUpPictureList: cargoPickUpPictureList.value,
cargoPictureList: cargoPictureList.value,
}
const { code } = await taskApi.pickup(formData)
if (code !== 200) return uni.utils.toast('提交数据失败!')
uni.reLaunch({ url: '/subpkg_task/detail/index?id=' + id.value })
}
</script>
<template>
<view class="page-container">
<!-- 省略上传组件 -->
<button @click="onSubmitForm" :disabled="!enableSubmit" class="button">提交</button>
</view>
</template>接口封装:
javascript
// apis/task.js
export default {
/**
* 提货
* @property {Object} data - 接口参数
*/
pickup(data) {
if (!data.id) return
return uniFetch.post('/driver/tasks/takeDelivery', data)
},
}易错点:cargoPickUpPictureList 和 cargoPictureList 里只放 { url };提交前确认 id 已从 onLoad 取到;await 后判断 code,失败要提示。
调试与验证过程
转写稿主要讲任务通知列表的滚动分页、下拉刷新和公告列表,本节提货没有对应分页逻辑,因此不能把分页步骤硬套到提货上。但转写稿里反复强调的调试节奏可以借用:先验证接口和数据,再做页面处理;异步请求要等待结果。转写稿中“上拉加窄”“斯罗VU”“配ジ”等是转写误差,应理解为“上拉加载”“scroll-view”“page”等。对应到本节:先确认云空间能上传,再检查 v-model 是否回显,最后在提交前查看两个图片数组是否只包含 url。若提交失败,先看 id 是否存在、接口返回 code 是否为 200,再检查图片数组结构,避免把组件完整对象直接发给后端。
代码示例
示例一:延迟提货页面(subpkg_task/delay/index.vue)
vue
<!-- apis/task.js -->
<script>
export default {
/**
* 延迟提货
* @param {Object} data - 接口参数
*/
delay(data) {
if (!data.id) return
return uniFetch.put('/driver/tasks/delay', data)
},
}
</script>vue
<!-- subpkg_task/delay/index.vue -->
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import taskApi from '@/apis/task'
// 原计划提货时间
const planDepartureTime = ref('')
// 延迟提货任务的 ID
const id = ref('')
// 延迟提货时间
const delayTime = ref('')
// 延迟原因
const delayReason = ref('')
// 获取地址上的参数
onLoad((query) => {
planDepartureTime.value = query.planDepartureTime
id.value = query.id
})
// 监听用户选择时间
function onPickerChange(ev) {
// 日期部分要与原计划提货日期对应,只取时分拼接
const [date] = planDepartureTime.value.split(' ')
delayTime.value = `${date} ${ev.detail.value}`
}
// 统计输入的字数
const wordsCount = computed(() => delayReason.value.length)
// 验证延迟时间:不超过原计划时间 2 小时
const delayTimeValid = computed(() => {
const start = new Date(planDepartureTime.value)
const end = new Date(delayTime.value)
// 时间差区间在 0 ~ 7200 * 1000 毫秒
return end - start > 0 && end - start < 7200 * 1000
})
// 验证延迟原因:1 ~ 50 字
const delayReasonValid = computed(() => {
return wordsCount.value <= 50 && wordsCount.value > 0
})
// 是否禁用提交按钮
const enableSubmit = computed(() => {
return !delayReasonValid.value || !delayTimeValid.value
})
// 提交表单数据
async function onSubmitForm() {
const formData = {
id: id.value,
delayTime: delayTime.value,
delayReason: delayReason.value,
}
const { code } = await taskApi.delay(formData)
if (code !== 200) return uni.utils.toast('提交数据失败,稍后重试!')
uni.reLaunch({ url: '/pages/task/index' })
}
</script>
<template>
<view class="page-container">
<uni-list>
<uni-list-item title="延迟时间" showArrow>
<template v-slot:footer>
<picker @change="onPickerChange" class="time-picker" mode="time">
<text v-if="!delayTime">不可超过2个小时</text>
<text v-else :class="{ error: !delayTimeValid }">{{ delayTime + ':00' }}</text>
</picker>
</template>
</uni-list-item>
<uni-list-item direction="column">
<template v-slot:body>
<view class="textarea-wrapper">
<textarea
class="textarea"
v-model="delayReason"
placeholder-style="color: #818181"
placeholder="请输入延迟提货原因"
></textarea>
<text :class="{ error: wordsCount > 50 }" class="words-count">
{{ wordsCount }}/50
</text>
</view>
</template>
</uni-list-item>
<uni-list-item :border="false">
<template v-slot:body>
<button @click="onSubmitForm" :disabled="enableSubmit" class="button">
提交
</button>
</template>
</uni-list-item>
</uni-list>
</view>
</template>
<style lang="scss" scoped>
.page-container {
padding: 30rpx;
}
.time-picker {
color: #818181;
}
.error {
color: #ff0000;
}
.textarea-wrapper {
position: relative;
width: 100%;
}
.textarea {
width: 100%;
height: 200rpx;
font-size: 28rpx;
}
.words-count {
position: absolute;
right: 0;
bottom: 0;
font-size: 24rpx;
color: #818181;
}
.button {
background-color: #5591af;
color: #fff;
border-radius: 40rpx;
}
</style>关键行与易错点
- 入口在【任务详情】页用
navigator跳转,URL 上带id与planDepartureTime;本页必须用onLoad((query) => {...})接参,不能写在setup顶层。 picker的mode="time"只返回HH:mm,必须用planDepartureTime.split(' ')[0]拼回原日期,否则new Date()解析不到合法日期,验证永远失败。delayTimeValid用毫秒差判断:end - start > 0 && end - start < 7200 * 1000,即"只能往后延、且不超过 2 小时"。- 注意
enableSubmit这个变量名——它算的是"是否禁用",所以模板里写:disabled="enableSubmit";写成:disabled="!enableSubmit"会导致验证通过时反而不能提交,这是最常见的错。 delayTime存的是YYYY-MM-DD HH:mm,展示时补':00',但提交给后端仍用原值。- 接口封装里
if (!data.id) return是防御式写法,缺id时不发请求。
示例二:提货页面(subpkg_task/pickup/index.vue)
vue
<!-- apis/task.js 追加 -->
<script>
export default {
/**
* 提货
* @property {Object} data - 接口参数
*/
pickup(data) {
if (!data.id) return
return uniFetch.post('/driver/tasks/takeDelivery', data)
},
}
</script>vue
<!-- subpkg_task/pickup/index.vue -->
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import taskApi from '@/apis/task'
// 任务 ID
const id = ref('')
// 提货凭证图片
const receiptPictrues = ref([])
// 货品图片
const goodsPictrues = ref([])
onLoad((query) => {
// 获取任务 ID
id.value = query.id
})
// 数据二次处理,只保留 url 属性
const cargoPickUpPictureList = computed(() => {
return receiptPictrues.value.map(({ url }) => {
return { url }
})
})
// 数据二次处理,只保留 url 属性
const cargoPictureList = computed(() => {
return goodsPictrues.value.map(({ url }) => {
return { url }
})
})
// 数据验证:两类图片各至少 1 张
const enableSubmit = computed(() => {
return goodsPictrues.value.length > 0 && receiptPictrues.value.length > 0
})
// 提交表单数据
async function onSubmitForm() {
const formData = {
id: id.value,
cargoPickUpPictureList: cargoPickUpPictureList.value,
cargoPictureList: cargoPictureList.value,
}
const { code } = await taskApi.pickup(formData)
if (code !== 200) return uni.utils.toast('提交数据失败!')
uni.reLaunch({ url: '/subpkg_task/detail/index?id=' + id.value })
}
</script>
<template>
<view class="page-container">
<view class="receipt-info">
<uni-file-picker
v-model="receiptPictrues"
file-extname="jpg,webp,gif,png"
limit="3"
title="请拍照上传回单凭证"
></uni-file-picker>
<uni-file-picker
v-model="goodsPictrues"
file-extname="jpg,webp,gif,png"
limit="3"
title="请拍照上传货品照片"
></uni-file-picker>
</view>
<button @click="onSubmitForm" :disabled="!enableSubmit" class="button">提交</button>
</view>
</template>
<style lang="scss" scoped>
.page-container {
padding: 30rpx;
}
.receipt-info {
padding-bottom: 40rpx;
}
.button {
background-color: #5591af;
color: #fff;
border-radius: 40rpx;
}
</style>关键行与易错点
- 使用
uni-file-picker前必须先开通并关联 uniCloud 服务空间、拿到 AppID,然后重新启动项目(这一步非常重要,漏了会一直上传失败)。 title提示内容、limit限制数量、file-extname限制类型、v-model用于回显图片。- 易错点:两个
uni-file-picker都要绑v-model,第二个若漏写(笔记初版只有第一个有v-model),goodsPictrues永远为空数组,验证永远不通过、按钮永远置灰。 - 提交给后端的
cargoPickUpPictureList/cargoPictureList只保留url字段,用map(({ url }) => ({ url }))做二次处理,不要把整个组件返回对象塞进去。 - 本页的
enableSubmit语义是"允许提交",模板里是:disabled="!enableSubmit",和延迟提货页正好相反,两页对照看别混。 - 提交成功后跳回详情页用
uni.reLaunch,并把id带回去。
示例三:任务通知列表 —— 上拉分页 + 下拉刷新(scroll-view)
vue
<!-- pages/message/notify/index.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import messageApi from '@/apis/message'
// 通知列表数据
const notifyList = ref([])
// 下一页页码
const nextPage = ref(1)
// 是否还有更多数据
const hasMore = ref(true)
// 下拉刷新动画状态
const isTrigger = ref(false)
// 获取通知列表
async function getNotifyList(page = 1) {
const { code, data } = await messageApi.list(200, page, 5)
if (code !== 200) return uni.utils.toast('获取数据失败')
// 请求第一页(下拉刷新)时清空旧数据,避免累加
if (page === 1) {
notifyList.value = []
}
// 数据追加,而不是覆盖
notifyList.value = [...notifyList.value, ...data.items]
// 计算下一页页码:当前页 + 1
nextPage.value = ++data.page
// 判断是否还有下一页
hasMore.value = nextPage.value <= data.pages
}
// 监听用户是否滚动到页面底部
async function onScrollToLower() {
// 没有更多数据则不再发请求
if (!hasMore.value) return
await getNotifyList(nextPage.value)
}
// 监听用户的下拉操作
async function onRefresh() {
// 开启下拉动画
isTrigger.value = true
// 不传页码,默认请求第一页
await getNotifyList()
// 必须等请求结束再关闭动画
isTrigger.value = false
}
onMounted(() => {
getNotifyList()
})
</script>
<template>
<scroll-view
class="scroll-view"
scroll-y
refresher-enabled
:refresher-triggered="isTrigger"
@scrolltolower="onScrollToLower"
@refresherrefresh="onRefresh"
>
<view v-for="item in notifyList" :key="item.id" class="item">
<text class="title">{{ item.title }}</text>
<text class="time">{{ item.createdAt }}</text>
<text v-if="!item.isRead" class="dot"></text>
</view>
</scroll-view>
</template>
<style lang="scss" scoped>
.scroll-view {
height: 100vh; /* 必须限定高度,否则不会滚动 */
}
.item {
position: relative;
padding: 30rpx;
border-bottom: 1rpx solid #eee;
}
.title {
font-size: 30rpx;
}
.time {
display: block;
font-size: 24rpx;
color: #818181;
}
.dot {
position: absolute;
top: 30rpx;
right: 30rpx;
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background-color: #ff0000;
}
</style>关键行与易错点
scroll-view只负责"区域滚动",必须给高度(如height: 100vh),否则容器被子元素撑开、永远不出现滚动;本项目里就是把它做成和页面一样大,造成"页面在滚动"的假象。- 上拉触底监听
@scrolltolower(对应scrolltolower事件),下拉监听@refresherrefresh,下拉动画由refresher-enabled开启、refresher-triggered控制。 nextPage.value = ++data.page是前置自增,先加 1 再赋值;写成data.page++逻辑就对不上,下一页会重复请求当前页。- 数据必须追加(
[...notifyList.value, ...data.items]),直接=会覆盖,滚动多次列表仍只有 5 条。 - 下拉刷新本质是"重新请求第一页",但追加逻辑会把新旧数据拼在一起(出现 10 条);所以
page === 1时先把notifyList置空数组。 isTrigger必须在await之后才置为false,否则请求还没回来动画就消失了;转写稿里特别强调了这个await。- 没有更多数据时(
hasMore为false)要在onScrollToLower里return,否则触底仍会一直发请求。 - 未读红点用
v-if="!item.isRead";若类名同时写了静态class="dot"又写:class,静态类名会一直生效导致红点去不掉。
常见错误
延迟时间提交后后端报格式错误 / 时间对不上
- 现象:页面显示
10:30,提交上去只有时分,或日期变成了今天。 - 原因:
picker mode="time"只返回HH:mm,日期部分缺失;直接new Date(delayTime)得到的是无效或错误日期。 - 修法:从
planDepartureTime里split(' ')取日期,拼成${date} ${ev.detail.value};提交前补:00成完整秒级时间。
- 现象:页面显示
iOS 真机上
delayTimeValid恒为false,按钮永远点不了- 现象:安卓正常,iOS 上延迟时间选完仍不可提交。
- 原因:
new Date('2024-05-20 10:30:00')这种带-且含空格的字符串在 iOS 上解析为Invalid Date,相减得到NaN。 - 修法:把
-换成/再解析,或用正则拆成年月日时分秒分别new Date(y, m-1, d, h, mi, s)。
提交按钮的禁用逻辑写反
- 现象:表单没填完按钮可以点,填完了反而变灰。
- 原因:
enableSubmit这个命名算出来的是"不允许提交",却直接绑到了:disabled上,语义和用法互相抵消。 - 修法:要么改名
disabledSubmit直接绑定,要么保持enableSubmit语义、模板里写:disabled="!enableSubmit",命名和用法必须一致。
接口封装里
if (!data.id) return导致页面解构报错- 现象:
onLoad没拿到id时,const { code } = await taskApi.delay(...)抛 "Cannot destructure property 'code' of undefined"。 - 原因:
onLoad回调参数漏写(写成onLoad(() => {}))或用onMounted去读路由参数,id为空;而接口函数空return返回的是undefined而不是 Promise。 - 修法:统一用
onLoad((query) => ...)取参;接口函数无参时return Promise.reject(...),页面侧await前先判空。
- 现象:
提货页第二个
uni-file-picker没绑v-model- 现象:货品照片明明选了,提交时
cargoPictureList是空数组,按钮还一直禁用。 - 原因:只给回单凭证的 picker 加了
v-model,货品照片的 picker 漏绑,选完的图没有落到任何响应式变量里。 - 修法:两个 picker 各自绑定
receiptPictrues/goodsPictrues,并确认enableSubmit校验的是这两个变量。
- 现象:货品照片明明选了,提交时
把 picker 返回的完整对象数组原样提交
- 现象:接口 400,或后端入库字段一堆
name、size、extname、uuid。 - 原因:
uni-file-picker的v-model里是完整文件对象,而接口只要[{ url }]。 - 修法:用
computed做一次map(({ url }) => ({ url }))的二次处理,提交时用处理后的数组。
- 现象:接口 400,或后端入库字段一堆
关联 uniCloud 服务空间后上传一直失败
- 现象:选图后进度条卡住或提示上传失败,控制台报空间不存在。
- 原因:改了
AppID/ 关联服务空间后没有重启项目,运行环境还是旧的。 - 修法:关联完成后必须重新启动项目;再确认
manifest.json里云开发环境已勾选。
scroll-view内容超出却不滚动- 现象:内容撑满整屏,滚的是整个页面,
scrolltolower也不触发。 - 原因:没写
scroll-y,或scroll-view自身没给高度(被子元素撑开后没有滚动余量)。 - 修法:加
scroll-y并显式设置高度(如height: 100vh),同时确保外层容器高度已确定。
- 现象:内容撑满整屏,滚的是整个页面,
上拉分页数据不累加 / 页码不变
- 现象:滚了五次列表还是 5 条;或每次都请求第 1 页。
- 原因:用等号直接覆盖
list.value = res.items;nextPage声明后忘了自增,一直传初始值。 - 修法:
list.value = [...list.value, ...res.items];请求成功后nextPage.value = data.page + 1(注意是++nextPage.value而非nextPage.value++)。
下拉刷新后列表条数翻倍,动画一直转圈
- 现象:下拉一次数据从 15 条变 20 条;刷新动画不消失。
- 原因:刷新复用了追加逻辑,第一页数据被追加而非替换;
refreshTrigger在异步请求完成前就被置false(少了await)。 - 修法:请求前判断
page === 1则先把列表置空;await拿到结果后再把refreshTrigger置false。
滚动到底后仍在发请求
- 现象:最后一页之后控制台还在刷网络请求。
- 原因:
hasMore算出来了但没有在回调里提前return,只是记录了状态。 - 修法:进入回调第一行就
if (!hasMore.value) return。
公告未读小圆点去不掉
- 现象:
isRead改成 1 了,红点还在。 - 原因:静态
class里保留了unread,v-bind:class只是叠加,不会覆盖静态类名。 - 修法:把静态
class里的unread删掉,只保留:class="{ unread: !item.isRead }"。
- 现象:
练习
延迟提货防重复提交:给"提交"按钮加节流(或
loading锁),保证连点两次只发一次请求,请求失败后能恢复可点状态。要求不引入第三方库。延迟时间选择器加下限:通过
picker的start属性,让用户只能选择"原计划提货时间之后、2 小时以内"的时间;超出范围时输入框标红并禁用提交。抽取分页组合式函数:把上拉分页的逻辑(
list、nextPage、hasMore、loading、loadMore、refresh)抽成usePagination(fetcher),让任务通知列表和公告列表共用,注意处理"请求中不重复触发"和"第一页清空"两件事。提货页上传未完成时的提交保护:模拟弱网,让图片还在上传中就点提交,观察会发生什么;然后改造为"有文件处于上传中时按钮禁用,并给出提示"。
公告列表本地已读:点击某条未读公告后,把该条的
isRead改为 1(不调接口,仅本地),验证红点消失、且不影响其他条目;再思考如果列表数据来自分页追加,怎样避免"刷新后本地状态被冲掉"。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 神领物流-任务通知上拉分页-1 | 12:37 |
| 2 | 补充scroll-view组件使用说明 | 11:11 |
| 3 | 神领物流-任务通知上拉分页-2 | 06:38 |
| 4 | 神领物流-任务通知上拉分页-3 | 05:36 |
| 5 | 神领物流-任务通知下拉刷新-1 | 09:07 |
| 6 | 神领物流-任务通知下拉刷新-2 | 04:58 |
| 7 | 神领物流-公告列表 | 12:59 |
小结
页面参数只负责"传"、校验要在页面里"守":
id、planDepartureTime这类关键参数进入页面后先判空,任何依赖它们的接口调用都要有兜底,否则一个空id就会把整条链路打断。表单三件套的写法是固定的:响应式数据 →
computed校验 →computed汇总成提交开关,三步分离后模板只负责绑定,改校验规则时不用动template。接口数据的"形状"往往和组件给的不一样:
uni-file-picker给完整文件对象、后端只要url,中间必须有一层computed做裁剪,不要指望后端兼容前端组件的输出。scroll-view的滚动前提是"高度确定 + 方向开启",两个条件缺一不可;项目里把它做成整屏大小,是为了把页面滚动收敛到一个可控区域内,下拉刷新、上拉加载才有稳定的挂载点。分页的三个变量缺一不可:
page(下一页取几)、hasMore(还发不发)、list(是覆盖还是追加)。page取自响应里的当前页而不是自己++计数,能避免接口返回异常时的页码错位。"追加"和"覆盖"是同一份列表的两种语义:上拉是追加,下拉刷新是覆盖,所以刷新分支必须先清空再赋值,否则数据越刷越多。
下拉动画的关闭必须放在
await之后:任何"请求结束后才收尾"的 UI 状态(loading、动画、按钮禁用),都要用try/finally或await保证时序,否则异步一快一慢就会出现状态残留。静态
class和:class是叠加关系不是替换关系:写条件样式前先把静态类名里的"状态类"清干净,否则判断逻辑再对,样式也永远生效。