危险

为之则易,不为则难

0%

04_小兔鲜

✔ 掌握登录模块的业务逻辑。

✔ 掌握会员中心的业务逻辑。

✔ 掌握个人信息的业务逻辑。

登录方式

  1. 用户名/手机号 + 密码。

  2. 手机号 + 验证码。

  3. 授权登录。

实际开发过程中常常需要实现以上的一种或多种方式,我们的项目主要实现授权登录

微信授权登录

用户在使用小程序时,其实已登录微信,其本质上就是:微信授权给小程序读取微信用户信息

微信授权登录

传统登录方式

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

传统密码登录

静态结构

登录页,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>

获取登录凭证

  • 前端:调用 wx.login() 接口获取登录凭证(code)。

  • 后端:通过凭证(code)向微信服务器换取用户登录态信息。

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
})

面试:小程序登录流程

获取手机授权

出于安全限制,小程序【规定】想获取用户的手机号,必须由用户主动【点击按钮】并【允许申请】才可获取加密的手机号信息。

授权登录

  • 前端:提供 open-type 按钮,在事件处理函数中获取加密的手机号信息。

  • 后端:解密手机号信息,把手机号和用户登录态关联在一起。

pages/login/login.vue

1
2
3
4
5
6
// #2 获取用户手机号码
const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = (ev) => {
const encrytedData = ev.detail!.encryptedData!
const iv = ev.detail!.iv!
console.log(encrytedData, iv)
}
1
2
3
4
5
<!-- #1 -->
<button class="button phone" open-type="getPhoneNumber" @getphonenumber="onGetphonenumber">
<text class="icon icon-phone"></text>
手机号快捷登录
</button>

🤔 为什么我无法唤起获取手机号的界面?

获取手机号功能目前针对非个人开发者,所以个人开发者无法唤起获取手机号界面,详见文档

调用登录接口(生产环境)

具体步骤

  1. 封装接口,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
})
}
  1. 页面中调用,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'
// #1
import { postLoginWxMinAPI } from '@/services/login'

let code = ''
onLoad(async () => {
const res = await uni.login()
code = res.code
})

// #2
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)
}

小结一下

模拟手机登录(开发环境)

获取手机号功能,目前针对非个人开发者,且完成了认证的小程序开放详见文档,为了更好实现登录后续的业务,后端提供了一个内部测试用的接口,只需要传手机号即可实现快捷登录。

请求封装

  1. services/login.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 小程序登录_内测版
* @param phoneNumber 模拟手机号码
*/
export const postLoginWxMinSimpleAPI = (phoneNumber: string) => {
return http({
method: 'POST',
url: '/login/wxMin/simple',
data: {
phoneNumber
}
})
}
  1. 页面中调用,pages/login/login.vue
1
2
3
4
5
6
7
// #1
import { postLoginWxMinSimpleAPI } from '@/services/login'
// #2
const onGetphonenumberSimple = async () => {
const res = await postLoginWxMinSimpleAPI('13123456789')
console.log(res)
}
1
2
3
4
5
<!-- #1 -->
<button class="button phone" @tap="onGetphonenumberSimple">
<text class="icon icon-phone"></text>
手机号快捷登录
</button>

小结一下

用户信息持久化存储

Pinia 的持久化存储插件在 项目起步 模块已经搭建完成,现在只需要在用户登录成功后,补充 TS 类型声明并保存用户信息即可。

具体步骤

  1. 指定后端返回数据的类型,types/member.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** 小程序登录 登录用户信息 */
export type LoginResult = {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
/** 手机号 */
mobile: string
/** 登录凭证 */
token: string
}
  1. 调整接口,services/login.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 小程序登录_内测版
* @param phoneNumber 模拟手机号码
*/
export const postLoginWxMinSimpleAPI = (phoneNumber: string) => {
return http<LoginResult>({
method: 'POST',
url: '/login/wxMin/simple',
data: {
phoneNumber
}
})
}
  1. 给 store 中相关方法的参数指定类型,stores/modules/member.ts
