Skip to content

表单验证与任务通知(一) ​

本页对应 表单验证与任务通知,共 7 段视频,约 1 小时 11 分。

学习目标 ​

  1. 能够使用 uni-forms、uni-forms-item 与 v-model 绑定表单数据,并通过调试工具验证已正确获取账号、密码等表单项值。
  2. 能够为不同表单项定义 rules,使用 required、pattern、errorMessage 配置必填与格式校验,并将规则正确绑定到表单。
  3. 能够通过 ref 获取表单实例,在提交事件中调用 validate 完成校验,并使用 try/catch 区分校验成功与失败,输出对应结果或错误提示。
  4. 能够封装登录 API 方法,校验入参后以 POST 方式提交账号和密码,并在页面提交时正确调用该 API、查看请求与响应结果。
  5. 能够配置响应拦截器过滤接口返回的多余数据,仅返回 data,并在登录流程中正确解构 code、data 等字段。
  6. 能够定义 Pinia 用户 Store,声明并持久化 token,在登录成功后写入 token,并通过本地存储验证持久化结果。

知识点 ​

跨端兼容:用 v-if + v-show 做标签页切换 ​

为什么需要:uni-app 要跨端运行,小程序不支持 Vue 的内置组件 Component 和 Keep-Alive。如果直接照搬 Web 端标签页方案,切回已打开的标签时组件可能被销毁重建,状态丢失,体验变差。
怎么想:把“是否加载过”和“当前显示哪一个”拆成两个职责:v-if 负责首次加载,v-show 负责显示隐藏。这样组件只加载一次,后续切换只改 CSS 显示状态。
怎么做:

vue
<!-- pages/message/index.vue -->
<script setup>
import { ref, reactive } from 'vue'
import slNotify from './components/notify'
import slAnnounce from './components/announce'

const tabIndex = ref(0)
const tabMetas = reactive([
  { title: '任务通知', rendered: true },
  { title: '公告', rendered: false },
])

function onTabChange(index) {
  tabMetas[index].rendered = true
  tabIndex.value = index
}
</script>

<template>
  <view class="page-container">
    <view v-show="tabIndex === 0" v-if="tabMetas[0].rendered" class="message-list">
      <sl-notify />
    </view>
    <view v-show="tabIndex === 1" v-if="tabMetas[1].rendered" class="message-list">
      <sl-announce />
    </view>
  </view>
</template>

rendered 为 true 表示组件已加载过;tabIndex 控制当前显示哪个组件。
易错点:只写 v-if 会导致切换时反复销毁重建;只写 v-show 又会让未访问过的组件一开始就加载。二者要配合使用。

任务通知列表:先封装 API,再渲染数据 ​

为什么需要:消息列表、分页、刷新都依赖同一个接口,直接散落在组件里会重复且难维护。
怎么想:先到 apis 目录建消息请求模块,再在组件里调用。接口返回后先判断 code,再取 data.items 渲染。调试时不要急着解构,先打印返回结果,确认 items、page、pages 是否存在,这一点和转写稿中强调的“先看返回结果再解构”一致。
怎么做:

javascript
// apis/message.js
import { uniFetch } from './uni-fetch'

export default {
  list(contentType, page = 1, pageSize = 10) {
    if (!contentType) return
    return uniFetch.get('/driver/messages/page', { contentType, page, pageSize })
  },
}
vue
<!-- pages/message/components/notify.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import messageApi from '@/apis/message'

const notifyList = ref([])
const nextPage = ref(1)
const pageSize = ref(5)
const isEmpty = ref(false)

onMounted(() => {
  getNotifyList()
})

async function getNotifyList(page = 1, pageSize = 10) {
  const { code, data } = await messageApi.list(201, page, pageSize)
  if (code !== 200) return uni.utils.toast('获取通知失败,稍后重试!')
  notifyList.value = data.items || []
  isEmpty.value = notifyList.value.length === 0
}
</script>

