Skip to content

上拉分页与延迟提货(一) ​

本页对应 上拉分页与延迟提货,共 7 段视频,约 1 小时 3 分。

学习目标 ​

  1. 能够区分页面滚动与区域滚动,并在小程序页面中配置 scroll-view 的 scroll-y/scroll-x、固定高度、scrolltolower、refresher-enabled、refresher-triggered 等属性,验证区域滚动、触底触发和下拉动画效果。
  2. 能够实现任务通知列表上拉分页:监听 scrolltolower 事件,维护 nextPage 与 hasMore 状态,依据后端返回的 pages 判断是否继续请求,并使用展开运算符合并新旧列表数据,验证滚动到底部可加载下一页且无更多数据时停止请求。
  3. 能够实现任务通知下拉刷新:监听 refresherrefresh 事件,重新请求第一页数据,在请求第一页时重置列表数据,并使用 await 控制 refresher-triggered 关闭下拉动画,验证刷新后仅保留第一页数据。
  4. 能够完成公告列表页面开发:引入 message API,在 onMounted 中调用封装方法获取公告数据,进行返回状态判断与失败提示,将 items 渲染到模板中,验证页面加载后请求成功并正确显示公告条数与字段。
  5. 能够正确使用 :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 控制台,再新建服务空间和开发环境,最后把服务空间关联到项目。
怎么做:

  1. 使用已注册账号登录 uniCloud 控制台。

uniCloud 登录控制台

  1. 新建服务空间。

新建服务空间

  1. 新建 uniCloud 开发环境。

新建 uniCloud 开发环境

  1. 关联服务空间。

关联服务空间

关联服务空间确认

  1. 获取 AppID。

获取 AppID

  1. 重新启动项目,!!!非常重要!!!

易错点:关联服务空间后不重启项目,页面仍可能读不到云环境;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,静态类名会一直生效导致红点去不掉。

常见错误 ​

  1. 延迟时间提交后后端报格式错误 / 时间对不上

    • 现象:页面显示 10:30,提交上去只有时分,或日期变成了今天。
    • 原因:picker mode="time" 只返回 HH:mm,日期部分缺失;直接 new Date(delayTime) 得到的是无效或错误日期。
    • 修法:从 planDepartureTime 里 split(' ') 取日期,拼成 ${date} ${ev.detail.value};提交前补 :00 成完整秒级时间。
  2. 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)。
  3. 提交按钮的禁用逻辑写反

    • 现象:表单没填完按钮可以点,填完了反而变灰。
    • 原因:enableSubmit 这个命名算出来的是"不允许提交",却直接绑到了 :disabled 上,语义和用法互相抵消。
    • 修法:要么改名 disabledSubmit 直接绑定,要么保持 enableSubmit 语义、模板里写 :disabled="!enableSubmit",命名和用法必须一致。
  4. 接口封装里 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 前先判空。
  5. 提货页第二个 uni-file-picker 没绑 v-model

    • 现象:货品照片明明选了,提交时 cargoPictureList 是空数组,按钮还一直禁用。
    • 原因:只给回单凭证的 picker 加了 v-model,货品照片的 picker 漏绑,选完的图没有落到任何响应式变量里。
    • 修法:两个 picker 各自绑定 receiptPictrues / goodsPictrues,并确认 enableSubmit 校验的是这两个变量。
  6. 把 picker 返回的完整对象数组原样提交

    • 现象:接口 400,或后端入库字段一堆 name、size、extname、uuid。
    • 原因:uni-file-picker 的 v-model 里是完整文件对象,而接口只要 [{ url }]。
    • 修法:用 computed 做一次 map(({ url }) => ({ url })) 的二次处理,提交时用处理后的数组。
  7. 关联 uniCloud 服务空间后上传一直失败

    • 现象:选图后进度条卡住或提示上传失败,控制台报空间不存在。
    • 原因:改了 AppID / 关联服务空间后没有重启项目,运行环境还是旧的。
    • 修法:关联完成后必须重新启动项目;再确认 manifest.json 里云开发环境已勾选。
  8. scroll-view 内容超出却不滚动

    • 现象:内容撑满整屏,滚的是整个页面,scrolltolower 也不触发。
    • 原因:没写 scroll-y,或 scroll-view 自身没给高度(被子元素撑开后没有滚动余量)。
    • 修法:加 scroll-y 并显式设置高度(如 height: 100vh),同时确保外层容器高度已确定。
  9. 上拉分页数据不累加 / 页码不变

    • 现象:滚了五次列表还是 5 条;或每次都请求第 1 页。
    • 原因:用等号直接覆盖 list.value = res.items;nextPage 声明后忘了自增,一直传初始值。
    • 修法:list.value = [...list.value, ...res.items];请求成功后 nextPage.value = data.page + 1(注意是 ++nextPage.value 而非 nextPage.value++)。
  10. 下拉刷新后列表条数翻倍,动画一直转圈

    • 现象:下拉一次数据从 15 条变 20 条;刷新动画不消失。
    • 原因:刷新复用了追加逻辑,第一页数据被追加而非替换;refreshTrigger 在异步请求完成前就被置 false(少了 await)。
    • 修法:请求前判断 page === 1 则先把列表置空;await 拿到结果后再把 refreshTrigger 置 false。
  11. 滚动到底后仍在发请求

    • 现象:最后一页之后控制台还在刷网络请求。
    • 原因:hasMore 算出来了但没有在回调里提前 return,只是记录了状态。
    • 修法:进入回调第一行就 if (!hasMore.value) return。
  12. 公告未读小圆点去不掉

    • 现象:isRead 改成 1 了,红点还在。
    • 原因:静态 class 里保留了 unread,v-bind:class 只是叠加,不会覆盖静态类名。
    • 修法:把静态 class 里的 unread 删掉,只保留 :class="{ unread: !item.isRead }"。