1
2
3
4
5
6
7
// 会员信息
const profile = ref<LoginResult>()

// 保存会员信息,登录时使用
const setProfile = (val: LoginResult) => {
profile.value = val
}
  1. 登录页,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)
}

小结一下

会员中心页(我的)

主要实现两部分业务。

  1. 渲染当前登录会员的昵称和头像,从 Store 中获取。

  2. 猜你喜欢分页加载,可封装成组合式函数实现复用逻辑。

我的

静态结构

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' }">
<!-- 情况1:已登录 -->
<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>
<!-- 情况2:未登录 -->
<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'

// ...
// #1
const memberStore = useMemberStore()
</script>

<template>
<scroll-view class="viewport" scroll-y enable-back-to-top>
<!-- 个人资料 -->
<view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }">
<!-- 情况1:已登录 -->
<!-- #2 -->
<view class="overview" v-if="memberStore.profile">
<navigator url="/pagesMember/profile/profile" hover-class="none">
<!-- #3 -->
<image
class="avatar"
mode="aspectFill"
:src="memberStore.profile.avatar"
></image>
</navigator>
<view class="meta">
<!-- #4 -->
<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>
<!-- 情况2:未登录 -->
<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">
// ...

// #1
const guessRef = ref<XtxGuessInstance>()

// #4
const onScrolltolower = () => {
guessRef.value?.getMore()
}
</script>

<template>
<!-- #3 绑定 @scrolltolower -->
<scroll-view
class="viewport"
scroll-y
enable-back-to-top
@scrolltolower="onScrolltolower"
>
<!-- ... -->
<!-- 猜你喜欢 -->
<view class="guess">
<!-- #2 -->
<XtxGuess ref="guessRef" />
</view>
</scroll-view>
</template>

封装函数

  1. 封装猜你喜欢组合式函数,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()
}

// 返回 ref 和事件处理函数
return { guessRef, onScrolltolower }
}
  1. 页面中使用,pages/my/my.vue
1
2
3
4
// #1
import { useGuessList } from '@/composables'
// #2
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">
<!-- 列表1 -->
<view class="list" v-if="true">
<navigator url="/pagesMember/address/address" hover-class="none" class="item arrow">
我的收货地址
</navigator>
</view>
<!-- 列表2 -->
<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>
<!-- 列表3 -->
<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>

小结一下

退出登录

设置页需实现以下业务。

  1. 退出登录,清理用户信息,返回上一页。

  2. 根据登录状态,按需展示页面内容。

参考效果

登录状态

参考代码

pagesMember/settings/settings.vue

1
2
<!-- #1 -->
<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()

// #2
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">
<!-- #1 -->
<view class="button" @tap="onLogout">退出登录</view>
</view>
</view>

小结一下

会员信息页

用户可以对会员信息进行更新操作,涉及到表单数据提交、图片读取、文件上传等知识点。

会员信息页

静态结构

  1. 会员信息页,处理成分包页面: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. 隐藏个人信息的的头部。
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": "个人信息"
}
}
]
}
]
}

小结一下

获取会员信息

  1. 封装接口,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',
})
}
  1. 页面中调用,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()
})
  1. 类型声明,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 */
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 */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
}

/** 小程序登录 登录用户信息 */
export type LoginResult = BaseProfile & {
/** 用户ID */
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 = '女' | '男'
  1. 修改接口,指定后端返回数据的类型,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'
})
}
  1. 页面中存储数据并渲染,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'
// #1
import type { ProfileDetail } from '@/types/member'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()