<template>
  <scroll-view class="scroll-view" refresher-enabled scroll-y>
    <view class="scroll-view-wrapper">
      <view class="message-action">
        <text class="iconfont icon-clear"></text> 全部已读
      </view>
      <uni-card
        v-for="notify in notifyList"
        :key="notify.id"
        :border="false"
        :is-shadow="false"
      >
        <view class="brief">{{ notify.content }}</view>
        <view class="extra">
          <text class="time">{{ notify.created }}</text>
          <navigator
            hover-class="none"
            class="link"
            :url="`/subpkg_task/detail/index?id=${notify.id}`"
          >查看详情</navigator>
        </view>
        <template v-slot:title>
          <view :class="{ unread: !notify.isRead }" class="title unread">
            您有新的运输任务
          </view>
        </template>
      </uni-card>
      <view v-if="isEmpty" class="message-blank">暂无消息</view>
    </view>
  </scroll-view>
</template>

易错点:isRead 为 1 表示已读,为 0 表示未读;空列表要显示占位提示。组件内使用 Vue 生命周期 onMounted,不要误写成页面生命周期。

上拉分页:监听 scrolltolower,追加数据并判断 hasMore ​

为什么需要:移动端列表数据多,不能一次全拉;滚动到底部再请求下一页更符合使用习惯。
怎么想:scroll-view 的 scrolltolower 触发时请求下一页;下一页数据要追加到原数组;用返回的总页码 data.pages 与 nextPage 比较,判断是否还有更多。
怎么做:

vue
<script setup>
import { ref, onMounted } from 'vue'
import messageApi from '@/apis/message'

const notifyList = ref([])
const nextPage = ref(1)
const pageSize = ref(5)
const isEmpty = ref(false)
const hasMore = ref(true)

onMounted(() => {
  getNotifyList()
})

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

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

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

易错点:分页数据不要覆盖原数组;nextPage 依据返回的 page 加 1;没有更多数据时要提前 return,避免重复请求。

下拉刷新:重新请求第 1 页,并关闭刷新动画 ​

为什么需要:用户下拉时希望及时看到最新消息。
怎么想:监听 refresherrefresh,本质是重新请求第 1 页;请求前要清空原列表,否则会重复拼接。刷新动画通过 refresher-triggered 控制,请求结束后再关闭。
怎么做:

vue
<script setup>
import { ref, onMounted } from 'vue'
import messageApi from '@/apis/message'

const notifyList = ref([])
const nextPage = ref(1)
const pageSize = ref(5)
const isEmpty = ref(false)
const hasMore = ref(true)
const isTriggered = ref(false)

async function onScrollViewRefresh() {
  isTriggered.value = true
  await getNotifyList()
  isTriggered.value = false
}

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

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

易错点:忘记 await 会在请求未完成时关闭动画;第 1 页不清空会导致数据重复;refresher-triggered 拼写和取值要正确。

公告列表:复用同一接口,注意 contentType 不同 ​

为什么需要:公告和任务通知结构相似,但接口参数不同。
怎么想:公告与任务通知共用列表接口,contentType 为 201 表示任务通知,为 200 表示公告。分页、刷新逻辑与任务通知一致,可照着实现。
怎么做:

vue
<!-- pages/message/components/announce.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import messageApi from '@/apis/message'

const announceList = ref([])
const nextPage = ref(1)
const pageSize = ref(5)
const isEmpty = ref(false)
const hasMore = ref(true)
const isTriggered = ref(false)

onMounted(() => {
  getAnnounceList()
})

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

<template>
  <scroll-view
    @refresherrefresh="onScrollViewRefresh"
    @scrolltolower="onScrollToLower"
    :refresher-triggered="isTriggered"
    class="scroll-view"
    refresher-enabled
    scroll-y
  >
    <view class="scroll-view-wrapper">
      <view class="message-action">
        <text class="iconfont icon-clear"></text> 全部已读
      </view>
      <uni-list :border="false">
        <uni-list-item
          v-for="announce in announceList"
          :key="announce.id"
          :to="`/subpkg_message/content/index?id=${announce.id}`"
          ellipsis="1"
          :title="announce.title"
          :right-text="announce.created"
        >
          <template v-slot:header>
            <text v-if="!announce.isRead" class="dot"></text>
          </template>
        </uni-list-item>
      </uni-list>
      <view v-if="isEmpty" class="message-blank">暂无消息</view>
    </view>
  </scroll-view>
