主题
表单验证与任务通知(一)
本页对应 表单验证与任务通知,共 7 段视频,约 1 小时 11 分。
学习目标
- 能够使用
uni-forms、uni-forms-item与v-model绑定表单数据,并通过调试工具验证已正确获取账号、密码等表单项值。 - 能够为不同表单项定义
rules,使用required、pattern、errorMessage配置必填与格式校验,并将规则正确绑定到表单。 - 能够通过
ref获取表单实例,在提交事件中调用validate完成校验,并使用try/catch区分校验成功与失败,输出对应结果或错误提示。 - 能够封装登录 API 方法,校验入参后以 POST 方式提交账号和密码,并在页面提交时正确调用该 API、查看请求与响应结果。
- 能够配置响应拦截器过滤接口返回的多余数据,仅返回
data,并在登录流程中正确解构code、data等字段。 - 能够定义 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做入参检测,防止别人调用时漏传导致请求无意义。
常见错误
切换标签页后内容不显示
现象:点击第二个 Tab 后页面空白或组件未加载。
原因:只改了tabIndex,没有把对应tabMetas[index].rendered置为true。
修法:切换时同时更新rendered和tabIndex,保证v-if先放行、v-show再控制显示。上拉分页重复请求或停不下来
现象:滚动到底部连续发请求,或没有更多数据仍继续请求。
原因:未维护hasMore,或nextPage更新错误,或请求中未做防抖/加载态。
修法:根据nextPage <= data.pages更新hasMore;请求前判断hasMore和加载状态;页码以接口返回page递增。下拉刷新后列表数据重复或动画不关闭
现象:刷新后旧数据和新数据叠加;下拉动画一直转。
原因:第 1 页请求前未清空原数组;未用async/await后在finally中关闭refresher-triggered。
修法:page === 1时先清空列表;刷新方法用try/finally将isTriggered置回false。表单验证规则不生效
现象:点击登录没有错误提示,或规则验证不到对应字段。
原因:uni-forms的model、uni-forms-item的name、rules的键名没有一一对应。
修法:统一字段名,如account、password;检查:model、name、rules三处拼写完全一致。登录接口报 500 或参数位置不对
现象:网络面板里参数拼在 URL 上,接口返回异常。
原因:文档要求 POST,却用了 GET。
修法:按接口文档选择请求方法;POST 时把账号密码作为请求体传递,并保留必要入参校验。解构
code/data失败
现象:登录返回结果中解构不到code,或拿不到 token。
原因:响应拦截器未把外层结果中的data解包返回。
修法:在响应拦截器统一返回业务数据;确认返回结构后再解构,不要直接照抄调用代码。
练习
- 完成消息通知和公告的“全部已读”:调用接口后同步更新列表中的
isRead状态,并处理空列表和失败提示。 - 给任务列表补充“加载中”“没有更多了”“暂无数据”三种状态,要求上拉时不能重复发请求。
- 把消息列表中的分页、下拉刷新、上拉加载逻辑抽成一个可复用组合函数,供通知和公告共用。
- 为登录表单增加账号/手机号登录方式切换,切换时重置表单数据并切换对应验证规则。
- 完善请求封装:统一注入 token、统一处理非 200 业务码,并在登录成功后只持久化必要字段。
课堂画面






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