// #2
const profile = ref<ProfileDetail>()
const getMemberProfileData = async () => {
const res = await getMemberProfileAPI()
// #3
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">
<!-- #4 -->
<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>
<!-- #5 -->
<text class="account">{{ profile?.account }}</text>
</view>
<view class="form-item">
<text class="label">昵称</text>
<!-- #6 -->
<input
class="input"
type="text"
placeholder="请填写昵称"
:value="profile?.nickname"
/>
</view>
<view class="form-item">
<text class="label">性别</text>
<radio-group>
<label class="radio">
<!-- #7 -->
<radio
value="男"
color="#27ba9b"
:checked="profile?.gender === '男'"
/>

</label>
<label class="radio">
<!-- #8 -->
<radio
value="女"
color="#27ba9b"
:checked="profile?.gender === '女'"
/>

</label>
</radio-group>
</view>
<view class="form-item">
<text class="label">生日</text>
<!-- #9 -->
<picker
class="picker"
mode="date"
start="1900-01-01"
:end="new Date()"
:value="profile?.birthday"
>
<!-- #10 -->
<view v-if="profile?.birthday">{{ profile.birthday }}</view>
<view class="placeholder" v-else>请选择日期</view>
</picker>
</view>
<view class="form-item">
<text class="label">城市</text>
<!-- #11 -->
<picker
class="picker"
mode="region"
:value="profile?.fullLocation?.split(' ')"
>
<!-- #12 -->
<view v-if="profile?.fullLocation">{{ profile.fullLocation }}</view>
<view class="placeholder" v-else>请选择城市</view>
</picker>
</view>
<view class="form-item">
<text class="label">职业</text>
<!-- #13 -->
<input
class="input"
type="text"
placeholder="请填写职业"
:value="profile?.profession"
/>
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button">保 存</button>
</view>
</view>
</template>

小结一下

更新会员头像

  1. 通过 uni.chooseMedia() 读取用户相册的照片或者拍照。

  2. 通过 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
// 更新 Store 头像
memberStore.profile!.avatar = avatar
uni.showToast({ icon: 'success', title: '更新成功' })
} else {
uni.showToast({ icon: 'error', title: '出现错误' })
}
}
})
}
})
}
  • 网页端上传文件用 Axios + FormData

  • 小程序端上传文件用 wx.uploadFile()

  • 使用 uni.uploadFile() 能自动多端兼容。

小结一下

修改用户昵称

涉及到 <input><radio><picker> 表单组件的数据收集。

  1. 接口封装,services/profile.ts
1
2
3
4
5
6
7
8
9
10
11
/**
* 修改个人信息
* @param data 请求体参数
*/
export const putMemberProfileAPI = (data: ProfileParams) => {
return http<ProfileDetail>({
method: 'PUT',
url: '/member/profile',
data,
})
}
  1. 类型声明,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
}
  1. 编写代码,src/pagesMember/profile/profile.vue

通过 v-model 收集数据。

1
<input class="input" type="text" placeholder="请填写昵称" v-model="profile.nickname"/>
  1. 提交表单,更新会员信息。
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
// #mark
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,
})
// #mark
memberStore.profile!.nickname = res.result.nickname
uni.showToast({ icon: 'success', title: '保存成功' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}

小结一下

修改性別

  1. 收集数据。
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,
})
// 更新Store昵称
memberStore.profile!.nickname = res.result.nickname
uni.showToast({ icon: 'success', title: '保存成功' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}

小结一下

修改生日

  1. 收集数据。
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,
})
// 更新Store昵称
memberStore.profile!.nickname = res.result.nickname
uni.showToast({ icon: 'success', title: '保存成功' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}

小结一下

修改城市

  1. 收集数据。
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]
})
// 更新Store昵称
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<ProfileDetail>()
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
// 更新 Store 头像
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">
<!-- #4 -->
<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>
<!-- #1 -->
<input
class="input"
type="text"
placeholder="请填写昵称"
v-model="profile.nickname"
/>
</view>
<view class="form-item">
<text class="label">性别</text>
<!-- #2 -->
<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>
<!-- #3 -->
<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>
<!-- #4 -->
<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>
<!-- #5 -->
<input
class="input"
type="text"
placeholder="请填写职业"
v-model="profile.profession"
/>
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button">保 存</button>
</view>
</view>
</template>

个人信息

静态结构

地址模块共两个页面:地址管理页,地址表单页 ,划分到会员分包中。

picture_31

地址管理页

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, // 默认地址,1为是,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
/** 默认地址,1为是,0为否 */
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'

