✔ 掌握登录模块的业务逻辑。
✔ 掌握会员中心的业务逻辑。
✔ 掌握个人信息的业务逻辑。
登录方式
用户名/手机号 + 密码。
手机号 + 验证码。
授权登录。
实际开发过程中常常需要实现以上的一种或多种方式,我们的项目主要实现授权登录。
微信授权登录
用户在使用小程序时,其实已登录微信,其本质上就是:微信授权给小程序读取微信用户信息。

传统登录方式
传统登录方式,一般是通过输入密码或者手机验证码实现登录。

静态结构
登录页,pages/login/login.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
| <template> <view class="viewport"> <view class="logo"> <image src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/logo_icon.png" ></image> </view> <view class="login"> <button class="button phone"> <text class="icon icon-phone"></text> 手机号快捷登录 </button> <view class="extra"> <view class="caption"> <text>其他登录方式</text> </view> <view class="options"> <button> <text class="icon icon-phone">模拟快捷登录</text> </button> </view> </view> <view class="tips">登录/注册即视为你同意《服务条款》和《小兔鲜儿隐私协议》</view> </view> </view> </template>
<style lang="scss"> page { height: 100%; }
.viewport { display: flex; flex-direction: column; height: 100%; padding: 20rpx 40rpx; }
.logo { flex: 1; text-align: center; image { width: 220rpx; height: 220rpx; margin-top: 15vh; } }
.login { display: flex; flex-direction: column; height: 60vh; padding: 40rpx 20rpx 20rpx;
.button { display: flex; align-items: center; justify-content: center; width: 100%; height: 80rpx; font-size: 28rpx; border-radius: 72rpx; color: #fff; .icon { font-size: 40rpx; margin-right: 6rpx; } }
.phone { background-color: #28bb9c; }
.wechat { background-color: #06c05f; }
.extra { flex: 1; padding: 70rpx 70rpx 0; .caption { width: 440rpx; line-height: 1; border-top: 1rpx solid #ddd; font-size: 26rpx; color: #999; position: relative; text { transform: translate(-40%); background-color: #fff; position: absolute; top: -12rpx; left: 50%; } }
.options { display: flex; justify-content: center; align-items: center; margin-top: 70rpx; button { padding: 0; background-color: transparent; } }
.icon { font-size: 24rpx; color: #444; display: flex; flex-direction: column; align-items: center;
&::before { display: flex; align-items: center; justify-content: center; width: 80rpx; height: 80rpx; margin-bottom: 6rpx; font-size: 40rpx; border: 1rpx solid #444; border-radius: 50%; } } .icon-weixin::before { border-color: #06c05f; color: #06c05f; } } }
.tips { position: absolute; bottom: 80rpx; left: 20rpx; right: 20rpx; font-size: 22rpx; color: #999; text-align: center; } </style>
|
获取登录凭证
pages/login/login.vue
1 2 3 4 5 6 7
| import { onLoad } from '@dcloudio/uni-app'
let code = '' onLoad(async () => { const res = await uni.login() code = res.code })
|
面试:小程序登录流程。
获取手机授权
出于安全限制,小程序【规定】想获取用户的手机号,必须由用户主动【点击按钮】并【允许申请】才可获取加密的手机号信息。

pages/login/login.vue
1 2 3 4 5 6
| const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = (ev) => { const encrytedData = ev.detail!.encryptedData! const iv = ev.detail!.iv! console.log(encrytedData, iv) }
|
1 2 3 4 5
| <button class="button phone" open-type="getPhoneNumber" @getphonenumber="onGetphonenumber"> <text class="icon icon-phone"></text> 手机号快捷登录 </button>
|
🤔 为什么我无法唤起获取手机号的界面?
获取手机号功能目前针对非个人开发者,所以个人开发者无法唤起获取手机号界面,详见文档。
调用登录接口(生产环境)
具体步骤
- 封装接口,
services/login.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { http } from '@/utils/http'
type LoginParams = { code: string encryptedData: string iv: string }
export const postLoginWxMinAPI = (data: LoginParams) => { return http({ method: 'POST', url: '/login/wxMin', data }) }
|
- 页面中调用,
pages/login/login.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { onLoad } from '@dcloudio/uni-app'
import { postLoginWxMinAPI } from '@/services/login'
let code = '' onLoad(async () => { const res = await uni.login() code = res.code })
const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = async (ev) => { const encryptedData = ev.detail.encryptedData! const iv = ev.detail.iv! const res = await postLoginWxMinAPI({ code, encryptedData, iv }) console.log(res) }
|
小结一下
模拟手机登录(开发环境)
获取手机号功能,目前针对非个人开发者,且完成了认证的小程序开放,详见文档,为了更好实现登录后续的业务,后端提供了一个内部测试用的接口,只需要传手机号即可实现快捷登录。
请求封装
services/login.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13
|
export const postLoginWxMinSimpleAPI = (phoneNumber: string) => { return http({ method: 'POST', url: '/login/wxMin/simple', data: { phoneNumber } }) }
|
- 页面中调用,
pages/login/login.vue
。
1 2 3 4 5 6 7
| import { postLoginWxMinSimpleAPI } from '@/services/login'
const onGetphonenumberSimple = async () => { const res = await postLoginWxMinSimpleAPI('13123456789') console.log(res) }
|
1 2 3 4 5
| <button class="button phone" @tap="onGetphonenumberSimple"> <text class="icon icon-phone"></text> 手机号快捷登录 </button>
|
小结一下
用户信息持久化存储
Pinia 的持久化存储插件在 项目起步 模块已经搭建完成,现在只需要在用户登录成功后,补充 TS 类型声明并保存用户信息即可。
具体步骤
- 指定后端返回数据的类型,
types/member.d.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export type LoginResult = { id: number avatar: string account: string nickname?: string mobile: string token: string }
|
- 调整接口,
services/login.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13
|
export const postLoginWxMinSimpleAPI = (phoneNumber: string) => { return http<LoginResult>({ method: 'POST', url: '/login/wxMin/simple', data: { phoneNumber } }) }
|
- 给 store 中相关方法的参数指定类型,
stores/modules/member.ts
。
1 2 3 4 5 6 7
| const profile = ref<LoginResult>()
const setProfile = (val: LoginResult) => { profile.value = val }
|
- 登录页,
pages/login/login.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const loginSuccess = (profile: LoginResult) => { const memberStore = useMemberStore() memberStore.setProfile(profile) uni.showToast({ icon: 'success', title: '登录成功' }) setTimeout(() => { uni.switchTab({ url: '/pages/my/my' }) }, 500) }
const onGetphonenumberSimple = async () => { const res = await postLoginWxMinSimpleAPI('13123456789') loginSuccess(res.result) }
|
小结一下
会员中心页(我的)
主要实现两部分业务。
渲染当前登录会员的昵称和头像,从 Store 中获取。
猜你喜欢分页加载,可封装成组合式函数实现复用逻辑。

静态结构
pages/my/my.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
| <script setup lang="ts">
const { safeAreaInsets } = uni.getSystemInfoSync()
const orderTypes = [ { type: 1, text: '待付款', icon: 'icon-currency' }, { type: 2, text: '待发货', icon: 'icon-gift' }, { type: 3, text: '待收货', icon: 'icon-check' }, { type: 4, text: '待评价', icon: 'icon-comment' }, ] </script>
<template> <scroll-view class="viewport" scroll-y enable-back-to-top> <view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }"> <view class="overview" v-if="false"> <navigator url="/pagesMember/profile/profile" hover-class="none"> <image class="avatar" mode="aspectFill" src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/avatar_3.jpg" ></image> </navigator> <view class="meta"> <view class="nickname"> 黑马程序员 </view> <navigator class="extra" url="/pagesMember/profile/profile" hover-class="none"> <text class="update">更新头像昵称</text> </navigator> </view> </view> <view class="overview" v-else> <navigator url="/pages/login/login" hover-class="none"> <image class="avatar gray" mode="aspectFill" src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-04-06/db628d42-88a7-46e7-abb8-659448c33081.png" ></image> </navigator> <view class="meta"> <navigator url="/pages/login/login" hover-class="none" class="nickname"> 未登录 </navigator> <view class="extra"> <text class="tips">点击登录账号</text> </view> </view> </view> <navigator class="settings" url="/pagesMember/settings/settings" hover-class="none"> 设置 </navigator> </view> <view class="orders"> <view class="title"> 我的订单 <navigator class="navigator" url="/pagesOrder/list/list?type=0" hover-class="none"> 查看全部订单<text class="icon-right"></text> </navigator> </view> <view class="section"> <navigator v-for="item in orderTypes" :key="item.type" :class="item.icon" :url="`/pagesOrder/list/list?type=${item.type}`" class="navigator" hover-class="none" > {{ item.text }} </navigator> <button class="contact icon-handset" open-type="contact">售后</button> </view> </view> <view class="guess"> <XtxGuess ref="guessRef" /> </view> </scroll-view> </template>
<style lang="scss"> page { height: 100%; overflow: hidden; background-color: #f7f7f8; }
.viewport { height: 100%; background-repeat: no-repeat; background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/center_bg.png); background-size: 100% auto; }
.profile { margin-top: 20rpx; position: relative;
.overview { display: flex; height: 120rpx; padding: 0 36rpx; color: #fff; }
.avatar { width: 120rpx; height: 120rpx; border-radius: 50%; background-color: #eee; }
.gray { filter: grayscale(100%); }
.meta { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; line-height: 30rpx; padding: 16rpx 0; margin-left: 20rpx; }
.nickname { max-width: 350rpx; margin-bottom: 16rpx; font-size: 30rpx;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.extra { display: flex; font-size: 20rpx; }
.tips { font-size: 22rpx; }
.update { padding: 3rpx 10rpx 1rpx; color: rgba(255, 255, 255, 0.8); border: 1rpx solid rgba(255, 255, 255, 0.8); margin-right: 10rpx; border-radius: 30rpx; }
.settings { position: absolute; bottom: 0; right: 40rpx; font-size: 30rpx; color: #fff; } }
.orders { position: relative; z-index: 99; padding: 30rpx; margin: 50rpx 20rpx 0; background-color: #fff; border-radius: 10rpx; box-shadow: 0 4rpx 6rpx rgba(240, 240, 240, 0.6);
.title { height: 40rpx; line-height: 40rpx; font-size: 28rpx; color: #1e1e1e;
.navigator { font-size: 24rpx; color: #939393; float: right; } }
.section { width: 100%; display: flex; justify-content: space-between; padding: 40rpx 20rpx 10rpx; .navigator, .contact { text-align: center; font-size: 24rpx; color: #333; &::before { display: block; font-size: 60rpx; color: #ff9545; } } .contact { padding: 0; margin: 0; border: 0; background-color: transparent; line-height: inherit; } } }
.guess { background-color: #f7f7f8; margin-top: 20rpx; } </style>
|
隐藏导航
pages.json
1 2 3 4 5 6 7 8
| { "path": "pages/my/my", "style": { "navigationStyle": "custom", "navigationBarTextStyle": "white", "navigationBarTitleText": "我的" } }
|
渲染信息
pages/my/my.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| <script setup lang="ts"> import { useMemberStore } from '@/stores'
const memberStore = useMemberStore() </script>
<template> <scroll-view class="viewport" scroll-y enable-back-to-top> <view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }"> <view class="overview" v-if="memberStore.profile"> <navigator url="/pagesMember/profile/profile" hover-class="none"> <image class="avatar" mode="aspectFill" :src="memberStore.profile.avatar" ></image> </navigator> <view class="meta"> <view class="nickname"> {{ memberStore.profile.nickname || memberStore.profile.account }} </view> <navigator class="extra" url="/pagesMember/profile/profile" hover-class="none" > <text class="update">更新头像昵称</text> </navigator> </view> </view> <view class="overview" v-else> </view> </view> </scroll-view> </template>
|
小结一下
猜你喜欢
基本处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <script setup lang="ts">
const guessRef = ref<XtxGuessInstance>()
const onScrolltolower = () => { guessRef.value?.getMore() } </script>
<template> <scroll-view class="viewport" scroll-y enable-back-to-top @scrolltolower="onScrolltolower" > <view class="guess"> <XtxGuess ref="guessRef" /> </view> </scroll-view> </template>
|
封装函数
- 封装猜你喜欢组合式函数,
src/composables/index.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import type { XtxGuessInstance } from '@/types/components' import { ref } from 'vue'
export const useGuessList = () => { const guessRef = ref<XtxGuessInstance>()
const onScrolltolower = () => { guessRef.value?.getMore() }
return { guessRef, onScrolltolower } }
|
- 页面中使用,
pages/my/my.vue
。
1 2 3 4
| import { useGuessList } from '@/composables'
const { guessRef, onScrolltolower } = useGuessList()
|
小结一下
会员设置页
小程序分包:将小程序的代码分割成多个部分,分别打包成多个小程序包,减少小程序的加载时间,提高用户体验。
分包预下载:在进入小程序某个页面时,由框架自动预下载可能需要的分包,提升进入后续分包页面时的启动速度。
会员模块的二级页面,按模块处理成分包页面,有以下好处。
设置分包

通过 VS Code 插件 uni-create-view 可以快速新建分包页面,自动配置分包路由。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "subPackages": [ { "root": "pagesMember", "pages": [ { "path": "settings/settings", "style": { "navigationBarTitleText": "设置" } } ] } ] }
|
分包预下载
当用户进入【我的】页面时,由框架自动预下载【会员模块】的分包,提升进入后续分包页面时的启动速度。
pages.json
1 2 3 4 5 6 7 8
| { "preloadRule": { "pages/my/my": { "network": "all", "packages": ["pagesMember"] } } }
|
静态结构
设置页:pagesMember/settings/settings.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| <template> <view class="viewport"> <view class="list" v-if="true"> <navigator url="/pagesMember/address/address" hover-class="none" class="item arrow"> 我的收货地址 </navigator> </view> <view class="list"> <button hover-class="none" class="item arrow" open-type="openSetting">授权管理</button> <button hover-class="none" class="item arrow" open-type="feedback">问题反馈</button> <button hover-class="none" class="item arrow" open-type="contact">联系我们</button> </view> <view class="list"> <navigator hover-class="none" class="item arrow" url=" ">关于小兔鲜儿</navigator> </view> <view class="action"> <view class="button">退出登录</view> </view> </view> </template>
<style lang="scss"> page { background-color: #f4f4f4; }
.viewport { padding: 20rpx; }
.list { padding: 0 20rpx; background-color: #fff; margin-bottom: 20rpx; border-radius: 10rpx; .item { line-height: 90rpx; padding-left: 10rpx; font-size: 30rpx; color: #333; border-top: 1rpx solid #ddd; position: relative; text-align: left; border-radius: 0; background-color: #fff; &::after { width: auto; height: auto; left: auto; border: none; } &:first-child { border: none; } &::after { right: 5rpx; } } .arrow::after { content: '\e6c2'; position: absolute; top: 50%; color: #ccc; font-family: 'erabbit' !important; font-size: 32rpx; transform: translateY(-50%); } }
.action { text-align: center; line-height: 90rpx; margin-top: 40rpx; font-size: 32rpx; color: #333; .button { background-color: #fff; margin-bottom: 20rpx; border-radius: 10rpx; } } </style>
|
小结一下
退出登录
设置页需实现以下业务。
退出登录,清理用户信息,返回上一页。
根据登录状态,按需展示页面内容。
参考效果

参考代码
pagesMember/settings/settings.vue
1 2
| <view class="button" @tap="onLogout">退出登录</view>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { useMemberStore } from '@/stores'
const memberStore = useMemberStore()
const onLogout = () => { uni.showModal({ content: '是否退出登录?', success: (res) => { if (res.confirm) { memberStore.clearProfile() uni.navigateBack() } } }) }
|
退出登录后,不再显示【我的收获地址】和【退出登录】按钮。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <view class="viewport"> <view class="list" v-if="memberStore.profile"> <navigator url="/pagesMember/address/address" hover-class="none" class="item arrow" > 我的收货地址 </navigator> </view> <view class="action" v-if="memberStore.profile"> <view class="button" @tap="onLogout">退出登录</view> </view> </view>
|
小结一下
会员信息页
用户可以对会员信息进行更新操作,涉及到表单数据提交、图片读取、文件上传等知识点。

静态结构
- 会员信息页,处理成分包页面:
src/pagesMember/profile/profile.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
| <script setup lang="ts">
const { safeAreaInsets } = uni.getSystemInfoSync() </script>
<template> <view class="viewport"> <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }"> <navigator open-type="navigateBack" class="back icon-left" hover-class="none"></navigator> <view class="title">个人信息</view> </view> <view class="avatar"> <view class="avatar-content"> <image class="image" src=" " mode="aspectFill" /> <text class="text">点击修改头像</text> </view> </view> <view class="form"> <view class="form-content"> <view class="form-item"> <text class="label">账号</text> <text class="account">账号名</text> </view> <view class="form-item"> <text class="label">昵称</text> <input class="input" type="text" placeholder="请填写昵称" value="" /> </view> <view class="form-item"> <text class="label">性别</text> <radio-group> <label class="radio"> <radio value="男" color="#27ba9b" :checked="true" /> 男 </label> <label class="radio"> <radio value="女" color="#27ba9b" :checked="false" /> 女 </label> </radio-group> </view> <view class="form-item"> <text class="label">生日</text> <picker class="picker" mode="date" start="1900-01-01" :end="new Date()" value="2000-01-01" > <view v-if="false">2000-01-01</view> <view class="placeholder" v-else>请选择日期</view> </picker> </view> <view class="form-item"> <text class="label">城市</text> <picker class="picker" mode="region" :value="['广东省', '广州市', '天河区']"> <view v-if="false">广东省广州市天河区</view> <view class="placeholder" v-else>请选择城市</view> </picker> </view> <view class="form-item"> <text class="label">职业</text> <input class="input" type="text" placeholder="请填写职业" value="" /> </view> </view> <button class="form-button">保 存</button> </view> </view> </template>
<style lang="scss"> page { background-color: #f4f4f4; }
.viewport { display: flex; flex-direction: column; height: 100%; background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/order_bg.png); background-size: auto 420rpx; background-repeat: no-repeat; }
// 导航栏 .navbar { position: relative;
.title { height: 40px; display: flex; justify-content: center; align-items: center; font-size: 16px; font-weight: 500; color: #fff; }
.back { position: absolute; height: 40px; width: 40px; left: 0; font-size: 20px; color: #fff; display: flex; justify-content: center; align-items: center; } }
// 头像 .avatar { text-align: center; width: 100%; height: 260rpx; display: flex; flex-direction: column; justify-content: center; align-items: center;
.image { width: 160rpx; height: 160rpx; border-radius: 50%; background-color: #eee; }
.text { display: block; padding-top: 20rpx; line-height: 1; font-size: 26rpx; color: #fff; } }
// 表单 .form { background-color: #f4f4f4;
&-content { margin: 20rpx 20rpx 0; padding: 0 20rpx; border-radius: 10rpx; background-color: #fff; }
&-item { display: flex; height: 96rpx; line-height: 46rpx; padding: 25rpx 10rpx; background-color: #fff; font-size: 28rpx; border-bottom: 1rpx solid #ddd;
&:last-child { border: none; }
.label { width: 180rpx; color: #333; }
.account { color: #666; }
.input { flex: 1; display: block; height: 46rpx; }
.radio { margin-right: 20rpx; }
.picker { flex: 1; } .placeholder { color: #808080; } }
&-button { height: 80rpx; text-align: center; line-height: 80rpx; margin: 30rpx 20rpx; color: #fff; border-radius: 80rpx; font-size: 30rpx; background-color: #27ba9b; } } </style>
|
- 隐藏个人信息的的头部。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| { "subPackages": [ { "root": "pagesMember", "pages": [ { "path": "profile/profile", "style": { "navigationStyle": "custom", "navigationBarTextStyle": "white", "navigationBarTitleText": "个人信息" } } ] } ] }
|
小结一下
获取会员信息
- 封装接口,
src/services/profile.ts
。
1 2 3 4 5 6 7 8 9 10 11
| import { http } from '@/utils/http'
export const getMemberProfileAPI = () => { return http({ method: 'GET', url: '/member/profile', }) }
|
- 页面中调用,
src/pagesMember/profile/profile.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { getMemberProfileAPI } from '@/services/profile' import { onLoad } from '@dcloudio/uni-app'
const { safeAreaInsets } = uni.getSystemInfoSync()
const getMemberProfileData = async () => { const res = await getMemberProfileAPI() console.log(res) } onLoad(() => { getMemberProfileData() })
|
- 类型声明,
src/types/member.d.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| export type ProfileDetail = { id: number avatar: string account: string nickname?: string gender?: Gender birthday?: string fullLocation?: string profession?: string }
export type Gender = '女' | '男'
|
类型声明封装升级(可选),提取用户信息通用部分,再复用类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| type BaseProfile = { id: number avatar: string account: string nickname?: string }
export type LoginResult = BaseProfile & { id: number avatar: string account: string nickname?: string mobile: string token: string }
export type ProfileDetail = BaseProfile & { gender?: Gender birthday?: string fullLocation?: string profession?: string }
export type Gender = '女' | '男'
|
- 修改接口,指定后端返回数据的类型,
services/profile.ts
。
1 2 3 4 5 6 7 8 9 10 11 12
| import type { ProfileDetail } from '@/types/member' import { http } from '@/utils/http'
export const getMemberProfileAPI = () => { return http<ProfileDetail>({ method: 'GET', url: '/member/profile' }) }
|
- 页面中存储数据并渲染,
src/pagesMember/profile/profile.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
| <script setup lang="ts"> import { getMemberProfileAPI } from '@/services/profile' import { onLoad } from '@dcloudio/uni-app' import { ref } from 'vue'
import type { ProfileDetail } from '@/types/member'
const { safeAreaInsets } = uni.getSystemInfoSync()
const profile = ref<ProfileDetail>() const getMemberProfileData = async () => { const res = await getMemberProfileAPI() profile.value = res.result } onLoad(() => { getMemberProfileData() }) </script>
<template> <view class="viewport"> <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }"> <navigator open-type="navigateBack" class="back icon-left" hover-class="none" ></navigator> <view class="title">个人信息</view> </view> <view class="avatar"> <view class="avatar-content"> <image class="image" :src="profile?.avatar" mode="aspectFill" /> <text class="text">点击修改头像</text> </view> </view> <view class="form"> <view class="form-content"> <view class="form-item"> <text class="label">账号</text> <text class="account">{{ profile?.account }}</text> </view> <view class="form-item"> <text class="label">昵称</text> <input class="input" type="text" placeholder="请填写昵称" :value="profile?.nickname" /> </view> <view class="form-item"> <text class="label">性别</text> <radio-group> <label class="radio"> <radio value="男" color="#27ba9b" :checked="profile?.gender === '男'" /> 男 </label> <label class="radio"> <radio value="女" color="#27ba9b" :checked="profile?.gender === '女'" /> 女 </label> </radio-group> </view> <view class="form-item"> <text class="label">生日</text> <picker class="picker" mode="date" start="1900-01-01" :end="new Date()" :value="profile?.birthday" > <view v-if="profile?.birthday">{{ profile.birthday }}</view> <view class="placeholder" v-else>请选择日期</view> </picker> </view> <view class="form-item"> <text class="label">城市</text> <picker class="picker" mode="region" :value="profile?.fullLocation?.split(' ')" > <view v-if="profile?.fullLocation">{{ profile.fullLocation }}</view> <view class="placeholder" v-else>请选择城市</view> </picker> </view> <view class="form-item"> <text class="label">职业</text> <input class="input" type="text" placeholder="请填写职业" :value="profile?.profession" /> </view> </view> <button class="form-button">保 存</button> </view> </view> </template>
|
小结一下
更新会员头像
通过 uni.chooseMedia()
读取用户相册的照片或者拍照。
通过 uni.uploadFile()
上传用户图片。

接口文档
给按钮绑定点击事件。
1
| <view class="avatar-content" @tap="onAvatarChange"></view>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| const memberStore = useMemberStore() const onAvatarChange = () => { uni.chooseMedia({ count: 1, mediaType: ['image'], success: (res) => { const { tempFilePath } = res.tempFiles[0] uni.uploadFile({ url: '/member/profile/avatar', name: 'file', filePath: tempFilePath, success: (res) => { if (res.statusCode === 200) { const { avatar } = JSON.parse(res.data).result profile.value!.avatar = avatar memberStore.profile!.avatar = avatar uni.showToast({ icon: 'success', title: '更新成功' }) } else { uni.showToast({ icon: 'error', title: '出现错误' }) } } }) } }) }
|
网页端上传文件用 Axios + FormData
。
小程序端上传文件用 wx.uploadFile()
。
使用 uni.uploadFile()
能自动多端兼容。
小结一下
修改用户昵称
涉及到 <input>
、<radio>
、<picker>
表单组件的数据收集。
- 接口封装,
services/profile.ts
。
1 2 3 4 5 6 7 8 9 10 11
|
export const putMemberProfileAPI = (data: ProfileParams) => { return http<ProfileDetail>({ method: 'PUT', url: '/member/profile', data, }) }
|
- 类型声明,
types/member.d.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13
| import type { ProfileDetail, ProfileParams } from '@/types/member'
export type ProfileParams = Pick< ProfileDetail, 'nickname' | 'gender' | 'birthday' | 'profession' > & { provinceCode?: string cityCode?: string countyCode?: string }
|
- 编写代码,
src/pagesMember/profile/profile.vue
。
通过 v-model
收集数据。
1
| <input class="input" type="text" placeholder="请填写昵称" v-model="profile.nickname"/>
|
- 提交表单,更新会员信息。
1
| <button @tap="onSubmit" class="form-button">保 存</button>
|
1 2 3 4 5 6 7 8 9 10
| const onSubmit = async () => { const { nickname } = profile.value const res = await putMemberProfileAPI({ nickname, }) uni.showToast({ icon: 'success', title: '保存成功' }) setTimeout(() => { uni.navigateBack() }, 400) }
|
小结一下
同步头像昵称
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| const onAvatarChange = () => { uni.chooseMedia({ count: 1, mediaType: ['image'], success: (res) => { const { tempFilePath } = res.tempFiles[0] uni.uploadFile({ url: '/member/profile/avatar', name: 'file', filePath: tempFilePath, success: (res) => { if (res.statusCode === 200) { const { avatar } = JSON.parse(res.data).result profile.value!.avatar = avatar memberStore.profile!.avatar = avatar uni.showToast({ icon: 'success', title: '更新成功' }) } else { uni.showToast({ icon: 'error', title: '出现错误' }) } } }) } }) }
|
1 2 3 4 5 6 7 8 9 10 11 12
| const onSubmit = async () => { const { nickname } = profile.value const res = await putMemberProfileAPI({ nickname, }) memberStore.profile!.nickname = res.result.nickname uni.showToast({ icon: 'success', title: '保存成功' }) setTimeout(() => { uni.navigateBack() }, 400) }
|
小结一下
修改性別
- 收集数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <radio-group @change="onGenderChange"> <label class="radio"> <radio value="男" color="#27ba9b" :checked="profile?.gender === '男'" /> 男 </label> <label class="radio"> <radio value="女" color="#27ba9b" :checked="profile?.gender === '女'" /> 女 </label> </radio-group>
|
1 2 3
| const onGenderChange: UniHelper.RadioGroupOnChange = (ev) => { profile.value.gender = ev.detail.value as Gender }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| const onSubmit = async () => { const { nickname, gender } = profile.value const res = await putMemberProfileAPI({ nickname, gender, }) memberStore.profile!.nickname = res.result.nickname uni.showToast({ icon: 'success', title: '保存成功' }) setTimeout(() => { uni.navigateBack() }, 400) }
|
小结一下
修改生日
- 收集数据。
1 2 3 4 5 6 7 8 9 10 11
| <picker class="picker" mode="date" start="1900-01-01" :end="new Date()" :value="profile?.birthday" @change="onBirthdayChange" > <view v-if="profile.birthday">{{ profile.birthday }}</view> <view class="placeholder" v-else>请选择日期</view> </picker>
|
1 2 3
| const onBirthdayChange: UniHelper.DatePickerOnChange = (ev) => { profile.value.birthday = ev.detail.value }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const onSubmit = async () => { const { nickname, gender, birthday } = profile.value const res = await putMemberProfileAPI({ nickname, gender, birthday, }) memberStore.profile!.nickname = res.result.nickname uni.showToast({ icon: 'success', title: '保存成功' }) setTimeout(() => { uni.navigateBack() }, 400) }
|
小结一下
修改城市
- 收集数据。
1 2 3 4 5 6 7 8 9
| <picker class="picker" mode="region" :value="profile?.fullLocation?.split(' ')" @change="onFullLocationChange" > <view v-if="profile?.fullLocation">{{ profile.fullLocation }}</view> <view class="placeholder" v-else>请选择城市</view> </picker>
|
1 2 3 4 5 6 7
| let fullLocationCode: [string, string, string] = ['', '', ''] const onFullLocationChange: UniHelper.RegionPickerOnChange = (ev) => { profile.value.fullLocation = ev.detail.value.join(' ') fullLocationCode = ev.detail.code! }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const onSubmit = async () => { const { nickname, gender, birthday } = profile.value const res = await putMemberProfileAPI({ nickname, gender, birthday, provinceCode: fullLocationCode[0], cityCode: fullLocationCode[1], countyCode: fullLocationCode[2] }) memberStore.profile!.nickname = res.result.nickname uni.showToast({ icon: 'success', title: '保存成功' }) setTimeout(() => { uni.navigateBack() }, 400) }
|
小结一下
最后总结
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
| <script setup lang="ts"> import { getMemberProfileAPI } from '@/services/profile' import { onLoad } from '@dcloudio/uni-app' import { ref } from 'vue' import type { ProfileDetail, Gender } from '@/types/member' import { useMemberStore } from '@/stores'
const { safeAreaInsets } = uni.getSystemInfoSync()
const profile = ref({} as ProfileDetail)
const getMemberProfileData = async () => { const res = await getMemberProfileAPI() profile.value = res.result } onLoad(() => { getMemberProfileData() })
const memberStore = useMemberStore() const onAvatarChange = () => { uni.chooseMedia({ count: 1, mediaType: ['image'], success: (res) => { const { tempFilePath } = res.tempFiles[0] uni.uploadFile({ url: '/member/profile/avatar', name: 'file', filePath: tempFilePath, success: (res) => { if (res.statusCode === 200) { const { avatar } = JSON.parse(res.data).result profile.value!.avatar = avatar memberStore.profile!.avatar = avatar uni.showToast({ icon: 'success', title: '更新成功' }) } else { uni.showToast({ icon: 'error', title: '出现错误' }) } } }) } }) }
const onGenderChange: UniHelper.RadioGroupOnChange = (ev) => { profile.value.gender = ev.detail.value as Gender }
const onBirthdayChange: UniHelper.DatePickerOnChange = (ev) => { profile.value.birthday = ev.detail.value }
let fullLocationCode: [string, string, string] = ['', '', ''] const onFullLocationChange: UniHelper.RegionPickerOnChange = (ev) => { profile.value.fullLocation = ev.detail.value.join(' ') fullLocationCode = ev.detail.code! } </script>
<template> <view class="viewport"> <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }"> <navigator open-type="navigateBack" class="back icon-left" hover-class="none" ></navigator> <view class="title">个人信息</view> </view> <view class="avatar"> <view class="avatar-content" @tap="onAvatarChange"> <image class="image" :src="profile?.avatar" mode="aspectFill" /> <text class="text">点击修改头像</text> </view> </view> <view class="form"> <view class="form-content"> <view class="form-item"> <text class="label">账号</text> <text class="account">{{ profile?.account }}</text> </view> <view class="form-item"> <text class="label">昵称</text> <input class="input" type="text" placeholder="请填写昵称" v-model="profile.nickname" /> </view> <view class="form-item"> <text class="label">性别</text> <radio-group @change="onGenderChange"> <label class="radio"> <radio value="男" color="#27ba9b" :checked="profile?.gender === '男'" /> 男 </label> <label class="radio"> <radio value="女" color="#27ba9b" :checked="profile?.gender === '女'" /> 女 </label> </radio-group> </view> <view class="form-item"> <text class="label">生日</text> <picker class="picker" mode="date" start="1900-01-01" :end="new Date()" :value="profile?.birthday" @change="onBirthdayChange" > <view v-if="profile?.birthday">{{ profile.birthday }}</view> <view class="placeholder" v-else>请选择日期</view> </picker> </view> <view class="form-item"> <text class="label">城市</text> <picker class="picker" mode="region" :value="profile?.fullLocation?.split(' ')" @change="onFullLocationChange" > <view v-if="profile?.fullLocation">{{ profile.fullLocation }}</view> <view class="placeholder" v-else>请选择城市</view> </picker> </view> <view class="form-item"> <text class="label">职业</text> <input class="input" type="text" placeholder="请填写职业" v-model="profile.profession" /> </view> </view> <button class="form-button">保 存</button> </view> </view> </template>
|
个人信息
静态结构
地址模块共两个页面:地址管理页,地址表单页 ,划分到会员分包中。

地址管理页
src/pagesMember/address/address.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
| <script setup lang="ts">
</script>
<template> <view class="viewport"> <scroll-view class="scroll-view" scroll-y> <view v-if="true" class="address"> <view class="address-list"> <view class="item"> <view class="item-content"> <view class="user"> 黑马小王子 <text class="contact">13111111111</text> <text v-if="true" class="badge">默认</text> </view> <view class="locate">广东省 广州市 天河区 黑马程序员</view> <navigator class="edit" hover-class="none" :url="`/pagesMember/address-form/address-form?id=1`" > 修改 </navigator> </view> </view> <view class="item"> <view class="item-content"> <view class="user"> 黑马小公主 <text class="contact">13222222222</text> <text v-if="false" class="badge">默认</text> </view> <view class="locate">北京市 北京市 顺义区 黑马程序员</view> <navigator class="edit" hover-class="none" :url="`/pagesMember/address-form/address-form?id=2`" > 修改 </navigator> </view> </view> </view> </view> <view v-else class="blank">暂无收货地址</view> </scroll-view> <view class="add-btn"> <navigator hover-class="none" url="/pagesMember/address-form/address-form"> 新建地址 </navigator> </view> </view> </template>
<style lang="scss"> page { height: 100%; overflow: hidden; }
.delete-button { display: flex; justify-content: center; align-items: center; width: 50px; height: 100%; font-size: 28rpx; color: #fff; border-radius: 0; padding: 0; background-color: #cf4444; }
.viewport { display: flex; flex-direction: column; height: 100%; background-color: #f4f4f4;
.scroll-view { padding-top: 20rpx; } }
.address { padding: 0 20rpx; margin: 0 20rpx; border-radius: 10rpx; background-color: #fff;
.item-content { line-height: 1; padding: 40rpx 10rpx 38rpx; border-bottom: 1rpx solid #ddd; position: relative;
.edit { position: absolute; top: 36rpx; right: 30rpx; padding: 2rpx 0 2rpx 20rpx; border-left: 1rpx solid #666; font-size: 26rpx; color: #666; line-height: 1; } }
.item:last-child .item-content { border: none; }
.user { font-size: 28rpx; margin-bottom: 20rpx; color: #333;
.contact { color: #666; }
.badge { display: inline-block; padding: 4rpx 10rpx 2rpx 14rpx; margin: 2rpx 0 0 10rpx; font-size: 26rpx; color: #27ba9b; border-radius: 6rpx; border: 1rpx solid #27ba9b; } }
.locate { line-height: 1.6; font-size: 26rpx; color: #333; } }
.blank { margin-top: 300rpx; text-align: center; font-size: 32rpx; color: #888; }
.add-btn { height: 80rpx; text-align: center; line-height: 80rpx; margin: 30rpx 20rpx; color: #fff; border-radius: 80rpx; font-size: 30rpx; background-color: #27ba9b; } </style>
|
地址表单页
src/pagesMember/address-form/address-form.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
| <script setup lang="ts"> import { ref } from 'vue'
const form = ref({ receiver: '', contact: '', fullLocation: '', provinceCode: '', cityCode: '', countyCode: '', address: '', isDefault: 0, }) </script>
<template> <view class="content"> <form> <view class="form-item"> <text class="label">收货人</text> <input class="input" placeholder="请填写收货人姓名" value="" /> </view> <view class="form-item"> <text class="label">手机号码</text> <input class="input" placeholder="请填写收货人手机号码" value="" /> </view> <view class="form-item"> <text class="label">所在地区</text> <picker class="picker" mode="region" value=""> <view v-if="false">广东省 广州市 天河区</view> <view v-else class="placeholder">请选择省/市/区(县)</view> </picker> </view> <view class="form-item"> <text class="label">详细地址</text> <input class="input" placeholder="街道、楼牌号等信息" value="" /> </view> <view class="form-item"> <label class="label">设为默认地址</label> <switch class="switch" color="#27ba9b" :checked="true" /> </view> </form> </view> <button class="button">保存并使用</button> </template>
<style lang="scss"> page { background-color: #f4f4f4; }
.content { margin: 20rpx 20rpx 0; padding: 0 20rpx; border-radius: 10rpx; background-color: #fff;
.form-item, .uni-forms-item { display: flex; align-items: center; min-height: 96rpx; padding: 25rpx 10rpx; background-color: #fff; font-size: 28rpx; border-bottom: 1rpx solid #ddd; position: relative; margin-bottom: 0;
// 调整 uni-forms 样式 .uni-forms-item__content { display: flex; }
.uni-forms-item__error { margin-left: 200rpx; }
&:last-child { border: none; }
.label { width: 200rpx; color: #333; }
.input { flex: 1; display: block; height: 46rpx; }
.switch { position: absolute; right: -20rpx; transform: scale(0.8); }
.picker { flex: 1; }
.placeholder { color: #808080; } } }
.button { height: 80rpx; margin: 30rpx 20rpx; color: #fff; border-radius: 80rpx; font-size: 30rpx; background-color: #27ba9b; } </style>
|
检查分包
pages.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| { "subPackages": [ { "root": "pagesMember", "pages": [ { "path": "settings/settings", "style": { "navigationBarTitleText": "设置" } }, { "path": "profile/profile", "style": { "navigationStyle": "custom", "navigationBarTextStyle": "white", "navigationBarTitleText": "个人信息" } }, { "path": "address/address", "style": { "navigationBarTitleText": "收货地址" } }, { "path": "address-form/address-form", "style": { "navigationBarTitleText": "" } } ] }, ] }
|
动态设置标题
新建地址 和 修改地址 复用同一个地址表单页,需要根据页面参数 id
动态设置页面标题。
src/pagesMember/address-form/address-form.vue
1 2 3 4 5 6
| const query = defineProps<{ id?: string }>() uni.setNavigationBarTitle({ title: query.id ? '修改地址' : '新增地址' })
|
小结一下
新建地址页
新用户没有收货地址,先完成新建地址,新建成功返回地址管理页。
主要功能:前端收集表单的数据,提交表单给后端。

类型声明
src/types/address.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| export type AddressParams = { receiver: string contact: string provinceCode: string cityCode: string countyCode: string address: string isDefault: number }
|
接口封装
src/services/address.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import type { AddressParams } from '@/types/address' import { http } from '@/utils/http'
export const postMemberAddressAPI = (data: AddressParams) => { return http({ method: 'POST', url: '/member/address', data }) }
|
参考代码
地址表单页,input
组件通过 v-model
获取数据,其他表单组件结合 @change
事件获取,src/pagesMember/address-form.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
| <script setup lang="ts"> import { ref } from 'vue' import { postMemberAddressAPI } from '@/services/address'
const form = ref({ receiver: '', contact: '', fullLocation: '', provinceCode: '', cityCode: '', countyCode: '', address: '', isDefault: 0 })
const query = defineProps<{ id?: string }>() uni.setNavigationBarTitle({ title: query.id ? '修改地址' : '新增地址' })
const onRegionChange: UniHelper.RegionPickerOnChange = (ev) => { form.value.fullLocation = ev.detail.value.join(' ') const [provinceCode, cityCode, countyCode] = ev.detail.code! Object.assign(form.value, { provinceCode, cityCode, countyCode }) }
const onSwitchChange: UniHelper.SwitchOnChange = (ev) => { form.value.isDefault = ev.detail.value ? 1 : 0 }
const onSubmit = async () => { await postMemberAddressAPI(form.value) uni.showToast({ icon: 'none', title: '添加成功' }) setTimeout(() => { uni.navigateBack() }, 400) } </script>
<template> <view class="content"> <form> <view class="form-item"> <text class="label">收货人</text> <input class="input" placeholder="请填写收货人姓名" v-model="form.receiver" /> </view> <view class="form-item"> <text class="label">手机号码</text> <input class="input" placeholder="请填写收货人手机号码" v-model="form.contact" /> </view> <view class="form-item"> <text class="label">所在地区</text> <picker class="picker" mode="region" :value="form.fullLocation.split(' ')" @change="onRegionChange" > <view v-if="form.fullLocation">广东省 广州市 天河区</view> <view v-else class="placeholder">请选择省/市/区(县)</view> </picker> </view> <view class="form-item"> <text class="label">详细地址</text> <input class="input" placeholder="街道、楼牌号等信息" v-model="form.address" /> </view> <view class="form-item"> <label class="label">设为默认地址</label> <switch class="switch" color="#27ba9b" :checked="form.isDefault === 1" @change="onSwitchChange" /> </view> </form> </view> <button class="button" @tap="onSubmit">保存并使用</button> </template>
|
小结一下
地址管理页
为了能及时看到新建的收货地址,需在 onShow
生命周期中获取地址列表数据。

类型声明
src/types/address.d.ts
1 2 3 4 5 6
| export type AddressItem = AddressParams & { id: string; fullLocation: string }
|
商品详情复用地址类型:src/types/goods.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| + import type { GoodsItem } from './global'
- /** 地址信息 */ - export type AddressItem = { - receiver: string - contact: string - provinceCode: string - cityCode: string - countyCode: string - address: string - isDefault: number - id: string - fullLocation: string - }
|
接口封装
src/services/address.ts
1 2 3 4 5 6 7 8 9 10
| import type { AddressItem } from '@/types/address'
export const getMemberAddressAPI = () => { return http<AddressItem[]>({ method: 'GET', url: '/member/address', }) }
|
调用接口
src/pagesMember/address/address.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { getMemberAddressAPI } from '@/services/address' import type { AddressItem } from '@/types/address' import { onShow } from '@dcloudio/uni-app' import { ref } from 'vue'
const addressList = ref<AddressItem[]>([]) const getMemberAddressData = async () => { const res = await getMemberAddressAPI() addressList.value = res.result }
onShow(() => { getMemberAddressData() })
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <view class="address-list"> <view class="item" v-for="item in addressList" :key="item.id"> <view class="item-content"> <view class="user"> {{ item.receiver }} <text class="contact">{{ item.contact }}</text> <text v-if="item.isDefault" class="badge">默认</text> </view> <view class="locate" >{{ item.fullLocation }} {{ item.address }}</view > <navigator class="edit" hover-class="none" :url="`/pagesMember/address-form/address-form?id=${item.id}`" > 修改 </navigator> </view> </view> </view>
|
小结一下
修改地址页
通过页面参数 id
来区分当前是修改地址还是新建地址。

数据回显
修改地址之前,需要先实现数据回显,用户再进行有针对性的修改。
- 接口封装,
src/services/address.ts
。
1 2 3 4 5 6 7 8 9 10
|
export const getMemberAddressByIdAPI = (id: string) => { return http<AddressItem>({ method: 'GET', url: `/member/address/${id}`, }) }
|
- 页面初始化的时候根据
id
获取地址详情,把获取的数据合并到表单数据中,用于数据回显,src/pagesMember/address-form/address-form.vue
。
1 2 3 4 5 6 7 8 9 10
| const getMemberAddressByIdData = () => { if (query.id) { const res = await getMemberAddressByIdAPI(query.id) Object.assign(form.value, res.result) } } onLoad(() => { getMemberAddressByIdData() })
|
小结一下
更新地址
将用户修改后的地址信息重新发送到服务端进行存储。
- 接口封装,
src/services/address.ts
。
1 2 3 4 5 6 7 8 9 10 11 12
|
export const putMemberAddressByIdAPI = (id: string, data: AddressParams) => { return http({ method: 'PUT', url: `/member/address/${id}`, data, }) }
|
- 根据是否有地址 id 来判断提交表单到底是新建地址还是更新地址,
src/pagesMember/address-form/address-form.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const onSubmit = async () => { if (query.id) { await putMemberAddressByIdAPI(query.id, form.value) } else { await postMemberAddressAPI(form.value) } uni.showToast({ icon: 'none', title: query.id ? '修改成功' : '添加成功' }) setTimeout(() => { uni.navigateBack() }, 400) }
|
小结一下
表单校验
通过 uni-ui
组件库的 uni-forms 组件实现表单校验。

操作步骤
定义校验规则。
修改表单结构。
绑定校验规则。
提交时校验表单。
参考代码
src/pagesMember/address-form/address-form.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
| <script setup lang="ts">
const rules: UniHelper.UniFormsRules = { receiver: { rules: [{ required: true, errorMessage: '请输入收货人姓名' }] }, contact: { rules: [ { required: true, errorMessage: '请输入联系方式' }, { pattern: /^1[3-9]\d{9}$/, errorMessage: '手机号格式不正确' } ] }, fullLocation: { rules: [{ required: true, errorMessage: '请选择所在地区' }] }, address: { rules: [{ required: true, errorMessage: '请选择详细地址' }] } }
const formRef = ref<UniHelper.UniFormsInstance>()
const onSubmit = async () => { try { await formRef.value?.validate?.() if (query.id) { await putMemberAddressByIdAPI(query.id, form.value) } else { await postMemberAddressAPI(form.value) } uni.showToast() setTimeout(() => { uni.navigateBack() }, 400) } catch (error) { uni.showToast() } } </script>
<template> <view class="content"> <uni-forms :rules="rules" :model="form" ref="formRef"> <uni-forms-item name="receiver" class="form-item"> <text class="label">收货人</text> <input class="input" placeholder="请填写收货人姓名" v-model="form.receiver" /> </uni-forms-item> <uni-forms-item name="contact" class="form-item"> <text class="label">手机号码</text> <input class="input" placeholder="请填写收货人手机号码" :maxlength="11" v-model="form.contact" /> </uni-forms-item> <uni-forms-item name="fullLocation" class="form-item"> <text class="label">所在地区</text> <picker @change="onRegionChange" class="picker" mode="region" :value="form.fullLocation.split(' ')" > <view v-if="form.fullLocation"></view> <view v-else class="placeholder">请选择省/市/区(县)</view> </picker> </uni-forms-item> <uni-forms-item name="address" class="form-item"> <text class="label">详细地址</text> <input class="input" placeholder="街道、楼牌号等信息" v-model="form.address" /> </uni-forms-item> <view class="form-item"> <label class="label">设为默认地址</label> <switch @change="onSwitchChange" class="switch" color="#27ba9b" :checked="form.isDefault === 1" /> </view> </uni-forms> </view> <button @tap="onSubmit" class="button">保存并使用</button> </template>
|
小结一下
删除地址
通过 uni-ui
组件库的 uni-swipe-action 组件实现侧滑删除。

侧滑组件用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <template> <uni-swipe-action> <uni-swipe-action-item> <view>内容</view> <template #right> <button class="delete-button">删除</button> </template> </uni-swipe-action-item> </uni-swipe-action> </template>
|
接口封装
services/address.ts
1 2 3 4 5 6 7 8 9 10
|
export const deleteMemberAddressByIdAPI = (id: string) => { return http({ method: 'DELETE', url: `/member/address/${id}`, }) }
|
参考代码
侧滑地址列表项,右侧显示删除按钮,删除地址前需二次确认,pagesMember/address/address.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
| <script setup lang="ts"> import { deleteMemberAddressByIdAPI, getMemberAddressAPI } from '@/services/address' import type { AddressItem } from '@/types/address' import { onShow } from '@dcloudio/uni-app' import { ref } from 'vue'
const addressList = ref<AddressItem[]>([]) const getMemberAddressData = async () => { const res = await getMemberAddressAPI() addressList.value = res.result }
onShow(() => { getMemberAddressData() })
const onDeleteAddress = (id: string) => { uni.showModal({ content: '删除地址?', success: async (res) => { if (res.confirm) { await deleteMemberAddressByIdAPI(id) getMemberAddressData() } } }) } </script>
<template> <view class="viewport"> <scroll-view class="scroll-view" scroll-y> <view v-if="addressList.length" class="address"> <uni-swipe-action class="address-list"> <uni-swipe-action-item class="item" v-for="item in addressList" :key="item.id" > <view class="item-content"> <view class="user"> {{ item.receiver }} <text class="contact">{{ item.contact }}</text> <text v-if="item.isDefault" class="badge">默认</text> </view> <view class="locate" >{{ item.fullLocation }} {{ item.address }}</view > <navigator class="edit" hover-class="none" :url="`/pagesMember/address-form/address-form?id=${item.id}`" > 修改 </navigator> </view> <template #right> <button @tap="onDeleteAddress(item.id)" class="delete-button"> 删除 </button> </template> </uni-swipe-action-item> </uni-swipe-action> </view> <view v-else class="blank">暂无收货地址</view> </scroll-view> <view class="add-btn"> <navigator hover-class="none" url="/pagesMember/address-form/address-form" > 新建地址 </navigator> </view> </view> </template>
|
小结一下