练习 ​

  1. 延迟提货防重复提交:给"提交"按钮加节流(或 loading 锁),保证连点两次只发一次请求,请求失败后能恢复可点状态。要求不引入第三方库。

  2. 延迟时间选择器加下限:通过 picker 的 start 属性,让用户只能选择"原计划提货时间之后、2 小时以内"的时间;超出范围时输入框标红并禁用提交。

  3. 抽取分页组合式函数:把上拉分页的逻辑(list、nextPage、hasMore、loading、loadMore、refresh)抽成 usePagination(fetcher),让任务通知列表和公告列表共用,注意处理"请求中不重复触发"和"第一页清空"两件事。

  4. 提货页上传未完成时的提交保护:模拟弱网,让图片还在上传中就点提交,观察会发生什么;然后改造为"有文件处于上传中时按钮禁用,并给出提示"。

  5. 公告列表本地已读:点击某条未读公告后,把该条的 isRead 改为 1(不调接口,仅本地),验证红点消失、且不影响其他条目;再思考如果列表数据来自分页追加,怎样避免"刷新后本地状态被冲掉"。

原视频对照 ​

#视频时长
1神领物流-任务通知上拉分页-112:37
2补充scroll-view组件使用说明11:11
3神领物流-任务通知上拉分页-206:38
4神领物流-任务通知上拉分页-305:36
5神领物流-任务通知下拉刷新-109:07
6神领物流-任务通知下拉刷新-204:58
7神领物流-公告列表12:59

小结 ​

  1. 页面参数只负责"传"、校验要在页面里"守":id、planDepartureTime 这类关键参数进入页面后先判空,任何依赖它们的接口调用都要有兜底,否则一个空 id 就会把整条链路打断。

  2. 表单三件套的写法是固定的:响应式数据 → computed 校验 → computed 汇总成提交开关,三步分离后模板只负责绑定,改校验规则时不用动 template。

  3. 接口数据的"形状"往往和组件给的不一样:uni-file-picker 给完整文件对象、后端只要 url,中间必须有一层 computed 做裁剪,不要指望后端兼容前端组件的输出。

  4. scroll-view 的滚动前提是"高度确定 + 方向开启",两个条件缺一不可;项目里把它做成整屏大小,是为了把页面滚动收敛到一个可控区域内,下拉刷新、上拉加载才有稳定的挂载点。

  5. 分页的三个变量缺一不可:page(下一页取几)、hasMore(还发不发)、list(是覆盖还是追加)。page 取自响应里的当前页而不是自己 ++ 计数,能避免接口返回异常时的页码错位。

  6. "追加"和"覆盖"是同一份列表的两种语义:上拉是追加,下拉刷新是覆盖,所以刷新分支必须先清空再赋值,否则数据越刷越多。

  7. 下拉动画的关闭必须放在 await 之后:任何"请求结束后才收尾"的 UI 状态(loading、动画、按钮禁用),都要用 try/finally 或 await 保证时序,否则异步一快一慢就会出现状态残留。

  8. 静态 class 和 :class 是叠加关系不是替换关系:写条件样式前先把静态类名里的"状态类"清干净,否则判断逻辑再对,样式也永远生效。