/**
* 添加收货地址
* @param data 请求参数
*/
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 // 默认地址,1为是,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>
<!-- #1 -->
<input
class="input"
placeholder="请填写收货人姓名"
v-model="form.receiver"
/>
</view>
<view class="form-item">
<text class="label">手机号码</text>
<!-- #2 -->
<input
class="input"
placeholder="请填写收货人手机号码"
v-model="form.contact"
/>
</view>
<view class="form-item">
<text class="label">所在地区</text>
<!-- #3 -->
<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>
<!-- #4 -->
<input
class="input"
placeholder="街道、楼牌号等信息"
v-model="form.address"
/>
</view>
<view class="form-item">
<label class="label">设为默认地址</label>
<!-- #5 -->
<switch
class="switch"
color="#27ba9b"
:checked="form.isDefault === 1"
@change="onSwitchChange"
/>
</view>
</form>
</view>
<!-- 提交按钮 -->
<!-- #6 -->
<button class="button" @tap="onSubmit">保存并使用</button>
</template>

小结一下

地址管理页

为了能及时看到新建的收货地址,需在 onShow 生命周期中获取地址列表数据。

地址管理页

类型声明

src/types/address.d.ts

1
2
3
4
5
6
export type AddressItem = AddressParams & {
// 收获地址 ID
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 来区分当前是修改地址还是新建地址

修改地址页

数据回显

修改地址之前,需要先实现数据回显,用户再进行有针对性的修改。

  1. 接口封装,src/services/address.ts
1
2
3
4
5
6
7
8
9
10
/**
* 获取收货地址详情
* @param id 地址id(路径参数)
*/
export const getMemberAddressByIdAPI = (id: string) => {
return http<AddressItem>({
method: 'GET',
url: `/member/address/${id}`,
})
}
  1. 页面初始化的时候根据 id 获取地址详情,把获取的数据合并到表单数据中,用于数据回显,src/pagesMember/address-form/address-form.vue
1
2
3
4
5
6
7
8
9
10
const getMemberAddressByIdData = () => {
// const res = await getMemberAddressByIdAPI(query!.id!)
if (query.id) {
const res = await getMemberAddressByIdAPI(query.id)
Object.assign(form.value, res.result)
}
}
onLoad(() => {
getMemberAddressByIdData()
})

小结一下

更新地址

将用户修改后的地址信息重新发送到服务端进行存储。

  1. 接口封装,src/services/address.ts
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 修改收货地址
* @param id 地址id(路径参数)
* @param data 表单数据(请求体参数)
*/
export const putMemberAddressByIdAPI = (id: string, data: AddressParams) => {
return http({
method: 'PUT',
url: `/member/address/${id}`,
data,
})
}
  1. 根据是否有地址 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 组件实现表单校验。

表单校验

操作步骤

  1. 定义校验规则。

  2. 修改表单结构。

  3. 绑定校验规则。

  4. 提交时校验表单。

参考代码

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>() // [!code ++]

// 提交表单
const onSubmit = async () => {
try {
// 表单校验
await formRef.value?.validate?.() // [!code ++]
// 校验通过后再发送请求
if (query.id) {
// 修改地址请求
await putMemberAddressByIdAPI(query.id, form.value)
} else {
// 新建地址请求
await postMemberAddressAPI(form.value)
}
// 成功提示
uni.showToast()
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 400)
} catch (error) {
uni.showToast() // [!code ++]
}
}
</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
/**
* 删除收货地址
* @param id 地址id(路径参数)
*/
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()
})

// #3
const onDeleteAddress = (id: string) => {
// 二次确认
uni.showModal({
content: '删除地址?',
success: async (res) => {
if (res.confirm) {
// 根据id删除收货地址
await deleteMemberAddressByIdAPI(id)
// 重新获取收货地址列表
getMemberAddressData()
}
}
})
}
</script>

<template>
<view class="viewport">
<!-- 地址列表 -->
<scroll-view class="scroll-view" scroll-y>
<!-- #1 -->
<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>
<!-- #2 -->
<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>

小结一下