</template>

易错点:公告请求不要误传 201;下拉刷新代码中要操作 announceList.value,不要写成函数名;isRead 判断未读小红点逻辑与任务通知一致。

任务列表:先封装接口,再渲染待提货数据 ​

为什么需要:司机收到运输任务后,初始状态是“待提货”,需要先看到任务列表,核对始发地、终点地和提货时间,再进入详情或提货页。
怎么想:列表页只做两件事:调接口取数据、按字段渲染;跳转详情和提货页都统一传 id,是否显示“提货”由 enablePickUp 判断。转写稿里强调“先看接口实际返回再解构”,这里同样适用:先确认返回中有 code、data.items,再写渲染。
怎么做:先封装 apis/task.js,再在 pickup.vue 中调用。

javascript
// apis/task.js
import { uniFetch } from './uni-fetch'
export default {
  list(status, page = 1, pageSize = 10) {
    return uniFetch.get('/driver/tasks/list', { status, page, pageSize });
  },
}
vue
<!-- pages/task/components/pickup.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import taskApi from '@/apis/task'

const pickUpList = ref([])
const nextPage = ref(1)
const pageSize = ref(5)
const isEmpty = ref(false)

onMounted(() => { getPickUpList() })

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

模板中任务卡片跳转地址为 /subpkg_task/detail/index?id=${pickup.id},提货按钮地址为 /subpkg_task/pickup/index?id=${pickup.id};按钮用 v-if="pickup.enablePickUp" 控制。微信小程序里 disabled 属性控制样式可能不生效,笔记中改为类名控制:

css
.action {
  /* 省略定位与尺寸样式 */
  &.disabled { background-color: #fadcd9; }
}

易错点:跳转参数名是 id,不是任务编号;不要把所有任务都渲染成可提货;禁用态要用类名兼容小程序。

上拉分页:追加数据并判断是否还有更多 ​

为什么需要:任务列表数据多,不能一次全量加载,需要滚动到底部继续取下一页。
怎么想:监听 scrolltolower,页码从 nextPage 取;新数据要追加到原数组,不能直接覆盖。是否有更多数据,用接口返回的总页数 pages 判断。
怎么做:

vue
<scroll-view @scrolltolower="onScrollToLower" scroll-y refresher-enabled class="scroll-view">
  <!-- 列表内容 -->
</scroll-view>
javascript
function onScrollToLower() {
  if (!hasMore.value) return
  getPickUpList(nextPage.value)
}

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

易错点:分页数据要用扩展运算符或 concat 追加;nextPage 按返回的 data.page 加 1;hasMore 不要写成固定 true,否则会重复请求空页。

下拉刷新:重新请求第一页并关闭动画 ​

为什么需要:司机在现场可能希望手动刷新任务状态,下拉刷新要重新取第一页数据。
怎么想:监听 refresherrefresh,调用列表方法;刷新本质是请求第 1 页,因此渲染前要清空旧列表。动画开关由 refresher-triggered 控制,请求结束后再关闭。
怎么做:

vue
<scroll-view
  @scrolltolower="onScrollToLower"
  @refresherrefresh="onScrollViewRefresh"
  :refresher-triggered="isTriggered"
  scroll-y
  refresher-enabled
  class="scroll-view"
>
javascript
async function onScrollViewRefresh() {
  isTriggered.value = true
  await getPickUpList()
  isTriggered.value = false
}

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

易错点:刷新后不清空会重复数据;关闭动画要等 await 完成;refresher-triggered 的值要随请求状态切换。

任务详情:按 id 取数,按 status 渲染 ​

为什么需要:详情页要展示运输起点/终点、提货/交付对接人、异常信息、凭证图片等,不同任务状态展示内容不同。
怎么想:先通过 onLoad 拿 params.id,再调用详情接口;渲染时按 status 分支:1 待提货、2 在途、4 已交付、6 已完成。待提货显示“延迟提货/提货”,在途显示“异常上报/交付”,已交付显示“回车登记”。异常上报和回车登记传 transportTaskId。
怎么做:

javascript
// apis/task.js
detail(id) {
  if (!id) return
  return uniFetch.get(`/driver/tasks/details/${id}`);
}
vue
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import taskApi from '@/apis/task'

const taskDetail = ref({})
onLoad((params) => { getTaskDetail(params.id) })

async function getTaskDetail(id) {
  if (!id) return
  const { code, data } = await taskApi.detail(id)
  if (code !== 200) return uni.utils.toast('获取数据失败, 稍后重试 !')
  taskDetail.value = data
}
</script>

模板中用 v-if="taskDetail.status === 1" 展示提货联系人、联系电话、预计/实际提货时间;status === 2 展示交付联系人、预计/实际送达时间。底部按钮示例:

vue
<view class="toolbar" v-if="taskDetail.status === 1">
  <navigator :url="`/subpkg_task/delay/index?id=${taskDetail.id}`" class="button secondary">延迟提货</navigator>
  <navigator :url="`/subpkg_task/pickup/index?id=${taskDetail.id}`" class="button primary">提货</navigator>
</view>
<view class="toolbar" v-if="taskDetail.status === 2">
  <navigator :url="`/subpkg_task/except/index?transportTaskId=${taskDetail.transportTaskId}`" class="button secondary">异常上报</navigator>
  <navigator :url="`/subpkg_task/delivery/index?id=${taskDetail.id}`" class="button primary">交付</navigator>
</view>
<view class="toolbar" v-if="taskDetail.status === 4">
  <navigator :url="`/subpkg_task/record/index?transportTaskId=${taskDetail.transportTaskId}`" class="button primary block">回车登记</navigator>
</view>

易错点:详情取数前先判断 id,避免无效请求;不同状态按钮 URL 参数不同,异常上报/回车登记用 transportTaskId;待提货状态下异常信息、提货信息、交付信息为空,不要误渲染。

条件编译:H5 与小程序/App 的入口差异 ​

为什么需要:详情页包含按任务 ID 查询商品的功能,可扩展为扫码读取任务 ID,但 H5 端扫码能力受限,需要按平台展示不同图标。
怎么想:H5 用搜索图标,App 和小程序用扫码图标;扫码功能暂未支持,只做入口差异。
怎么做:

vue
<view class="search-bar">
  <!-- #ifdef H5 -->
  <text class="iconfont icon-search"></text>
  <!-- #endif -->
  <!-- #ifdef APP-PLUS | MP -->
  <text class="iconfont icon-scan"></text>
  <!-- #endif -->
  <input class="input" type="text" placeholder="输入运单号" />
</view>

易错点:平台标识要写对:H5、APP-PLUS、MP;条件编译注释不能随意改写。

代码示例 ​

示例 1:消息模块 —— Tab 切换(v-if + v-show)与任务通知列表(下拉刷新 + 上拉分页) ​

javascript
// apis/message.js
import { uniFetch } from './uni-fetch'

export default {
  /**
   * 消息列表
   * @param {string} contentType - 消息类型:201 任务通知,200 公告
   * @param {string} page - 页码
   * @param {string} pageSize - 每页条数
   */
  list(contentType, page = 1, pageSize = 10) {
    if (!contentType) return
    return uniFetch.get('/driver/messages/page', { contentType, page, pageSize })
  },
}
vue
<!-- pages/message/index.vue -->
<script setup>
  import { ref, reactive } from 'vue'

  import slNotify from './components/notify'
  import slAnnounce from './components/announce'

  // Tab 标签页索引
  const tabIndex = ref(0)
  const tabMetas = reactive([
    { title: '任务通知', rendered: true },
    { title: '公告', rendered: false },
  ])

  // 切换标签页
  function onTabChange(index) {
    tabMetas[index].rendered = true
    tabIndex.value = index
  }
</script>

<template>
  <view class="page-container">
    <view class="tabs">
      <view
        v-for="(tab, index) in tabMetas"
        :key="index"
        class="tab"
        :class="{ active: tabIndex === index }"
        @click="onTabChange(index)"
      >
        {{ tab.title }}
      </view>
    </view>

    <view v-show="tabIndex === 0" v-if="tabMetas[0].rendered" class="message-list">
      <sl-notify />
    </view>
    <view v-show="tabIndex === 1" v-if="tabMetas[1].rendered" class="message-list">
      <sl-announce />
    </view>
  </view>
</template>
vue
<!-- pages/message/components/notify.vue -->
<script setup>
  import { ref, onMounted } from 'vue'
  import messageApi from '@/apis/message'

  // 任务列表数据
  const notifyList = ref([])
  // 下一页页码
  const nextPage = ref(1)
  // 每页包含数据条数
  const pageSize = ref(5)
  // 是否为空列表
  const isEmpty = ref(false)
  // 是否还有更多数据
  const hasMore = ref(true)
  // 下拉刷新动画是否打开
  const isTriggered = ref(false)

  // 生命周期
  onMounted(() => {
    getNotifyList()
  })

  // 下拉刷新
  async function onScrollViewRefresh() {
    isTriggered.value = true
    await getNotifyList()
    isTriggered.value = false
  }

  // 上拉分页
  function onScrollToLower() {
    if (!hasMore.value) return
    // 获取下一页数据
    getNotifyList(nextPage.value)
  }

  // 任务列表
  async function getNotifyList(page = 1, pageSize = 10) {
    const { code, data } = await messageApi.list(201, page, pageSize)
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast('获取通知失败,稍后重试!')
    // 第一页时清空原数据
    if (page === 1) notifyList.value = []
    // 渲染数据
    notifyList.value = [...notifyList.value, ...(data.items || [])]
    // 更新下一页页码
    nextPage.value = ++data.page
    // 是否为空列表
    isEmpty.value = notifyList.value.length === 0
    // 是否还有更多数据
    hasMore.value = nextPage.value <= data.pages
  }
</script>

<template>
  <scroll-view
    @refresherrefresh="onScrollViewRefresh"
    @scrolltolower="onScrollToLower"
    :refresher-triggered="isTriggered"
    class="scroll-view"
    refresher-enabled
    scroll-y
  >
    <view class="scroll-view-wrapper">
      <view class="message-action">
        <text class="iconfont icon-clear"></text> 全部已读
      </view>
      <uni-card
        v-for="notify in notifyList"
        :key="notify.id"
        :border="false"
        :is-shadow="false"
      >
        <view class="brief">{{ notify.content }}</view>
        <view class="extra">
          <text class="time">{{ notify.created }}</text>
          <navigator
            hover-class="none"
            class="link"
            :url="`/subpkg_task/detail/index?id=${notify.id}`"
          >查看详情</navigator>
        </view>
        <template v-slot:title>
          <view :class="{ unread: !notify.isRead }" class="title unread"
            >您有新的运输任务</view
          >
        </template>
      </uni-card>
      <view v-if="isEmpty" class="message-blank">暂无消息</view>
    </view>
  </scroll-view>
</template>

关键行

  • tabMetas 中的 rendered 标记“组件是否加载过”,与 v-if 配合;tabIndex 控制“当前显示谁”,与 v-show 配合。uni-app 小程序端不支持 Component / Keep-Alive,所以用 v-if + v-show 组合:v-if 保证组件只被加载一次(保留状态),v-show 只切换显示隐藏(不销毁、不重复请求)。
  • @scrolltolower="onScrollToLower" 触底加载;@refresherrefresh="onScrollViewRefresh" 下拉刷新;:refresher-triggered="isTriggered" 控制刷新动画开关。
  • notifyList.value = [...notifyList.value, ...(data.items || [])] 把新一页追加到原数组(也可用 concat)。
  • hasMore.value = nextPage.value <= data.pages 用“下一页页码 ≤ 总页数”判断是否还有数据。

易错点

  • 刷新时必须先清空原列表:if (page === 1) notifyList.value = [],否则数据会重复堆叠。
  • isTriggered 的打开/关闭必须配 async/await:先置 true,await 请求结束后再置 false,否则动画会一直转或提前关闭。
  • 触底时先判断 if (!hasMore.value) return,否则没有数据时仍会无限请求。
  • 组件内用的是 Vue 生命周期 onMounted(来自 vue),不是 uni-app 页面的 onLoad。
  • isRead 为 0 表示未读、1 表示已读,模板中用的是 !notify.isRead 控制未读样式;isEmpty 用于空列表占位提示。

示例 2:任务模块 —— 待提货列表(分页 + 刷新 + 按钮禁用兼容) ​

javascript
// apis/task.js
import { uniFetch } from './uni-fetch'

export default {
  /**
   * 任务列表
   * @param {number} status - 任务状态
   * @param {string} page - 数据页码
   * @param {string} pageSize - 每页数据条数
   */
  list(status, page = 1, pageSize = 10) {
    return uniFetch.get('/driver/tasks/list', { status, page, pageSize })
  },

  /**
   * 任务详情
   * @param {string} id - 任务ID
   */
  detail(id) {
    if (!id) return
    return uniFetch.get(`/driver/tasks/details/${id}`)
  },
}
vue
<!-- pages/task/components/pickup.vue -->
<script setup>
  import { ref, onMounted } from 'vue'
  import taskApi from '@/apis/task'

  // 任务列表数据
  const pickUpList = ref([])
  // 下一页页码
  const nextPage = ref(1)
  // 每页包含数据条数
  const pageSize = ref(5)
  // 是否为空列表
  const isEmpty = ref(false)
  // 是否还有更多数据
  const hasMore = ref(true)
  // 下拉刷新动画是否打开
  const isTriggered = ref(false)

  // 生命周期
  onMounted(() => {
    getPickUpList()
  })

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

  // 监听页面是否滚动到底部
  function onScrollToLower() {
    // 没有更多数据时则不需要再请求了
    if (!hasMore.value) return
    getPickUpList(nextPage.value)
  }

  // 获取任务列表
  async function getPickUpList(page = 1, pageSize = 5) {
    const { code, data } = await taskApi.list(1, page, pageSize)
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast('获取列表失败,稍后重试!')
    // 页面为 1 时,清空数组
    if (page === 1) pickUpList.value = []
    // 渲染任务列表
    pickUpList.value = [...pickUpList.value, ...(data.items || [])]
    // 计算下一页页码
    nextPage.value = ++data.page
    // 判断列表是否为空
    isEmpty.value = pickUpList.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="pickup in pickUpList" :key="pickup.id" class="task-card">
        <navigator
          hover-class="none"
          :url="`/subpkg_task/detail/index?id=${pickup.id}`"
        >
          <view class="header">
            <text class="no">任务编号: {{ pickup.transportTaskId }}</text>
          </view>
          <view class="body">
            <view class="timeline">
              <view class="line">{{ pickup.startAddress }}</view>
              <view class="line">{{ pickup.endAddress }}</view>
            </view>
          </view>
        </navigator>
        <view class="footer">
          <view class="label">提货时间</view>
          <view class="time">{{ pickup.planDepartureTime }}</view>
          <navigator
            v-if="pickup.enablePickUp"
            hover-class="none"
            :url="`/subpkg_task/pickup/index?id=${pickup.id}`"
            class="action"
          >
            提货
          </navigator>
          <navigator v-else hover-class="none" url=" " class="action disabled">
            提货
          </navigator>
        </view>
      </view>
      <view v-if="isEmpty" class="task-blank">无待提货物</view>
    </view>
  </scroll-view>
</template>

<style lang="scss" scoped>
  /* pages/task/components/style.scss */
  .action {
    position: absolute;
    right: 0;
    top: 50%;
    display: flex;
    align-items: center;
    height: 64rpx;
    padding: 0 40rpx;
    background-color: $uni-primary;
    color: #fff;
    font-size: $uni-font-size-small;
    border-radius: 64rpx;
    transform: translate(0, -50%);

    &.disabled {
      background-color: #fadcd9;
    }
  }
</style>

关键行

  • taskApi.list(1, page, pageSize):1 是“待提货”状态,nextPage.value 由接口返回的 data.page + 1 计算得出。
  • 跳转详情/提货页传参统一用 id::url="\/subpkg_task/pickup/index?id=${pickup.id}`"`。
  • pickup.enablePickUp 决定提货按钮可用/禁用,用 v-if / v-else 渲染两个 navigator。

易错点

  • 微信小程序里 navigator 的 disabled 属性控制样式不生效,必须改成“类名控制禁用样式”::class="{ disabled: !pickup.enablePickUp }" 或本例的 v-if / v-else + class="action disabled"。
  • 分页请求必须把新数据追加到原数组,不能直接 pickUpList.value = data.items,否则上拉后只剩最后一页。
  • 刷新(第 1 页)必须先清空数组再追加,否则刷新后数据重复。
  • onScrollToLower 中务必先判断 hasMore,避免无数据时重复请求。

示例 3:表单验证 + 登录 + Token 持久化 ​

javascript
// apis/user.js
import { uniFetch } from './uni-fetch'

export default {
  /**
   * 账号密码登录
   * @param {object} data - 表单数据,包含 account、password
   */
  login(data) {
    // 入参检测,保证代码健壮性
    if (!data.account || !data.password) return
    return uniFetch.post('/driver/login/account', data) // 路径以接口文档为准
  },
}
javascript
// stores/user.js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useUserStore = defineStore(
  'user',
  () => {
    // 用来记录用户登录状态
    const token = ref('')
    return { token }
  },
  {
    // 只持久化 token
    persist: {
      paths: ['token'],
    },
  }
)
vue
<!-- pages/login/components/account.vue -->
<script setup>
  import { ref, reactive } from 'vue'
  import userApi from '@/apis/user'
  import { useUserStore } from '@/stores/user'

  // 表单元素(用于调用 validate)
  const formRef = ref(null)

  // 表单数据
  const formData = reactive({
    account: '',
    password: '',
  })

  // 验证规则
  const rules = {
    account: [
      { required: true, errorMessage: '请输入登录账号' },
      { pattern: '^[A-Za-z0-9_]{6,8}$', errorMessage: '账号格式不正确' },
    ],
    password: [
      { required: true, errorMessage: '请输入登录密码' },
      { pattern: '^[A-Za-z0-9_]{6,8}$', errorMessage: '密码格式不正确' },
    ],
  }

  // 用户状态
  const userStore = useUserStore()

  // 提交表单数据
  async function onSubmit() {
    try {
      // 验证成功后返回的就是表单中的数据
      await formRef.value.validate()

      // 验证通过后再调用登录接口
      const { code, data } = await userApi.login(formData)
      if (code !== 200) return uni.utils.toast('登录失败,稍后重试!')

      // 持久化 token
      userStore.token = data.token
      uni.utils.toast('登录成功')
    } catch (err) {
      // 验证失败会抛出错误,这里捕获,避免控制台报红
      console.log(err)
    }
  }
</script>

<template>
  <uni-forms ref="formRef" :model="formData" :rules="rules">
    <uni-forms-item name="account" label="账号">
      <input v-model="formData.account" type="text" placeholder="请输入账号" />
    </uni-forms-item>
    <uni-forms-item name="password" label="密码">
      <input
        v-model="formData.password"
        type="password"
        placeholder="请输入密码"
      />
    </uni-forms-item>
    <button type="primary" @click="onSubmit">登录</button>
  </uni-forms>
</template>
javascript
// apis/uni-fetch.js 响应拦截器(片段)
// 将返回数据中多余的部分过滤掉,只保留 data
uniFetch.interceptors.response.use((result) => {
  const { data } = result
  return data
})

关键行

  • :model="formData" 把表单数据交给 uni-forms,v-model 只能作用在 uni-forms-item 内部的具体控件(input)上。
  • :rules="rules" 把验证规则交给表单;规则对象的 key(account / password)必须与 formData 的 key 一致,uni-forms-item 的 name 也必须一致,三处对应才能生效。
  • required: true 表示必填,pattern 表示用正则校验(字母数字下划线 6–8 位),errorMessage 是校验失败提示。
  • formRef.value.validate() 用 ref 拿到表单实例后调用校验方法,返回 Promise;成功时 resolve 出表单数据,失败时 reject 出错误信息数组。
  • 响应拦截器里 const { data } = result; return data,之后业务代码才能直接解构出 { code, data }。

易错点

  • formRef 必须用 ref 定义(因为要拿 DOM/组件实例),调用时是 formRef.value.validate(),漏掉 .value 会报错。
  • uni-forms-item 的 name、formData 的 key、rules 的 key 三者不一致时,验证“静默失效”——不报错但也不提示,是最常见的坑。
  • validate() 失败会抛异常,必须用 try/catch 包住,否则控制台会报红;校验通过后再调登录接口。
  • pinia 中修改 token 可以直接赋值(userStore.token = data.token),与 Vuex 必须 commit 不同。
  • persist 只写 paths: ['token'],不要整库持久化;接口封装中对 data.account / data.password 做入参检测,防止别人调用时漏传导致请求无意义。

常见错误 ​

  1. 切换标签页后内容不显示
    现象:点击第二个 Tab 后页面空白或组件未加载。
    原因:只改了 tabIndex,没有把对应 tabMetas[index].rendered 置为 true。
    修法:切换时同时更新 rendered 和 tabIndex,保证 v-if 先放行、v-show 再控制显示。

  2. 上拉分页重复请求或停不下来
    现象:滚动到底部连续发请求,或没有更多数据仍继续请求。
    原因:未维护 hasMore,或 nextPage 更新错误,或请求中未做防抖/加载态。
    修法:根据 nextPage <= data.pages 更新 hasMore;请求前判断 hasMore 和加载状态;页码以接口返回 page 递增。

  3. 下拉刷新后列表数据重复或动画不关闭
    现象:刷新后旧数据和新数据叠加;下拉动画一直转。
    原因:第 1 页请求前未清空原数组;未用 async/await 后在 finally 中关闭 refresher-triggered。
    修法:page === 1 时先清空列表;刷新方法用 try/finally 将 isTriggered 置回 false。

  4. 表单验证规则不生效
    现象:点击登录没有错误提示,或规则验证不到对应字段。
    原因:uni-forms 的 model、uni-forms-item 的 name、rules 的键名没有一一对应。
    修法:统一字段名,如 account、password;检查 :model、name、rules 三处拼写完全一致。

  5. 登录接口报 500 或参数位置不对
    现象:网络面板里参数拼在 URL 上,接口返回异常。
    原因:文档要求 POST,却用了 GET。
    修法:按接口文档选择请求方法;POST 时把账号密码作为请求体传递,并保留必要入参校验。

  6. 解构 code/data 失败
    现象:登录返回结果中解构不到 code,或拿不到 token。
    原因:响应拦截器未把外层结果中的 data 解包返回。
    修法:在响应拦截器统一返回业务数据;确认返回结构后再解构,不要直接照抄调用代码。

练习 ​

  1. 完成消息通知和公告的“全部已读”:调用接口后同步更新列表中的 isRead 状态,并处理空列表和失败提示。
  2. 给任务列表补充“加载中”“没有更多了”“暂无数据”三种状态,要求上拉时不能重复发请求。
  3. 把消息列表中的分页、下拉刷新、上拉加载逻辑抽成一个可复用组合函数,供通知和公告共用。
  4. 为登录表单增加账号/手机号登录方式切换,切换时重置表单数据并切换对应验证规则。
  5. 完善请求封装:统一注入 token、统一处理非 200 业务码,并在登录成功后只持久化必要字段。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1神领物流-表单验证-112:12
2神领物流-表单验证-211:23
3神领物流-表单验证-313:34
4神领物流-登录功能说明05:11
5神领物流-登录-110:50
6神领物流-登录-207:49
7神领物流-登录-310:54

小结 ​

  1. 跨端页面优先保证兼容性,标签页切换要同时考虑“是否渲染”和“是否显示”。
  2. 列表分页要同时维护 nextPage、hasMore、isEmpty,不能只追加数据。
  3. 下拉刷新本质是重置第一页,必须先清空旧数据再追加,并在请求结束后关闭刷新动画。
  4. 表单验证能否生效,关键看 model、name、rules 三处字段是否严格对应。
  5. validate 的结果要按 Promise 处理,成功取数据,失败给提示,避免未捕获异常。
  6. 登录功能要按接口文档确认请求方法、参数位置和返回结构,不能凭经验直接写。
  7. Pinia 持久化只保存必要字段,登录后及时写入 token,后续请求再统一携带。
  8. 对接口入参、分页边界和异常分支做保护,是提升代码健壮性的基本要求。