危险

为之则易,不为则难

0%

06_小兔鲜

✔ 完成填写订单模块的开发。

✔ 完成订单详情模块的开发。

✔ 完成订单列表模块的开发。

加入购物车

在商品详情页把 选中规格后的商品(SKU) 加入购物车。

购物车

接口封装

src/services/cart.ts

1
2
3
4
5
6
7
8
9
10
11
12
import { http } from '@/utils/http'
/**
* 加入购物车
* @param data 请求体参数
*/
export const postMemberCartAPI = (data: { skuId: string; count: number }) => {
return http({
method: 'POST',
url: '/member/cart',
data,
})
}

参考代码

通过 SKU 组件提供的 add-cart 事件,获取加入购物车时所需的参数,pages/goods/goods.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { postMemberCartAPI } from '@/services/cart'
import type { SkuPopupEvent } from '@/components/vk-data-goods-sku-popup/vk-data-goods-sku-popup'
// 加入购物车事件
const onAddCart = async (ev: SkuPopupEvent) => {
await postMemberCartAPI({ skuId: ev._id, count: ev.buy_num })
uni.showToast({ title: '添加成功' })
isShowSku.value = false
}
</script>

<template>
<!-- SKU弹窗组件 -->
<vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" @add-cart="onAddCart" />
</template>

小结一下

购物车列表

购物车列表需要访问后才能登录。

购物车

静态结构

src/pages/cart/cart.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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
<script setup lang="ts">
//
</script>

<template>
<scroll-view scroll-y class="scroll-view">
<!-- 已登录: 显示购物车 -->
<template v-if="true">
<!-- 购物车列表 -->
<view class="cart-list" v-if="true">
<!-- 优惠提示 -->
<view class="tips">
<text class="label">满减</text>
<text class="desc">满1件, 即可享受9折优惠</text>
</view>
<!-- 滑动操作分区 -->
<uni-swipe-action>
<!-- 滑动操作项 -->
<uni-swipe-action-item v-for="item in 2" :key="item" class="cart-swipe">
<!-- 商品信息 -->
<view class="goods">
<!-- 选中状态 -->
<text class="checkbox" :class="{ checked: true }"></text>
<navigator
:url="`/pages/goods/goods?id=1435025`"
hover-class="none"
class="navigator"
>
<image
mode="aspectFill"
class="picture"
src="https://yanxuan-item.nosdn.127.net/da7143e0103304f0f3230715003181ee.jpg"
></image>
<view class="meta">
<view class="name ellipsis">人手必备,儿童轻薄透气防蚊裤73-140cm</view>
<view class="attrsText ellipsis">黄色小象 140cm</view>
<view class="price">69.00</view>
</view>
</navigator>
<!-- 商品数量 -->
<view class="count">
<text class="text">-</text>
<input class="input" type="number" value="1" />
<text class="text">+</text>
</view>
</view>
<!-- 右侧删除按钮 -->
<template #right>
<view class="cart-swipe-right">
<button class="button delete-button">删除</button>
</view>
</template>
</uni-swipe-action-item>
</uni-swipe-action>
</view>
<!-- 购物车空状态 -->
<view class="cart-blank" v-else>
<image src="/static/images/blank_cart.png" class="image" />
<text class="text">购物车还是空的,快来挑选好货吧</text>
<navigator url="/pages/index/index" hover-class="none">
<button class="button">去首页看看</button>
</navigator>
</view>
<!-- 吸底工具栏 -->
<view class="toolbar">
<text class="all" :class="{ checked: true }">全选</text>
<text class="text">合计:</text>
<text class="amount">100</text>
<view class="button-grounp">
<view class="button payment-button" :class="{ disabled: true }"> 去结算(10) </view>
</view>
</view>
</template>
<!-- 未登录: 提示登录 -->
<view class="login-blank" v-else>
<text class="text">登录后可查看购物车中的商品</text>
<navigator url="/pages/login/login" hover-class="none">
<button class="button">去登录</button>
</navigator>
</view>
<!-- 猜你喜欢 -->
<XtxGuess ref="guessRef"></XtxGuess>
<!-- 底部占位空盒子 -->
<view class="toolbar-height"></view>
</scroll-view>
</template>

<style lang="scss">
// 根元素
:host {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #f7f7f8;
}

// 滚动容器
.scroll-view {
flex: 1;
}

// 购物车列表
.cart-list {
padding: 0 20rpx;

// 优惠提示
.tips {
display: flex;
align-items: center;
line-height: 1;
margin: 30rpx 10rpx;
font-size: 26rpx;
color: #666;

.label {
color: #fff;
padding: 7rpx 15rpx 5rpx;
border-radius: 4rpx;
font-size: 24rpx;
background-color: #27ba9b;
margin-right: 10rpx;
}
}

// 购物车商品
.goods {
display: flex;
padding: 20rpx 20rpx 20rpx 80rpx;
border-radius: 10rpx;
background-color: #fff;
position: relative;

.navigator {
display: flex;
}

.checkbox {
position: absolute;
top: 0;
left: 0;

display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 100%;

&::before {
content: '\e6cd';
font-family: 'erabbit' !important;
font-size: 40rpx;
color: #444;
}

&.checked::before {
content: '\e6cc';
color: #27ba9b;
}
}

.picture {
width: 170rpx;
height: 170rpx;
}

.meta {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
margin-left: 20rpx;
}

.name {
height: 72rpx;
font-size: 26rpx;
color: #444;
}

.attrsText {
line-height: 1.8;
padding: 0 15rpx;
font-size: 24rpx;
align-self: flex-start;
border-radius: 4rpx;
color: #888;
background-color: #f7f7f8;
}

.price {
line-height: 1;
font-size: 26rpx;
color: #444;
margin-bottom: 2rpx;
color: #cf4444;

&::before {
content: '¥';
font-size: 80%;
}
}

// 商品数量
.count {
position: absolute;
bottom: 20rpx;
right: 5rpx;

display: flex;
justify-content: space-between;
align-items: center;
width: 220rpx;
height: 48rpx;

.text {
height: 100%;
padding: 0 20rpx;
font-size: 32rpx;
color: #444;
}

.input {
height: 100%;
text-align: center;
border-radius: 4rpx;
font-size: 24rpx;
color: #444;
background-color: #f6f6f6;
}
}
}

.cart-swipe {
display: block;
margin: 20rpx 0;
}

.cart-swipe-right {
display: flex;
height: 100%;

.button {
display: flex;
justify-content: center;
align-items: center;
width: 50px;
padding: 6px;
line-height: 1.5;
color: #fff;
font-size: 26rpx;
border-radius: 0;
}

.delete-button {
background-color: #cf4444;
}
}
}

// 空状态
.cart-blank,
.login-blank {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 60vh;
.image {
width: 400rpx;
height: 281rpx;
}
.text {
color: #444;
font-size: 26rpx;
margin: 20rpx 0;
}
.button {
width: 240rpx !important;
height: 60rpx;
line-height: 60rpx;
margin-top: 20rpx;
font-size: 26rpx;
border-radius: 60rpx;
color: #fff;
background-color: #27ba9b;
}
}

// 吸底工具栏
.toolbar {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--window-bottom));
z-index: 1;

height: 100rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
border-top: 1rpx solid #ededed;
border-bottom: 1rpx solid #ededed;
background-color: #fff;

.all {
margin-left: 25rpx;
font-size: 14px;
color: #444;
display: flex;
align-items: center;
}

.all::before {
font-family: 'erabbit' !important;
content: '\e6cd';
font-size: 40rpx;
margin-right: 8rpx;
}

.checked::before {
content: '\e6cc';
color: #27ba9b;
}

.text {
margin-right: 8rpx;
margin-left: 32rpx;
color: #444;
font-size: 14px;
}

.amount {
font-size: 20px;
color: #cf4444;

.decimal {
font-size: 12px;
}

&::before {
content: '¥';
font-size: 12px;
}
}

.button-grounp {
position: absolute;
right: 10rpx;
top: 50%;

display: flex;
justify-content: space-between;
text-align: center;
line-height: 72rpx;
font-size: 13px;
color: #fff;
transform: translateY(-50%);

.button {
width: 240rpx;
margin: 0 10rpx;
border-radius: 72rpx;
}

.payment-button {
background-color: #27ba9b;

&.disabled {
opacity: 0.6;
}
}
}
}
// 底部占位空盒子
.toolbar-height {
height: 100rpx;
box-sizing: content-box;
}
</style>

登录状态

已登录显示购物车列表,否则应引导用户去登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup lang="ts">
import { useMemberStore } from '@/stores'

// 获取会员Store
const memberStore = useMemberStore()
</script>

<template>
<scroll-view scroll-y class="scroll-view">
<!-- 已登录: 显示购物车 -->
<template v-if="memberStore.profile">
<!-- 购物车列表 -->
</template>
<!-- 未登录: 提示登录 -->
<view class="login-blank" v-else>
<text class="text">登录后可查看购物车中的商品</text>
<navigator url="/pages/login/login" hover-class="none">
<button class="button">去登录</button>
</navigator>
</view>
</scroll-view>
</template>

pages/login/login.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
// 登录成功后直接返回
const loginSuccess = (profile: LoginResult) => {
// 保存会员信息
const memberStore = useMemberStore()
memberStore.setProfile(profile)
// 成功提示
uni.showToast({ icon: 'success', title: '登录成功' })
setTimeout(() => {
// 页面跳转
// uni.switchTab({ url: '/pages/my/my' })
uni.navigateBack()
}, 500)
}

列表渲染

调用接口获取当前登录用户购物车中的商品列表。

src/services/cart.ts

1
2
3
4
5
6
7
8
9
/**
* 获取购物车列表
*/
export const getMemberCartAPI = () => {
return http<CartItem[]>({
method: 'GET',
url: '/member/cart',
})
}

类型声明

src/types/cart.d.ts

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
/** 购物车类型 */
export type CartItem = {
/** 商品 ID */
id: string
/** SKU ID */
skuId: string
/** 商品名称 */
name: string
/** 图片 */
picture: string
/** 数量 */
count: number
/** 加入时价格 */
price: number
/** 当前的价格 */
nowPrice: number
/** 库存 */
stock: number
/** 是否选中 */
selected: boolean
/** 属性文字 */
attrsText: string
/** 是否为有效商品 */
isEffective: boolean
}

参考代码

在页面初始化的时候判断用户是否已登录,已登录获取购物车列表。

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
<script setup lang="ts">
import { getMemberCartAPI } from '@/services/cart'
import { useMemberStore } from '@/stores'
import type { CartItem } from '@/types/cart'
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'

// 获取会员Store
const memberStore = useMemberStore()

// 获取购物车数据
const cartList = ref<CartItem[]>([])
const getMemberCartData = async () => {
const res = await getMemberCartAPI()
cartList.value = res.result
}

// 初始化调用: 页面显示触发
onShow(() => {
// 用户已登录才允许调用
if (memberStore.profile) {
getMemberCartData()
}
})
</script>
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
<template v-if="memberStore.profile">
<!-- 购物车列表 -->
<!-- #1 -->
<view class="cart-list" v-if="cartList.length">
<!-- 优惠提示 -->
<view class="tips">
<text class="label">满减</text>
<text class="desc">满1件, 即可享受9折优惠</text>
</view>
<!-- 滑动操作分区 -->
<uni-swipe-action>
<!-- 滑动操作项 -->
<!-- #2 -->
<uni-swipe-action-item
v-for="item in cartList"
:key="item.skuId"
class="cart-swipe"
>
<!-- 商品信息 -->
<view class="goods">
<!-- 选中状态 -->
<!-- #3 -->
<text class="checkbox" :class="{ checked: item.selected }"></text>
<!-- #4 -->
<navigator
:url="`/pages/goods/goods?id=${item.id}`"
hover-class="none"
class="navigator"
>
<!-- #5 -->
<image
mode="aspectFill"
class="picture"
:src="item.picture"
></image>
<view class="meta">
<!-- #6 -->
<view class="name ellipsis">{{ item.name }}</view>
<view class="attrsText ellipsis">{{ item.attrsText }}</view>
<view class="price">{{ item.nowPrice }}</view>
</view>
</navigator>
<!-- 商品数量 -->
<view class="count">
<text class="text">-</text>
<!-- #7 -->
<input
class="input"
type="number"
:value="item.count.toString()"
/>
<text class="text">+</text>
</view>
</view>
<!-- 右侧删除按钮 -->
<template #right>
<view class="cart-swipe-right">
<button class="button delete-button">删除</button>
</view>
</template>
</uni-swipe-action-item>
</uni-swipe-action>
</view>
<!-- 购物车空状态 -->
<view class="cart-blank" v-else>
<image src="/static/images/blank_cart.png" class="image" />
<text class="text">购物车还是空的,快来挑选好货吧</text>
<navigator url="/pages/index/index" hover-class="none">
<button class="button">去首页看看</button>
</navigator>
</view>
<!-- 吸底工具栏 -->
<view class="toolbar">
<text class="all" :class="{ checked: true }">全选</text>
<text class="text">合计:</text>
<text class="amount">100</text>
<view class="button-grounp">
<view class="button payment-button" :class="{ disabled: true }">
去结算(10)
</view>
</view>
</view>
</template>

小结一下

删除购物车

通过侧滑删除购物车的商品,使用 uni-swipe-action 组件实现。

接口封装

src/services/cart.ts

1
2
3
4
5
6
7
8
9
10
11
/**
* 删除/清空购物车单品
* @param data 请求体参数 ids SKUID 集合
*/
export const deleteMemberCartAPI = (data: { ids: string[] }) => {
return http({
method: 'DELETE',
url: '/member/cart',
data,
})
}

参考代码

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
<script setup lang="ts">
import { deleteMemberCartAPI } from '@/services/cart'
// 点击删除按钮
const onDeleteCart = (skuId: string) => {
// 弹窗二次确认
uni.showModal({
content: '是否删除',
success: async (res) => {
if (res.confirm) {
// 后端删除单品
await deleteMemberCartAPI({ ids: [skuId] })
// 重新获取列表
getMemberCartData()
}
},
})
}
</script>

<template>
<!-- 右侧删除按钮 -->
<template #right>
<view class="cart-swipe-right">
<button @tap="onDeleteCart(item.skuId)" class="button delete-button">删除</button>
</view>
</template>
</template>

小结一下

修改商品信息

修改购买数量,修改选中状态。

接口封装

src/services/cart.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 修改购物车单品
* @param skuId SKUID
* @param data selected 选中状态 count 商品数量
*/
export const putMemberCartBySkuIdAPI = (
skuId: string,
data: { selected?: boolean; count?: number },
) => {
return http({
method: 'PUT',
url: `/member/cart/${skuId}`,
data,
})
}

修改商品数量

复用 SKU 插件中的 步进器组件 修改商品数量,补充类型声明文件让组件类型更安全。

src/components/vk-data-input-number-box/vk-data-input-number-box.d.ts

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
import { Component } from '@uni-helper/uni-app-types'

/** 步进器 */
export type InputNumberBox = Component<InputNumberBoxProps>

/** 步进器实例 */
export type InputNumberBoxInstance = InstanceType<InputNumberBox>

/** 步进器属性 */
export type InputNumberBoxProps = {
/** 输入框初始值(默认1) */
modelValue: number
/** 用户可输入的最小值(默认0) */
min: number
/** 用户可输入的最大值(默认99999) */
max: number
/** 步长,每次加或减的值(默认1) */
step: number
/** 是否禁用操作,包括输入框,加减按钮 */
disabled: boolean
/** 输入框宽度,单位rpx(默认80) */
inputWidth: string | number
/** 输入框和按钮的高度,单位rpx(默认50) */
inputHeight: string | number
/** 输入框和按钮的背景颜色(默认#F2F3F5) */
bgColor: string
/** 步进器标识符 */
index: string
/** 输入框内容发生变化时触发 */
onChange: (event: InputNumberBoxEvent) => void
/** 输入框失去焦点时触发 */
onBlur: (event: InputNumberBoxEvent) => void
/** 点击增加按钮时触发 */
onPlus: (event: InputNumberBoxEvent) => void
/** 点击减少按钮时触发 */
onMinus: (event: InputNumberBoxEvent) => void
}

/** 步进器事件对象 */
export type InputNumberBoxEvent = {
/** 输入框当前值 */
value: number
/** 步进器标识符 */
index: string
}

/** 全局组件类型声明 */
declare module '@vue/runtime-core' {
export interface GlobalComponents {
'vk-data-input-number-box': InputNumberBox
}
}

参考代码

pages/cart/cart.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts">
import type { InputNumberBoxEvent } from '@/components/vk-data-input-number-box/vk-data-input-number-box'

// 修改商品数量
const onChangeCount = (ev: InputNumberBoxEvent) => {
putMemberCartBySkuIdAPI(ev.index, { count: ev.value })
}
</script>

<template>
<!-- 商品数量 -->
<view class="count">
<vk-data-input-number-box
v-model="item.count"
:min="1"
:max="item.stock"
:index="item.skuId"
@change="onChangeCount"
/>
</view>
</template>

小结一下

修改商品选中/全选

封装全选的函数,src/services/cart.ts

1
2
3
4
5
6
7
8
9
10
11
/**
* 购物车全选/取消全选
* @param data selected 是否选中
*/
export const putMemberCartSelectedAPI = (data: { selected: boolean }) => {
return http({
method: 'PUT',
url: '/member/cart/selected',
data,
})
}

参考代码

pages/cart/cart.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
<script setup lang="ts">
// 修改选中状态-单品修改
const onChangeSelected = (item: CartItem) => {
// 前端数据更新-是否选中取反
item.selected = !item.selected
// 后端数据更新
putMemberCartBySkuIdAPI(item.skuId, { selected: item.selected })
}

// 计算全选状态
const isSelectedAll = computed(() => {
return cartList.value.length && cartList.value.every((v) => v.selected)
})

// 修改选中状态-全选修改
const onChangeSelectedAll = () => {
// 全选状态取反
const _isSelectedAll = !isSelectedAll.value
// 前端数据更新
cartList.value.forEach((item) => {
item.selected = _isSelectedAll
})
// 后端数据更新
putMemberCartSelectedAPI({ selected: _isSelectedAll })
}
</script>

<template>
<!-- 商品信息 -->
<view class="goods">
<!-- 选中状态 -->
<text @tap="onChangeSelected(item)" class="checkbox" :class="{ checked: item.selected }">
</text>
</view>
<!-- 吸底工具栏 -->
<view class="toolbar">
<text @tap="onChangeSelectedAll" class="all" :class="{ checked: isSelectedAll }">全选</text>
</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
29
30
<script setup lang="ts">
// 计算选中单品列表
const selectedCartList = computed(() => {
return cartList.value.filter((v) => v.selected)
})

// 计算选中总件数
const selectedCartListCount = computed(() => {
return selectedCartList.value.reduce((sum, item) => sum + item.count, 0)
})

// 计算选中总金额
const selectedCartListMoney = computed(() => {
return selectedCartList.value
.reduce((sum, item) => sum + item.count * item.nowPrice, 0)
.toFixed(2)
})

// 结算按钮
const gotoPayment = () => {
if (selectedCartListCount.value === 0) {
return uni.showToast({
icon: 'none',
title: '请选择商品',
})
}
// 跳转到结算页
uni.navigateTo({ url: '/pagesOrder/create/create' })
}
</script>
1
2
3
4
5
6
7
8
9
10
11
<text class="text">合计:</text>
<text class="amount">{{ selectedCartListMoney }}</text>
<view class="button-grounp">
<view
@tap="gotoPayment"
class="button payment-button"
:class="{ disabled: selectedCartListCount === 0 }"
>
去结算({{ selectedCartListCount }})
</view>
</view>

小结一下

带返回按钮的购物车

为了解决小程序 tabBar 页面限制 导致无法返回上一页的问题,将购物车业务独立为组件,使其既可从底部 tabBar 访问,又可在商品详情页中跳转并返回

这样就需要 两个购物车页面 实现该功能,其中一个页面为 tabBar 页,另一个为普通页。

购物车页

目录结构如下。

1
2
3
4
5
pages/cart
├── components
│   └── CartMain.vue ...................................... 购物车业务组件
├── cart2.vue ............................................. 普通页
└── cart.vue ............................................ TabBar页

pages/cart/cart.vue

1
2
3
4
5
6
<script lang="ts" setup>
import CartMain from './components/CartMain.vue'
</script>
<template>
<CartMain />
</template>

pages.json 多添加一项配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"pages": [
// ...
{
"path": "pages/cart/cart",
"style": {
"navigationBarTitleText": "购物车"
}
},
{
"path": "pages/cart/cart2",
"style": {
"navigationBarTitleText": "购物车"
}
},
],
// ...
}

pages/goods/goods.vue

1
2
3
4
5
6
7
<navigator
class="icons-button"
url="/pages/cart/cart2"
open-type="navigate"
>
<text class="icon-cart"></text>购物车
</navigator>

把原本的购物车业务独立封装成组件,在两个购物车页面分别引入即可。

tabBar 页:小程序跳转到 tabBar 页面时,会关闭其他所有非 tabBar 页面,所以小程序的 tabBar 页没有后退按钮。

填写订单页

小兔鲜儿项目有三种方式可以生成订单信息,分别是:购物车结算、立即购买、再次购买。

填写订单

静态结构

分包,src/pagesOrder/create/create.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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
<script setup lang="ts">
import { computed, ref } from 'vue'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 订单备注
const buyerMessage = ref('')
// 配送时间
const deliveryList = ref([
{ type: 1, text: '时间不限 (周一至周日)' },
{ type: 2, text: '工作日送 (周一至周五)' },
{ type: 3, text: '周末配送 (周六至周日)' },
])
// 当前配送时间下标
const activeIndex = ref(0)
// 当前配送时间
const activeDelivery = computed(() => deliveryList.value[activeIndex.value])
// 修改配送时间
const onChangeDelivery: UniHelper.SelectorPickerOnChange = (ev) => {
activeIndex.value = ev.detail.value
}
</script>

<template>
<scroll-view scroll-y class="viewport">
<!-- 收货地址 -->
<navigator
v-if="false"
class="shipment"
hover-class="none"
url="/pagesMember/address/address?from=order"
>
<view class="user"> 张三 13333333333 </view>
<view class="address"> 广东省 广州市 天河区 黑马程序员3 </view>
<text class="icon icon-right"></text>
</navigator>
<navigator
v-else
class="shipment"
hover-class="none"
url="/pagesMember/address/address?from=order"
>
<view class="address"> 请选择收货地址 </view>
<text class="icon icon-right"></text>
</navigator>

<!-- 商品信息 -->
<view class="goods">
<navigator
v-for="item in 2"
:key="item"
:url="`/pages/goods/goods?id=1`"
class="item"
hover-class="none"
>
<image
class="picture"
src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg"
/>
<view class="meta">
<view class="name ellipsis"> ins风小碎花泡泡袖衬110-160cm </view>
<view class="attrs">藏青小花 130</view>
<view class="prices">
<view class="pay-price symbol">99.00</view>
<view class="price symbol">99.00</view>
</view>
<view class="count">x5</view>
</view>
</navigator>
</view>

<!-- 配送及支付方式 -->
<view class="related">
<view class="item">
<text class="text">配送时间</text>
<picker :range="deliveryList" range-key="text" @change="onChangeDelivery">
<view class="icon-fonts picker">{{ activeDelivery.text }}</view>
</picker>
</view>
<view class="item">
<text class="text">订单备注</text>
<input
class="input"
:cursor-spacing="30"
placeholder="选题,建议留言前先与商家沟通确认"
v-model="buyerMessage"
/>
</view>
</view>

<!-- 支付金额 -->
<view class="settlement">
<view class="item">
<text class="text">商品总价: </text>
<text class="number symbol">495.00</text>
</view>
<view class="item">
<text class="text">运费: </text>
<text class="number symbol">5.00</text>
</view>
</view>
</scroll-view>

<!-- 吸底工具栏 -->
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
<view class="total-pay symbol">
<text class="number">99.00</text>
</view>
<view class="button" :class="{ disabled: true }"> 提交订单 </view>
</view>
</template>

<style lang="scss">
page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background-color: #f4f4f4;
}

.symbol::before {
content: '¥';
font-size: 80%;
margin-right: 5rpx;
}

.shipment {
margin: 20rpx;
padding: 30rpx 30rpx 30rpx 84rpx;
font-size: 26rpx;
border-radius: 10rpx;
background: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png)
20rpx center / 50rpx no-repeat #fff;
position: relative;

.icon {
font-size: 36rpx;
color: #333;
transform: translateY(-50%);
position: absolute;
top: 50%;
right: 20rpx;
}

.user {
color: #333;
margin-bottom: 5rpx;
}

.address {
color: #666;
}
}

.goods {
margin: 20rpx;
padding: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;

.item {
display: flex;
padding: 30rpx 0;
border-top: 1rpx solid #eee;

&:first-child {
border-top: none;
}

.picture {
width: 170rpx;
height: 170rpx;
border-radius: 10rpx;
margin-right: 20rpx;
}

.meta {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}

.name {
height: 80rpx;
font-size: 26rpx;
color: #444;
}

.attrs {
line-height: 1.8;
padding: 0 15rpx;
margin-top: 6rpx;
font-size: 24rpx;
align-self: flex-start;
border-radius: 4rpx;
color: #888;
background-color: #f7f7f8;
}

.prices {
display: flex;
align-items: baseline;
margin-top: 6rpx;
font-size: 28rpx;

.pay-price {
margin-right: 10rpx;
color: #cf4444;
}

.price {
font-size: 24rpx;
color: #999;
text-decoration: line-through;
}
}

.count {
position: absolute;
bottom: 0;
right: 0;
font-size: 26rpx;
color: #444;
}
}
}

.related {
margin: 20rpx;
padding: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;

.item {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 80rpx;
font-size: 26rpx;
color: #333;
}

.input {
flex: 1;
text-align: right;
margin: 20rpx 0;
padding-right: 20rpx;
font-size: 26rpx;
color: #999;
}

.item .text {
width: 125rpx;
}

.picker {
color: #666;
}

.picker::after {
content: '\e6c2';
}
}

/* 结算清单 */
.settlement {
margin: 20rpx;
padding: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;

.item {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
font-size: 26rpx;
color: #333;
}

.danger {
color: #cf4444;
}
}

/* 吸底工具栏 */
.toolbar {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--window-bottom));
z-index: 1;

background-color: #fff;
height: 100rpx;
padding: 0 20rpx;
border-top: 1rpx solid #eaeaea;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: content-box;

.total-pay {
font-size: 40rpx;
color: #cf4444;

.decimal {
font-size: 75%;
}
}

.button {
width: 220rpx;
text-align: center;
line-height: 72rpx;
font-size: 26rpx;
color: #fff;
border-radius: 72rpx;
background-color: #27ba9b;
}

.disabled {
opacity: 0.6;
}
}
</style>

渲染基本信息

在购物车点击去结算后,进入填写订单页,用户可以选择订单的收货地址或补充订单信息。

src/services/order.ts

1
2
3
4
5
6
7
8
9
10
11
import type { OrderPreResult } from '@/types/order'
import { http } from '@/utils/http'
/**
* 填写订单-获取预付订单
*/
export const getMemberOrderPreAPI = () => {
return http<OrderPreResult>({
method: 'GET',
url: '/member/order/pre',
})
}

类型声明

src/types/order.d.ts

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
import type { AddressItem } from './address'

/** 获取预付订单 返回信息 */
export type OrderPreResult = {
/** 商品集合 [ 商品信息 ] */
goods: OrderPreGoods[]
/** 结算信息 */
summary: {
/** 商品总价 */
totalPrice: number
/** 邮费 */
postFee: number
/** 应付金额 */
totalPayPrice: number
}
/** 用户地址列表 [ 地址信息 ] */
userAddresses: AddressItem[]
}

/** 商品信息 */
export type OrderPreGoods = {
/** 属性文字,例如“颜色:瓷白色 尺寸:8寸” */
attrsText: string
/** 数量 */
count: number
/** id */
id: string
/** 商品名称 */
name: string
/** 实付单价 */
payPrice: string
/** 图片 */
picture: string
/** 原单价 */
price: string
/** SKUID */
skuId: string
/** 实付价格小计 */
totalPayPrice: string
/** 小计总价 */
totalPrice: string
}

将后端返回的预付订单数据,渲染到页面中。

页面中调用,src/pagesOrder/create/create.vue

1
2
3
4
5
6
7
8
9
10
11
12
import { getMemberOrderPreAPI } from '@/services/order'
import type { OrderPreResult } from '@/types/order'
import { onLoad } from '@dcloudio/uni-app'

const orderPre = ref<OrderPreResult>()
const getMemberOrderPreData = async () => {
const res = await getMemberOrderPreAPI()
orderPre.value = res.result
}
onLoad(() => {
getMemberOrderPreData()
})

渲染内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<view class="goods">
<navigator
v-for="item in orderPre?.goods"
:key="item.skuId"
:url="`/pages/goods/goods?id=${item.id}`"
class="item"
hover-class="none"
>
<image class="picture" :src="item.picture" />
<view class="meta">
<view class="name ellipsis"> {{ item.name }} </view>
<view class="attrs">{{ item.attrsText }}</view>
<view class="prices">
<view class="pay-price symbol">{{ item.payPrice }}</view>
<view class="price symbol">{{ item.price }}</view>
</view>
<view class="count">x{{ item.count }}</view>
</view>
</navigator>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<view class="settlement">
<view class="item">
<text class="text">商品总价: </text>
<text class="number symbol">{{
orderPre?.summary.totalPayPrice.toFixed(2)
}}</text>
</view>
<view class="item">
<text class="text">运费: </text>
<text class="number symbol">{{
orderPre?.summary.postFee.toFixed(2)
}}</text>
</view>
</view>
1
2
3
4
5
6
7
8
9
10
11
<view
class="toolbar"
:style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
>
<view class="total-pay symbol">
<text class="number">{{
orderPre?.summary.totalPayPrice.toFixed(2)
}}</text>
</view>
<view class="button" :class="{ disabled: true }"> 提交订单 </view>
</view>

小结一下

渲染收获地址

  1. 渲染默认收获地址,src/pagesOrder/create/create.vue
1
2
3
const selecteAddress = computed(() => {
return orderPre.value?.userAddresses.find((v) => v.isDefault)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<navigator
v-if="selecteAddress"
class="shipment"
hover-class="none"
url="/pagesMember/address/address?from=order"
>
<view class="user">
{{ selecteAddress?.receiver }} {{ selecteAddress?.contact }}
</view>
<view class="address">
{{ selecteAddress?.fullLocation }} {{ selecteAddress?.address }}
</view>
<text class="icon icon-right"></text>
</navigator>
  1. 为了更好管理选中收货地址,创建独立 Store 维护,stores/modules/address.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import type { AddressItem } from '@/types/address'
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useAddressStore = defineStore('address', () => {
const selectedAddress = ref<AddressItem>()

const changeSelectedAddress = (val: AddressItem) => {
selectedAddress.value = val
}

return { selectedAddress, changeSelectedAddress }
})

src/pagesOrder/create/create.vue

1
2
3
4
5
6
7
8
const addressStore = useAddressStore()

const selecteAddress = computed(() => {
return (
addressStore.selectedAddress ||
orderPre.value?.userAddresses.find((v) => v.isDefault)
)
})

地址管理页

修改选中收货地址,<navigator> 组件需要阻止事件冒泡,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
<script setup lang="ts">
// 修改收货地址
const onChangeAddress = (item: AddressItem) => {
// 修改选中收货地址
const addressStore = useAddressStore()
addressStore.changeSelectedAddress(item)
// 返回上一页
uni.navigateBack()
}
</script>

<template>
<!-- 收货地址项 -->
<uni-swipe-action-item class="item" v-for="item in addressList" :key="item.id">
<view class="item-content" @tap="onChangeAddress(item)">
<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}`"
@tap.stop="() => {}"
>
修改
</navigator>
</view>
</uni-swipe-action-item>
</template>

小结一下

立即购买

从商品详情页的 SKU 组件直接点击【立即购买按钮】跳转到填写订单页,需要传递页面参数。

封装接口

src/services/order.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 填写订单-获取立即购买订单
*/
export const getMemberOrderPreNowAPI = (data: {
skuId: string
count: string
addressId?: string
}) => {
return http<OrderPreResult>({
method: 'GET',
url: '/member/order/pre/now',
data,
})
}

页面传参

从商品详情页的【立即购买事件】中收集两个必要参数,跳转填写订单页并传递页面参数

pages/goods/goods.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
<script setup lang="ts">
// 立即购买
const onBuyNow = (ev: SkuPopupEvent) => {
uni.navigateTo({ url: `/pagesOrder/create/create?skuId=${ev._id}&count=${ev.buy_num}` })
}
</script>

<template>
<!-- SKU 弹窗组件 -->
<vk-data-goods-sku-popup
v-model="isShowSku"
:localdata="localdata"
:mode="mode"
add-cart-background-color="#FFA868"
buy-now-background-color="#27BA9B"
ref="skuPopupRef"
:actived-style="{
color: '#27BA9B',
borderColor: '#27BA9B',
backgroundColor: '#E9F8F5'
}"
@add-cart="onAddCart"
@buy-now="onBuyNow"
/>
</template>

填写订单

pagesOrder/create/create.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
<script setup lang="ts">
import { getMemberOrderPreAPI, getMemberOrderPreNowAPI } from '@/services/order'

// #0 接收参数
const query = defineProps<{
skuId?: string
count?: string
}>()

const orderPre = ref<OrderPreResult>()

const getMemberOrderPreData = async () => {
// #2
if (query.count && query.skuId) {
// 调用立即购买 API
const res = await getMemberOrderPreNowAPI({
count: query.count,
skuId: query.skuId
})
orderPre.value = res.result
} else {
// 调用预支付 API
const res = await getMemberOrderPreAPI()
orderPre.value = res.result
}
}
</script>

小结一下

提交订单

收集填写订单页的数据,点击页面底部的提交订单按钮,创建一个新的订单。

类型声明

src/types/order.d.ts

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
/** 提交订单 请求参数 */
export type OrderCreateParams = {
/** 所选地址Id */
addressId: string
/** 配送时间类型,1为不限,2为工作日,3为双休或假日 */
deliveryTimeType: number
/** 订单备注 */
buyerMessage: string
/** 商品集合[ 商品信息 ] */
goods: {
/** 数量 */
count: number
/** skuId */
skuId: string
}[]
/** 支付渠道:支付渠道,1支付宝、2微信--支付方式为在线支付时,传值,为货到付款时,不传值 */
payChannel: 1 | 2
/** 支付方式,1为在线支付,2为货到付款 */
payType: 1 | 2
}

/** 提交订单 返回信息 */
export type OrderCreateResult = {
/** 订单Id */
id: string
}

接口封装

src/services/order.ts

1
2
3
4
5
6
7
8
9
10
11
/**
* 提交订单
* @param data 请求参数
*/
export const postMemberOrderAPI = (data: OrderCreateParams) => {
return http<{ id: string }>({
method: 'POST',
url: '/member/order',
data,
})
}

参考代码

点击提交订单按钮实现创建订单,订单创建成功后,跳转到订单详情并传递订单 id。

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
<script setup lang="ts">
// ...
// 提交订单
const onOrderSubmit = async () => {
if (!selecteAddress.value?.id) {
return uni.showToast({ icon: 'none', title: '请选择收货地址' })
}
const res = await postMemberOrderAPI({
addressId: selecteAddress.value.id, // 地址 ID
buyerMessage: buyerMessage.value, // 买家留言
deliveryTimeType: activeDelivery.value.type, // 配送时间类型,1 为不限,2 为工作日,3 为双休或假日
goods: orderPre.value!.goods.map((v) => ({
// 商品集合
count: v.count,
skuId: v.skuId
})),
payChannel: 2, // 支付渠道,1 表示支付宝,2 表示微信
payType: 1 // 支付方式,1 为在线支付,2 为货到付款
})
// 关闭当前页面,跳转到订单详情,传递订单id
uni.redirectTo({ url: `/pagesOrder/detail/detail?id=${res.result.id}` })
}
</script>

<template>
<view class="button" :class="{ disabled: !selecteAddress?.id }" @tap="onOrderSubmit">
提交订单
</view>
</template>

小结一下

订单详情页

需要展示多种订单状态 并实现不同订单状态对应的业务。

静态结构

已完成通过页面参数获取到订单 id 等基础业务,pagesOrder/detail/detail.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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
<script setup lang="ts">
import { useGuessList } from '@/composables'
import { ref } from 'vue'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 猜你喜欢
const { guessRef, onScrolltolower } = useGuessList()
// 弹出层组件
const popup = ref<UniHelper.UniPopupInstance>()
// 取消原因列表
const reasonList = ref([
'商品无货',
'不想要了',
'商品信息填错了',
'地址信息填写错误',
'商品降价',
'其它',
])
// 订单取消原因
const reason = ref('')
// 复制内容
const onCopy = (id: string) => {
// 设置系统剪贴板的内容
uni.setClipboardData({ data: id })
}
// 获取页面参数
const query = defineProps<{
id: string
}>()
</script>

<template>
<!-- 自定义导航栏: 默认透明不可见, scroll-view 滚动到 50 时展示 -->
<view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<view class="wrap">
<navigator v-if="true" open-type="navigateBack" class="back icon-left"></navigator>
<navigator v-else url="/pages/index/index" open-type="switchTab" class="back icon-home">
</navigator>
<view class="title">订单详情</view>
</view>
</view>
<scroll-view scroll-y class="viewport" id="scroller" @scrolltolower="onScrolltolower">
<template v-if="true">
<!-- 订单状态 -->
<view class="overview" :style="{ paddingTop: safeAreaInsets!.top + 20 + 'px' }">
<!-- 待付款状态:展示去支付按钮和倒计时 -->
<template v-if="true">
<view class="status icon-clock">等待付款</view>
<view class="tips">
<text class="money">应付金额: ¥ 99.00</text>
<text class="time">支付剩余</text>
00 时 29 分 59 秒
</view>
<view class="button">去支付</view>
</template>
<!-- 其他订单状态:展示再次购买按钮 -->
<template v-else>
<!-- 订单状态文字 -->
<view class="status"> 待付款 </view>
<view class="button-group">
<navigator
class="button"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待发货状态:模拟发货,开发期间使用,用于修改订单状态为已发货 -->
<view v-if="false" class="button"> 模拟发货 </view>
</view>
</template>
</view>
<!-- 配送状态 -->
<view class="shipment">
<!-- 订单物流信息 -->
<view v-for="item in 1" :key="item" class="item">
<view class="message">
您已在广州市天河区黑马程序员完成取件,感谢使用菜鸟驿站,期待再次为您服务。
</view>
<view class="date"> 2023-04-14 13:14:20 </view>
</view>
<!-- 用户收货地址 -->
<view class="locate">
<view class="user"> 张三 13333333333 </view>
<view class="address"> 广东省 广州市 天河区 黑马程序员 </view>
</view>
</view>

<!-- 商品信息 -->
<view class="goods">
<view class="item">
<navigator
class="navigator"
v-for="item in 2"
:key="item"
:url="`/pages/goods/goods?id=${item}`"
hover-class="none"
>
<image
class="cover"
src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg"
></image>
<view class="meta">
<view class="name ellipsis">ins风小碎花泡泡袖衬110-160cm</view>
<view class="type">藏青小花, 130</view>
<view class="price">
<view class="actual">
<text class="symbol">¥</text>
<text>99.00</text>
</view>
</view>
<view class="quantity">x1</view>
</view>
</navigator>
<!-- 待评价状态:展示按钮 -->
<view class="action" v-if="true">
<view class="button primary">申请售后</view>
<navigator url="" class="button"> 去评价 </navigator>
</view>
</view>
<!-- 合计 -->
<view class="total">
<view class="row">
<view class="text">商品总价: </view>
<view class="symbol">99.00</view>
</view>
<view class="row">
<view class="text">运费: </view>
<view class="symbol">10.00</view>
</view>
<view class="row">
<view class="text">应付金额: </view>
<view class="symbol primary">109.00</view>
</view>
</view>
</view>

<!-- 订单信息 -->
<view class="detail">
<view class="title">订单信息</view>
<view class="row">
<view class="item">
订单编号: {{ query.id }} <text class="copy" @tap="onCopy(query.id)">复制</text>
</view>
<view class="item">下单时间: 2023-04-14 13:14:20</view>
</view>
</view>

<!-- 猜你喜欢 -->
<XtxGuess ref="guessRef" />

<!-- 底部操作栏 -->
<view class="toolbar-height" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"></view>
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
<!-- 待付款状态:展示支付按钮 -->
<template v-if="true">
<view class="button primary"> 去支付 </view>
<view class="button" @tap="popup?.open?.()"> 取消订单 </view>
</template>
<!-- 其他订单状态:按需展示按钮 -->
<template v-else>
<navigator
class="button secondary"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待收货状态: 展示确认收货 -->
<view class="button primary"> 确认收货 </view>
<!-- 待评价状态: 展示去评价 -->
<view class="button"> 去评价 </view>
<!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
<view class="button delete"> 删除订单 </view>
</template>
</view>
</template>
<template v-else>
<!-- 骨架屏组件 -->
<PageSkeleton />
</template>
</scroll-view>
<!-- 取消订单弹窗 -->
<uni-popup ref="popup" type="bottom" background-color="#fff">
<view class="popup-root">
<view class="title">订单取消</view>
<view class="description">
<view class="tips">请选择取消订单的原因:</view>
<view class="cell" v-for="item in reasonList" :key="item" @tap="reason = item">
<text class="text">{{ item }}</text>
<text class="icon" :class="{ checked: item === reason }"></text>
</view>
</view>
<view class="footer">
<view class="button" @tap="popup?.close?.()">取消</view>
<view class="button primary">确认</view>
</view>
</view>
</uni-popup>
</template>

<style lang="scss">
page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}

.navbar {
width: 750rpx;
color: #000;
position: fixed;
top: 0;
left: 0;
z-index: 9;
/* background-color: #f8f8f8; */
background-color: transparent;

.wrap {
position: relative;

.title {
height: 44px;
display: flex;
justify-content: center;
align-items: center;
font-size: 32rpx;
/* color: #000; */
color: transparent;
}

.back {
position: absolute;
left: 0;
height: 44px;
width: 44px;
font-size: 44rpx;
display: flex;
align-items: center;
justify-content: center;
/* color: #000; */
color: #fff;
}
}
}

.viewport {
background-color: #f7f7f8;
}

.overview {
display: flex;
flex-direction: column;
align-items: center;

line-height: 1;
padding-bottom: 30rpx;
color: #fff;
background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/order_bg.png);
background-size: cover;

.status {
font-size: 36rpx;
}

.status::before {
margin-right: 6rpx;
font-weight: 500;
}

.tips {
margin: 30rpx 0;
display: flex;
font-size: 14px;
align-items: center;

.money {
margin-right: 30rpx;
}
}

.button-group {
margin-top: 30rpx;
display: flex;
justify-content: center;
align-items: center;
}

.button {
width: 260rpx;
height: 64rpx;
margin: 0 10rpx;
text-align: center;
line-height: 64rpx;
font-size: 28rpx;
color: #27ba9b;
border-radius: 68rpx;
background-color: #fff;
}
}

.shipment {
line-height: 1.4;
padding: 0 20rpx;
margin: 20rpx 20rpx 0;
border-radius: 10rpx;
background-color: #fff;

.locate,
.item {
min-height: 120rpx;
padding: 30rpx 30rpx 25rpx 75rpx;
background-size: 50rpx;
background-repeat: no-repeat;
background-position: 6rpx center;
}

.locate {
background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png);

.user {
font-size: 26rpx;
color: #444;
}

.address {
font-size: 24rpx;
color: #666;
}
}

.item {
background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/car.png);
border-bottom: 1rpx solid #eee;
position: relative;

.message {
font-size: 26rpx;
color: #444;
}

.date {
font-size: 24rpx;
color: #666;
}
}
}

.goods {
margin: 20rpx 20rpx 0;
padding: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;

.item {
padding: 30rpx 0;
border-bottom: 1rpx solid #eee;

.navigator {
display: flex;
margin: 20rpx 0;
}

.cover {
width: 170rpx;
height: 170rpx;
border-radius: 10rpx;
margin-right: 20rpx;
}

.meta {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}

.name {
height: 80rpx;
font-size: 26rpx;
color: #444;
}

.type {
line-height: 1.8;
padding: 0 15rpx;
margin-top: 6rpx;
font-size: 24rpx;
align-self: flex-start;
border-radius: 4rpx;
color: #888;
background-color: #f7f7f8;
}

.price {
display: flex;
margin-top: 6rpx;
font-size: 24rpx;
}

.symbol {
font-size: 20rpx;
}

.original {
color: #999;
text-decoration: line-through;
}

.actual {
margin-left: 10rpx;
color: #444;
}

.text {
font-size: 22rpx;
}

.quantity {
position: absolute;
bottom: 0;
right: 0;
font-size: 24rpx;
color: #444;
}

.action {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
padding: 30rpx 0 0;

.button {
width: 200rpx;
height: 60rpx;
text-align: center;
justify-content: center;
line-height: 60rpx;
margin-left: 20rpx;
border-radius: 60rpx;
border: 1rpx solid #ccc;
font-size: 26rpx;
color: #444;
}

.primary {
color: #27ba9b;
border-color: #27ba9b;
}
}
}

.total {
line-height: 1;
font-size: 26rpx;
padding: 20rpx 0;
color: #666;

.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10rpx 0;
}

.symbol::before {
content: '¥';
font-size: 80%;
margin-right: 3rpx;
}

.primary {
color: #cf4444;
font-size: 36rpx;
}
}
}

.detail {
line-height: 1;
padding: 30rpx 20rpx 0;
margin: 20rpx 20rpx 0;
font-size: 26rpx;
color: #666;
border-radius: 10rpx;
background-color: #fff;

.title {
font-size: 30rpx;
color: #444;
}

.row {
padding: 20rpx 0;

.item {
padding: 10rpx 0;
display: flex;
align-items: center;
}

.copy {
border-radius: 20rpx;
font-size: 20rpx;
border: 1px solid #ccc;
padding: 5rpx 10rpx;
margin-left: 10rpx;
}
}
}

.toolbar-height {
height: 100rpx;
box-sizing: content-box;
}

.toolbar {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--window-bottom));
z-index: 1;

height: 100rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
flex-direction: row-reverse;
border-top: 1rpx solid #ededed;
border-bottom: 1rpx solid #ededed;
background-color: #fff;
box-sizing: content-box;

.button {
display: flex;
justify-content: center;
align-items: center;

width: 200rpx;
height: 72rpx;
margin-left: 15rpx;
font-size: 26rpx;
border-radius: 72rpx;
border: 1rpx solid #ccc;
color: #444;
}

.delete {
order: 4;
}

.button {
order: 3;
}

.secondary {
order: 2;
color: #27ba9b;
border-color: #27ba9b;
}

.primary {
order: 1;
color: #fff;
background-color: #27ba9b;
}
}

.popup-root {
padding: 30rpx 30rpx 0;
border-radius: 10rpx 10rpx 0 0;
overflow: hidden;

.title {
font-size: 30rpx;
text-align: center;
margin-bottom: 30rpx;
}

.description {
font-size: 28rpx;
padding: 0 20rpx;

.tips {
color: #444;
margin-bottom: 12rpx;
}

.cell {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15rpx 0;
color: #666;
}

.icon::before {
content: '\e6cd';
font-family: 'erabbit' !important;
font-size: 38rpx;
color: #999;
}

.icon.checked::before {
content: '\e6cc';
font-size: 38rpx;
color: #27ba9b;
}
}

.footer {
display: flex;
justify-content: space-between;
padding: 30rpx 0 40rpx;
font-size: 28rpx;
color: #444;

.button {
flex: 1;
height: 72rpx;
text-align: center;
line-height: 72rpx;
margin: 0 20rpx;
color: #444;
border-radius: 72rpx;
border: 1rpx solid #ccc;
}

.primary {
color: #fff;
background-color: #27ba9b;
border: none;
}
}
}
</style>

自定义导航栏交互

  1. 导航栏左上角按钮:获取当前页面栈,如果不能返回上一页,按钮变成返回首页。

  2. 导航栏动画效果:滚动驱动的动画,根据滚动位置而不断改变动画的进度。

滚动驱动的动画目前只支持微信小程序端,暂不支持网页端。

导航栏交互

pages.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
// ...
"subPackages": [
// ...
{
"root": "pagesOrder",
"pages": [
// ...
{
"path": "detail/detail",
"style": {
"navigationBarTitleText": "订单详情",
"navigationStyle": "custom"
}
}
]
}
],
// ...
}

参考代码,pagesOrder/detail/detail.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
<script setup lang="ts">
// 获取页面栈
const pages = getCurrentPages()
// 获取当前页面实例,数组最后一项
const pageInstance = pages.at(-1) as any

// 页面渲染完毕,绑定动画效果
onReady(() => {
// 动画效果,导航栏背景色
pageInstance.animate(
'.navbar', // 选择器
[{ backgroundColor: 'transparent' }, { backgroundColor: '#f8f8f8' }], // 关键帧信息
1000, // 动画持续时长
{
scrollSource: '#scroller', // scroll-view 的选择器
startScrollOffset: 0, // 开始滚动偏移量
endScrollOffset: 50, // 停止滚动偏移量
timeRange: 1000, // 时间长度
},
)
// 动画效果,导航栏标题
pageInstance.animate('.navbar .title', [{ color: 'transparent' }, { color: '#000' }], 1000, {
scrollSource: '#scroller',
timeRange: 1000,
startScrollOffset: 0,
endScrollOffset: 50,
})
// 动画效果,导航栏返回按钮
pageInstance.animate('.navbar .back', [{ color: '#fff' }, { color: '#000' }], 1000, {
scrollSource: '#scroller',
timeRange: 1000,
startScrollOffset: 0,
endScrollOffset: 50,
})
})
</script>

<template>
<!-- 自定义导航栏: 默认透明不可见, scroll-view 滚动到 50 时展示 -->
<view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<view class="wrap">
<navigator
v-if="pages.length > 1"
open-type="navigateBack"
class="back icon-left"
></navigator>
<navigator v-else url="/pages/index/index" open-type="switchTab" class="back icon-home">
</navigator>
<view class="title">订单详情</view>
</view>
</view>
<scroll-view class="viewport" scroll-y enable-back-to-top id="scroller">
...滚动容器
</scroll-view>
</template>

小结一下

获取订单详情

  1. 声明后端返回数据的类型,src/types/order.d.ts
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
/** 订单详情 返回信息 */
export type OrderResult = {
/** 订单编号 */
id: string
/** 订单状态,1为待付款、2为待发货、3为待收货、4为待评价、5为已完成、6为已取消 */
orderState: number
/** 倒计时--剩余的秒数 -1 表示已经超时,正数表示倒计时未结束 */
countdown: number
/** 商品集合 [ 商品信息 ] */
skus: OrderSkuItem[]
/** 收货人 */
receiverContact: string
/** 收货人手机 */
receiverMobile: string
/** 收货人完整地址 */
receiverAddress: string
/** 下单时间 */
createTime: string
/** 商品总价 */
totalMoney: number
/** 运费 */
postFee: number
/** 应付金额 */
payMoney: number
}

/** 商品信息 */
export type OrderSkuItem = {
/** sku id */
id: string
/** 商品 id */
spuId: string
/** 商品名称 */
name: string
/** 商品属性文字 */
attrsText: string
/** 数量 */
quantity: number
/** 购买时单价 */
curPrice: number
/** 图片地址 */
image: string
}
  1. 接口封装,services/order.ts
1
2
3
4
5
6
7
8
9
10
11
import type { OrderResult } from '@/types/order'
/**
* 获取订单详情
* @param id 订单id
*/
export const getMemberOrderByIdAPI = (id: string) => {
return http<OrderResult>({
method: 'GET',
url: `/member/order/${id}`,
})
}
  1. 页面中调用,pagesOrder/detail/detai.vue
1
2
3
4
5
6
7
8
9
10
import { onLoad } from '@dcloudio/uni-app'
import type { OrderResult } from '@/types/order'
const order = ref<OrderResult>()
const getMemberOrderByIdData = async () => {
const res = await getMemberOrderByIdAPI(query.id)
order.value = res.result
}
onLoad(() => {
getMemberOrderByIdData()
})

渲染订单状态

基本内容

在订单详情中除了展示订单信息外,还需要根据不同订单状态展示不同的内容。

订单状态(orderState) 含义
1 待付款
2 待发货
3 待收货
4 待评价
5 已完成
6 已取消

枚举的作用:通过枚举来替代无意义的订单状态数字,提高程序的可读性。

  1. 定义枚举,src/services/constants.ts
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
/** 订单状态枚举 */
export enum OrderState {
/** 待付款 */
DaiFuKuan = 1,
/** 待发货 */
DaiFaHuo = 2,
/** 待收货 */
DaiShouHuo = 3,
/** 待评价 */
DaiPingJia = 4,
/** 已完成 */
YiWanCheng = 5,
/** 已取消 */
YiQuXiao = 6,
}

/** 订单状态列表 */
export const orderStateList = [
{ id: 0, text: '' },
{ id: 1, text: '待付款' },
{ id: 2, text: '待发货' },
{ id: 3, text: '待收货' },
{ id: 4, text: '待评价' },
{ id: 5, text: '已完成' },
{ id: 6, text: '已取消' },
]
  1. 根据后端返回的数据渲染订单详情,pagesOrder/detail/detail.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
<!-- #1 -->
<template v-if="order">
<!-- 订单状态 -->
<view
class="overview"
:style="{ paddingTop: safeAreaInsets!.top + 20 + 'px' }"
>
<!-- 待付款状态:展示去支付按钮和倒计时 -->
<!-- #2 -->
<template v-if="order.orderState === OrderState.DaiFuKuan">
<view class="status icon-clock">等待付款</view>
<view class="tips">
<text class="money">应付金额: ¥ 99.00</text>
<text class="time">支付剩余</text>
00 时 29 分 59 秒
</view>
<view class="button">去支付</view>
</template>
<!-- 其他订单状态:展示再次购买按钮 -->
<template v-else>
<!-- 订单状态文字 -->
<!-- #3 -->
<view class="status">
{{ orderStateList[order.orderState].text }}
</view>
<view class="button-group">
<navigator
class="button"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待发货状态:模拟发货,开发期间使用,用于修改订单状态为已发货 -->
<view v-if="false" class="button"> 模拟发货 </view>
</view>
</template>
</view>
<!-- 配送状态 -->
<!-- ... -->

<!-- 商品信息 -->
<!-- ... -->

<!-- 订单信息 -->
<!-- ... -->

<!-- 猜你喜欢 -->
<!-- ... -->

<!-- 底部操作栏 -->
<!-- ... -->
</template>

小结一下

支付倒计时

参考代码

通过 uni-ui 组件库的 uni-countdown 实现倒计时。

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 onTimeup = () => {
// 修改订单状态为已取消
order.value!.orderState = OrderState.YiQuXiao
}
</script>

<template>
<!-- 待付款状态:展示去支付按钮和倒计时 -->
<template v-if="order.orderState === OrderState.DaiFuKuan">
<view class="status icon-clock">等待付款</view>
<view class="tips">
<text class="money">应付金额: ¥ 99.00</text>
<text class="time">支付剩余</text>
<!-- 倒计时组件 -->
<uni-countdown
:second="order.countdown"
color="#fff"
splitor-color="#fff"
:show-day="false"
:show-colon="false"
@timeup="onTimeup"
/>
</view>
<view class="button">去支付</view>
</template>
</template>

小结一下

订单支付

订单支付其实就是根据订单号查询到支付信息,在小程序中调用微信支付的 API 而已。

微信支付说明

  1. 由于微信支付的限制,仅 appidwx26729f20b9efae3a 的开发者才能调用该接口。此外,开发者还需要微信授权登录。

  2. 对于其他开发者,可以使用模拟支付接口进行开发测试,调用后,订单状态将自动更新为已支付。

调用接口

  • 生产环境:调用正式接口,获取微信支付参数 + 发起微信支付。

  • 开发环境:调用模拟接口,通过模拟支付,修改订单状态为已支付。

services/pay.ts

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
import { http } from '@/utils/http'

/**
* 获取微信支付参数
* @param data orderId 订单id
*/
export const getPayWxPayMiniPayAPI = (data: { orderId: string }) => {
return http<WechatMiniprogram.RequestPaymentOption>({
method: 'GET',
url: '/pay/wxPay/miniPay',
data,
})
}

/**
* 模拟支付-内测版
* @param data orderId 订单id
*/
export const getPayMockAPI = (data: { orderId: string }) => {
return http({
method: 'GET',
url: '/pay/mock',
data,
})
}

参考代码

通过环境变量区分开发环境,调用不同接口,pagesOrder/detail/detail.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup lang="ts">
import { getPayMockAPI, getPayWxPayMiniPayAPI } from '@/services/pay'

// 订单支付
const onOrderPay = async () => {
// 通过环境变量区分开发环境
if (import.meta.env.DEV) {
// 开发环境:模拟支付,修改订单状态为已支付
await getPayMockAPI({ orderId: query.id })
} else {
// 生产环境:获取支付参数 + 发起微信支付
const res = await getPayWxPayMiniPayAPI({ orderId: query.id })
await wx.requestPayment(res.result)
}
// 关闭当前页,再跳转支付结果页
uni.redirectTo({ url: `/pagesOrder/payment/payment?id=${query.id}` })
}
</script>

<template>
<view class="button" @tap="onOrderPay">去支付</view>
</template>

注意真实支付,需要修改 pages/login/login.vue 中的登录方式。

1
2
3
4
5
6
7
8
<button
class="button phone"
open-type="getPhoneNumber"
@getphonenumber="onGetphonenumber"
>
<text class="icon icon-phone"></text>
手机号快捷登录
</button>

支付成功页

主要用于展示支付结果,src/pagesOrder/payment/payment.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">
import { useGuessList } from '@/composables'

// 获取页面参数
const query = defineProps<{
id: string
}>()

// 猜你喜欢
const { guessRef, onScrolltolower } = useGuessList()
</script>

<template>
<scroll-view class="viewport" scroll-y @scrolltolower="onScrolltolower">
<!-- 订单状态 -->
<view class="overview">
<view class="status icon-checked">支付成功</view>
<view class="buttons">
<navigator
hover-class="none"
class="button navigator"
url="/pages/index/index"
open-type="switchTab"
>
返回首页
</navigator>
<navigator
hover-class="none"
class="button navigator"
:url="`/pagesOrder/detail/detail?id=${query.id}`"
open-type="redirect"
>
查看订单
</navigator>
</view>
</view>

<!-- 猜你喜欢 -->
<XtxGuess ref="guessRef" />
</scroll-view>
</template>

<style lang="scss">
page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}

.viewport {
background-color: #f7f7f8;
}

.overview {
line-height: 1;
padding: 50rpx 0;
color: #fff;
background-color: #27ba9b;

.status {
font-size: 36rpx;
font-weight: 500;
text-align: center;
}

.status::before {
display: block;
font-size: 110rpx;
margin-bottom: 20rpx;
}

.buttons {
height: 60rpx;
line-height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
margin-top: 60rpx;
}

.button {
text-align: center;
margin: 0 10rpx;
font-size: 28rpx;
color: #fff;

&:first-child {
width: 200rpx;
border-radius: 64rpx;
border: 1rpx solid #fff;
}
}
}
</style>

小结一下

模拟发货

基本内容

仅在订单状态为待发货时,可模拟发货,调用后订单状态修改为待收货,包含模拟物流。

仅在开发期间使用,项目上线后应该是由商家发货。

接口封装src/services/order.ts

1
2
3
4
5
6
7
8
9
10
11
/**
* 模拟发货-内测版
* @description 在DEV环境下使用,仅在订单状态为待发货时,可模拟发货,调用后订单状态修改为待收货,包含模拟物流。
* @param id 订单id
*/
export const getMemberOrderConsignmentByIdAPI = (id: string) => {
return http({
method: 'GET',
url: `/member/order/consignment/${id}`,
})
}

参考代码pagesOrder/detail/detail.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts">
import { getMemberOrderConsignmentByIdAPI } from '@/services/order'
// 是否为开发环境
const isDev = import.meta.env.DEV
// 模拟发货
const onOrderSend = async () => {
if (isDev) {
await getMemberOrderConsignmentByIdAPI(query.id)
uni.showToast({ icon: 'success', title: '模拟发货完成' })
// 主动更新订单状态
order.value!.orderState = OrderState.DaiShouHuo
}
}
</script>

<template>
<!-- 待发货状态:模拟发货,开发期间使用,用于修改订单状态为已发货 -->
<view v-if="isDev && order.orderState == OrderState.DaiFaHuo" @tap="onOrderSend" class="button">
模拟发货
</view>
</template>

小结一下

确认收货

基本内容

点击确认收货时需二次确认,提示文案:为保障您的权益,请收到货并确认无误后,再确认收货

接口封装services/order.ts

1
2
3
4
5
6
7
8
9
10
11
/**
* 确认收货
* @description 仅在订单状态为待收货时,可确认收货。
* @param id 订单id
*/
export const putMemberOrderReceiptByIdAPI = (id: string) => {
return http<OrderResult>({
method: 'PUT',
url: `/member/order/${id}/receipt`,
})
}

参考代码pagesOrder/detail/detail.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 确认收货
const onOrderConfirm = () => {
// 二次确认弹窗
uni.showModal({
content: '为保障您的权益,请收到货并确认无误后,再确认收货',
success: async (success) => {
if (success.confirm) {
const res = await putMemberOrderReceiptByIdAPI(query.id)
// 更新订单状态
order.value = res.result
}
},
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<view class="button-group">
<navigator
class="button"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待发货状态:模拟发货,开发期间使用,用于修改订单状态为已发货 -->
<view
v-if="isDev && order.orderState === OrderState.DaiFaHuo"
class="button"
@tap="onOrderSend"
>
模拟发货
</view>
<view
v-if="order.orderState === OrderState.DaiShouHuo"
@tap="onOrderConfirm"
class="button"
>
确认收货
</view>
</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
<view
class="toolbar"
:style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
>
<!-- 待付款状态:展示支付按钮 -->
<template v-if="order.orderState === OrderState.DaiFuKuan">
<view class="button primary" @tap="onOrderPay"> 去支付 </view>
<view class="button" @tap="popup?.open?.()"> 取消订单 </view>
</template>
<!-- 其他订单状态:按需展示按钮 -->
<template v-else>
<navigator
class="button secondary"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待收货状态: 展示确认收货 -->
<view
class="button primary"
v-if="order.orderState === OrderState.DaiShouHuo"
@tap="onOrderConfirm"
>
确认收货
</view>
<!-- 待评价状态: 展示去评价 -->
<view class="button"> 去评价 </view>
<!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
<view class="button delete"> 删除订单 </view>
</template>
</view>

小结一下

订单物流

仅在订单状态为待收货,待评价,已完成时,可获取物流信息。

请求封装

types/order.d.ts

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
/** 物流信息 返回值类型 */
export type OrderLogisticResult = {
/** 快递公司 */
company: {
/** 公司名称 */
name: string
/** 快递编号 */
number: string
/** 联系电话 */
tel: string
}
/** 商品件数 */
count: number
/** 物流日志 */
list: LogisticItem[]
}

/** 物流日志 */
export type LogisticItem = {
/** 信息ID */
id: string
/** 信息文字 */
text: string
/** 时间 */
time: string
}

services/order.ts

1
2
3
4
5
6
7
8
9
10
11
12
import type { OrderLogisticResult } from '@/types/order'
/**
* 获取订单物流
* @description 仅在订单状态为待收货,待评价,已完成时,可获取物流信息。
* @param id 订单id
*/
export const getMemberOrderLogisticsByIdAPI = (id: string) => {
return http<OrderLogisticResult>({
method: 'GET',
url: `/member/order/${id}/logistics`,
})
}

页面中调用,pagesOrder/detail/detail.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const order = ref<OrderResult>()
// #1
const logisticList = ref<LogisticItem[]>([])
// #2
const getMemberOrderLogisticsByIdData = async () => {
const res = await getMemberOrderLogisticsByIdAPI(query.id)
logisticList.value = res.result.list
}
const getMemberOrderByIdData = async () => {
const res = await getMemberOrderByIdAPI(query.id)
order.value = res.result
// #3
if (
[
OrderState.DaiShouHuo,
OrderState.DaiPingJia,
OrderState.YiWanCheng
].includes(order.value.orderState)
) {
getMemberOrderLogisticsByIdData()
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<view class="shipment">
<!-- #4 订单物流信息 -->
<view v-for="item in logisticList" :key="item.id" class="item">
<view class="message">
{{ item.text }}
</view>
<view class="date"> {{ item.time }} </view>
</view>
<!-- 用户收货地址 -->
<view class="locate">
<view class="user">
{{ order.receiverContact }} {{ order.receiverMobile }}
</view>
<view class="address"> {{ order.receiverAddress }} </view>
</view>
</view>

小结一下

删除订单

仅在订单状态为待评价,已完成,已取消时,可删除订单。

接口封装

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 删除订单
* @description 仅在订单状态为待评价,已完成,已取消时,可删除订单。
* @param data ids 订单集合
*/
export const deleteMemberOrderAPI = (data: { ids: string[] }) => {
return http({
method: 'DELETE',
url: `/member/order`,
data,
})
}

代码展示

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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
<script setup lang="ts">
import { useGuessList } from '@/composables'
import { getMemberOrderByIdAPI } from '@/services/order'
import type { OrderResult } from '@/types/order'
import { onLoad, onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { OrderState, orderStateList } from '@/services/constants'
import { getPayMockAPI, getPayWxPayMiniPayAPI } from '@/services/pay'
import {
getMemberOrderConsignmentByIdAPI,
putMemberOrderReceiptByIdAPI
} from '@/services/order'
import type { LogisticItem } from '@/types/order'
import {
getMemberOrderLogisticsByIdAPI,
deleteMemberOrderAPI
} from '@/services/order'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 猜你喜欢
const { guessRef, onScrolltolower } = useGuessList()
// 弹出层组件
const popup = ref<UniHelper.UniPopupInstance>()
// 取消原因列表
const reasonList = ref([
'商品无货',
'不想要了',
'商品信息填错了',
'地址信息填写错误',
'商品降价',
'其它'
])
// 订单取消原因
const reason = ref('')
// 复制内容
const onCopy = (id: string) => {
// 设置系统剪贴板的内容
uni.setClipboardData({ data: id })
}
// 获取页面参数
const query = defineProps<{
id: string
}>()

const order = ref<OrderResult>()
const logisticList = ref<LogisticItem[]>([])
const getMemberOrderLogisticsByIdData = async () => {
const res = await getMemberOrderLogisticsByIdAPI(query.id)
logisticList.value = res.result.list
}
const getMemberOrderByIdData = async () => {
const res = await getMemberOrderByIdAPI(query.id)
order.value = res.result
if (
[
OrderState.DaiShouHuo,
OrderState.DaiPingJia,
OrderState.YiWanCheng
].includes(order.value.orderState)
) {
getMemberOrderLogisticsByIdData()
}
}
onLoad(() => {
getMemberOrderByIdData()
})
// 获取页面栈
const pages = getCurrentPages()
// 获取当前页面实例,数组最后一项
const pageInstance = pages.at(-1) as any

onReady(() => {
pageInstance.animate(
'.navbar', // 选择器
[{ backgroundColor: 'transparent' }, { backgroundColor: '#f8f8f8' }], // 关键帧信息
1000, // 动画持续时长
{
scrollSource: '#scroller', // scroll-view 的选择器
startScrollOffset: 0, // 开始滚动偏移量
endScrollOffset: 50, // 停止滚动偏移量
timeRange: 1000 // 时间长度
}
)
// 动画效果,导航栏标题
pageInstance.animate(
'.navbar .title',
[{ color: 'transparent' }, { color: '#000' }],
1000,
{
scrollSource: '#scroller',
timeRange: 1000,
startScrollOffset: 0,
endScrollOffset: 50
}
)
// 动画效果,导航栏返回按钮
pageInstance.animate(
'.navbar .back',
[{ color: '#fff' }, { color: '#000' }],
1000,
{
scrollSource: '#scroller',
timeRange: 1000,
startScrollOffset: 0,
endScrollOffset: 50
}
)
})

const onOrderPay = async () => {
if (import.meta.env.DEV) {
await getPayMockAPI({ orderId: query.id })
} else {
// 生产环境:获取支付参数 + 发起微信支付
const res = await getPayWxPayMiniPayAPI({ orderId: query.id })
await wx.requestPayment(res.result)
}
// 关闭当前页,再跳转支付结果页
uni.redirectTo({ url: `/pagesOrder/payment/payment?id=${query.id}` })
}
const isDev = import.meta.env.DEV
const onOrderSend = async () => {
if (isDev) {
await getMemberOrderConsignmentByIdAPI(query.id)
uni.showToast({ icon: 'success', title: '模拟发货完成' })
// 主动更新订单状态
order.value!.orderState = OrderState.DaiShouHuo
}
}
const onOrderConfirm = () => {
// 二次确认弹窗
uni.showModal({
content: '为保障您的权益,请收到货并确认无误后,再确认收货',
success: async (success) => {
if (success.confirm) {
const res = await putMemberOrderReceiptByIdAPI(query.id)
// 更新订单状态
order.value = res.result
}
}
})
}

// #3 倒计时结束事件
const onTimeup = () => {
// 修改订单状态为已取消
order.value!.orderState = OrderState.YiQuXiao
}

// #12
const onOrderDelete = () => {
uni.showModal({
content: '是否删除订单',
success: async (success) => {
if (success.confirm) {
await deleteMemberOrderAPI({ ids: [query.id] })
uni.redirectTo({ url: '/pagesOrder/list/list' })
}
}
})
}
</script>

<template>
<!-- 自定义导航栏: 默认透明不可见, scroll-view 滚动到 50 时展示 -->
<view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<view class="wrap">
<navigator
v-if="pages.length > 1"
open-type="navigateBack"
class="back icon-left"
></navigator>
<navigator
v-else
url="/pages/index/index"
open-type="switchTab"
class="back icon-home"
>
</navigator>
<view class="title">订单详情</view>
</view>
</view>
<scroll-view
scroll-y
class="viewport"
id="scroller"
@scrolltolower="onScrolltolower"
>
<!-- #1 -->
<template v-if="order">
<!-- 订单状态 -->
<view
class="overview"
:style="{ paddingTop: safeAreaInsets!.top + 20 + 'px' }"
>
<!-- 待付款状态:展示去支付按钮和倒计时 -->
<!-- #2 -->
<template v-if="order.orderState === OrderState.DaiFuKuan">
<view class="status icon-clock">等待付款</view>
<view class="tips">
<!-- #1 -->
<text class="money">应付金额: ¥ {{ order.payMoney }}</text>
<text class="time">支付剩余</text>
<!-- #2 -->
<uni-countdown
:second="order.countdown"
color="#fff"
splitor-color="#fff"
:show-day="false"
:show-colon="false"
@timeup="onTimeup"
/>
</view>
<view class="button" @tap="onOrderPay">去支付</view>
</template>
<!-- 其他订单状态:展示再次购买按钮 -->
<template v-else>
<!-- 订单状态文字 -->
<!-- #3 -->
<view class="status">
{{ orderStateList[order.orderState].text }}
</view>
<view class="button-group">
<navigator
class="button"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待发货状态:模拟发货,开发期间使用,用于修改订单状态为已发货 -->
<view
v-if="isDev && order.orderState === OrderState.DaiFaHuo"
class="button"
@tap="onOrderSend"
>
模拟发货
</view>
<view
v-if="order.orderState === OrderState.DaiShouHuo"
@tap="onOrderConfirm"
class="button"
>
确认收货
</view>
</view>
</template>
</view>
<!-- 配送状态 -->
<view class="shipment">
<!-- #4 订单物流信息 -->
<view v-for="item in logisticList" :key="item.id" class="item">
<view class="message">
{{ item.text }}
</view>
<view class="date"> {{ item.time }} </view>
</view>
<!-- 用户收货地址 -->
<view class="locate">
<view class="user">
{{ order.receiverContact }} {{ order.receiverMobile }}
</view>
<view class="address"> {{ order.receiverAddress }} </view>
</view>
</view>

<!-- 商品信息 -->
<view class="goods">
<view class="item">
<!-- #4 -->
<navigator
class="navigator"
v-for="item in order.skus"
:key="item.id"
:url="`/pages/goods/goods?id=${item.spuId}`"
hover-class="none"
>
<image class="cover" :src="item.image"></image>
<view class="meta">
<view class="name ellipsis">{{ item.name }}</view>
<view class="type">{{ item.attrsText }}</view>
<view class="price">
<view class="actual">
<text class="symbol">¥</text>
<text>{{ item.curPrice }}</text>
</view>
</view>
<view class="quantity">x{{ item.quantity }}</view>
</view>
</navigator>
<!-- #5 待评价状态:展示按钮 -->
<view
class="action"
v-if="order.orderState === OrderState.DaiPingJia"
>
<view class="button primary">申请售后</view>
<navigator url="" class="button"> 去评价 </navigator>
</view>
</view>
<!-- #6 合计 -->
<view class="total">
<view class="row">
<view class="text">商品总价: </view>
<view class="symbol">{{ order.totalMoney }}</view>
</view>
<view class="row">
<view class="text">运费: </view>
<view class="symbol">{{ order.postFee }}</view>
</view>
<view class="row">
<view class="text">应付金额: </view>
<view class="symbol primary">{{ order.payMoney }}</view>
</view>
</view>
</view>

<!-- #7 订单信息 -->
<view class="detail">
<view class="title">订单信息</view>
<view class="row">
<view class="item">
订单编号: {{ query.id }}
<text class="copy" @tap="onCopy(query.id)">复制</text>
</view>
<view class="item">下单时间: {{ order.createTime }}</view>
</view>
</view>

<!-- 猜你喜欢 -->
<XtxGuess ref="guessRef" />
<!-- 底部操作栏 -->
<view
class="toolbar-height"
:style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
></view>
<view
class="toolbar"
:style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
>
<!-- #8 待付款状态:展示支付按钮 -->
<template v-if="order.orderState === OrderState.DaiFuKuan">
<view class="button primary" @tap="onOrderPay"> 去支付 </view>
<view class="button" @tap="popup?.open?.()"> 取消订单 </view>
</template>
<!-- 其他订单状态:按需展示按钮 -->
<template v-else>
<navigator
class="button secondary"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
hover-class="none"
>
再次购买
</navigator>
<!-- #9 待收货状态: 展示确认收货 -->
<view
class="button primary"
v-if="order.orderState === OrderState.DaiShouHuo"
@tap="onOrderConfirm"
>
确认收货
</view>
<!-- #10 待评价状态: 展示去评价 -->
<view
class="button"
v-if="order.orderState === OrderState.DaiPingJia"
>
去评价
</view>
<!-- #11 待评价/已完成/已取消 状态: 展示删除订单 -->
<!-- 待评价之后都展示 -->
<view
class="button delete"
v-if="order.orderState >= OrderState.DaiPingJia"
@tap="onOrderDelete"
>
删除订单
</view>
</template>
</view>
</template>
<template v-else>
<!-- 骨架屏组件 -->
<PageSkeleton />
</template>
</scroll-view>
<!-- 取消订单弹窗 -->
<uni-popup ref="popup" type="bottom" background-color="#fff">
<view class="popup-root">
<view class="title">订单取消</view>
<view class="description">
<view class="tips">请选择取消订单的原因:</view>
<view
class="cell"
v-for="item in reasonList"
:key="item"
@tap="reason = item"
>
<text class="text">{{ item }}</text>
<text class="icon" :class="{ checked: item === reason }"></text>
</view>
</view>
<view class="footer">
<view class="button" @tap="popup?.close?.()">取消</view>
<view class="button primary">确认</view>
</view>
</view>
</uni-popup>
</template>

小结一下

再次购买

现在是第三种生成订单信息,从订单详情页的【再次购买】按钮跳转到填写订单页,需要传递页面参数。

  1. 订单详情页,pagesOrder/detail/detail.vue
1
2
3
4
5
6
7
8
9
<template>
<navigator
class="button"
hover-class="none"
:url="`/pagesOrder/create/create?orderId=${query.id}`"
>
再次购买
</navigator>
</template>
  1. 封装接口,services/order.ts
1
2
3
4
5
6
7
8
9
10
/**
* 填写订单-再次购买
* @param id 订单id
*/
export const getMemberOrderRepurchaseByIdAPI = (id: string) => {
return http<OrderPreResult>({
method: 'GET',
url: `/member/order/repurchase/${id}`,
})
}
  1. 填写订单页,pagesOrder/create/create.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
<script setup lang="ts">
// 页面参数
const query = defineProps<{
skuId?: string
count?: string
// #1
orderId?: string
}>()

// 获取订单信息
const orderPre = ref<OrderPreResult>()
const getMemberOrderPreData = async () => {
if (query.count && query.skuId) {
// 立即购买
const res = await getMemberOrderPreNowAPI({
count: query.count,
skuId: query.skuId,
})
orderPre.value = res.result
} else if (query.orderId) {
// #2 再次购买
const res = await getMemberOrderRepurchaseByIdAPI(query.orderId)
orderPre.value = res.result
} else {
// 预付订单
const res = await getMemberOrderPreAPI()
orderPre.value = res.result
}
}
</script>

取消订单

仅在订单状态为待付款时,可取消订单。

  1. 接口封装,services/order.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 取消订单
* @description 仅在订单状态为待付款时,可取消订单。
* @param id 订单id
* @param data cancelReason 取消理由
*/
export const getMemberOrderCancelByIdAPI = (id: string, data: { cancelReason: string }) => {
return http<OrderResult>({
method: 'PUT',
url: `/member/order/${id}/cancel`,
data,
})
}
  1. 绑定点击事件,pagesOrder/detail/detail.vue
1
2
3
4
<view class="footer">
<view class="button" @tap="popup?.close?.()">取消</view>
<view class="button primary" @tap="onOrderCancel">确认</view>
</view>
  1. 代码逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
const onOrderCancel = async () => {
// 发送请求
const res = await getMemberOrderCancelByIdAPI(query.id, {
cancelReason: reason.value
})
// 更新订单信息
order.value = res.result
// 关闭弹窗
popup.value?.close!()
// 轻提示
uni.showToast({ icon: 'none', title: '订单取消成功' })
}

订单列表页

根据订单的不同状态展示订单列表,并实现多 Tabs 分页加载。

订单列表

静态结构

pagesOrder/list/list.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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
<script setup lang="ts">
import { ref } from 'vue'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// tabs 数据
const orderTabs = ref([
{ orderState: 0, title: '全部' },
{ orderState: 1, title: '待付款' },
{ orderState: 2, title: '待发货' },
{ orderState: 3, title: '待收货' },
{ orderState: 4, title: '待评价' },
])
</script>

<template>
<view class="viewport">
<!-- tabs -->
<view class="tabs">
<text class="item" v-for="item in 5" :key="item"> 待付款 </text>
<!-- 游标 -->
<view class="cursor" :style="{ left: 0 * 20 + '%' }"></view>
</view>
<!-- 滑动容器 -->
<swiper class="swiper">
<!-- 滑动项 -->
<swiper-item v-for="item in 5" :key="item">
<!-- 订单列表 -->
<scroll-view scroll-y class="orders">
<view class="card" v-for="item in 2" :key="item">
<!-- 订单信息 -->
<view class="status">
<text class="date">2023-04-14 13:14:20</text>
<!-- 订单状态文字 -->
<text>待付款</text>
<!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
<text class="icon-delete"></text>
</view>
<!-- 商品信息,点击商品跳转到订单详情,不是商品详情 -->
<navigator
v-for="sku in 2"
:key="sku"
class="goods"
:url="`/pagesOrder/detail/detail?id=1`"
hover-class="none"
>
<view class="cover">
<image
mode="aspectFit"
src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg"
></image>
</view>
<view class="meta">
<view class="name ellipsis">ins风小碎花泡泡袖衬110-160cm</view>
<view class="type">藏青小花 130</view>
</view>
</navigator>
<!-- 支付信息 -->
<view class="payment">
<text class="quantity">共5件商品</text>
<text>实付</text>
<text class="amount"> <text class="symbol">¥</text>99</text>
</view>
<!-- 订单操作按钮 -->
<view class="action">
<!-- 待付款状态:显示去支付按钮 -->
<template v-if="true">
<view class="button primary">去支付</view>
</template>
<template v-else>
<navigator
class="button secondary"
:url="`/pagesOrder/create/create?orderId=id`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待收货状态: 展示确认收货 -->
<view v-if="false" class="button primary">确认收货</view>
</template>
</view>
</view>
<!-- 底部提示文字 -->
<view class="loading-text" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
{{ true ? '没有更多数据~' : '正在加载...' }}
</view>
</scroll-view>
</swiper-item>
</swiper>
</view>
</template>

<style lang="scss">
page {
height: 100%;
overflow: hidden;
}

.viewport {
height: 100%;
display: flex;
flex-direction: column;
background-color: #fff;
}

// tabs
.tabs {
display: flex;
justify-content: space-around;
line-height: 60rpx;
margin: 0 10rpx;
background-color: #fff;
box-shadow: 0 4rpx 6rpx rgba(240, 240, 240, 0.6);
position: relative;
z-index: 9;

.item {
flex: 1;
text-align: center;
padding: 20rpx;
font-size: 28rpx;
color: #262626;
}

.cursor {
position: absolute;
left: 0;
bottom: 0;
width: 20%;
height: 6rpx;
padding: 0 50rpx;
background-color: #27ba9b;
/* 过渡效果 */
transition: all 0.4s;
}
}

// swiper
.swiper {
background-color: #f7f7f8;
}

// 订单列表
.orders {
.card {
min-height: 100rpx;
padding: 20rpx;
margin: 20rpx 20rpx 0;
border-radius: 10rpx;
background-color: #fff;

&:last-child {
padding-bottom: 40rpx;
}
}

.status {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #999;
margin-bottom: 15rpx;

.date {
color: #666;
flex: 1;
}

.primary {
color: #ff9240;
}

.icon-delete {
line-height: 1;
margin-left: 10rpx;
padding-left: 10rpx;
border-left: 1rpx solid #e3e3e3;
}
}

.goods {
display: flex;
margin-bottom: 20rpx;

.cover {
width: 170rpx;
height: 170rpx;
margin-right: 20rpx;
border-radius: 10rpx;
overflow: hidden;
position: relative;
}

.quantity {
position: absolute;
bottom: 0;
right: 0;
line-height: 1;
padding: 6rpx 4rpx 6rpx 8rpx;
font-size: 24rpx;
color: #fff;
border-radius: 10rpx 0 0 0;
background-color: rgba(0, 0, 0, 0.6);
}

.meta {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}

.name {
height: 80rpx;
font-size: 26rpx;
color: #444;
}

.type {
line-height: 1.8;
padding: 0 15rpx;
margin-top: 10rpx;
font-size: 24rpx;
align-self: flex-start;
border-radius: 4rpx;
color: #888;
background-color: #f7f7f8;
}

.more {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #333;
}
}

.payment {
display: flex;
justify-content: flex-end;
align-items: center;
line-height: 1;
padding: 20rpx 0;
text-align: right;
color: #999;
font-size: 28rpx;
border-bottom: 1rpx solid #eee;

.quantity {
font-size: 24rpx;
margin-right: 16rpx;
}

.amount {
color: #444;
margin-left: 6rpx;
}

.symbol {
font-size: 20rpx;
}
}

.action {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 20rpx;

.button {
width: 180rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
margin-left: 20rpx;
border-radius: 60rpx;
border: 1rpx solid #ccc;
font-size: 26rpx;
color: #444;
}

.secondary {
color: #27ba9b;
border-color: #27ba9b;
}

.primary {
color: #fff;
background-color: #27ba9b;
}
}

.loading-text {
text-align: center;
font-size: 28rpx;
color: #666;
padding: 20rpx 0;
}
}
</style>

Tabs 滑动切换

订单列表的 Tabs 支持滑动切换,从【我的】进入订单列表,能高亮对应的下标。

  1. 循环标题和对应的轮播,点击高亮并滑动对应的轮播图,pagesOder/list/list.vue
1
const activeIndex = ref(0)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<view class="tabs">
<!-- #1 循环标题 -->
<!-- #3 点击高亮标题,@tap="activeIndex = index" -->
<text
class="item"
v-for="(item, index) in orderTabs"
:key="item.title"
@tap="activeIndex = index"
>
{{ item.title }}
</text>
<!-- #4 控制游标 -->
<view class="cursor" :style="{ left: activeIndex * 20 + '%' }"></view>
</view>
1
2
<!-- #2 循环轮播 -->
<swiper-item v-for="item in orderTabs" :key="item.title"></swiper-item>
1
2
<!-- #5 滑到对应的轮播 -->
<swiper :current="activeIndex" class="swiper">
  1. 滑动轮播图也高亮对应的 Tab。
1
<swiper :current="activeIndex" class="swiper" @change="activeIndex = $event.detail.current">

小结一下

跳转 Tab 页面高亮

从【我的】页面跳过来高亮对应的 Tab。

1
2
3
4
5
6
7
8
9
10
// #1
const query = defineProps<{
type: string
}>()

// #2
// 当直接点击【我的】,query.type 是 null,+null 的结果是 0
const activeIndex = ref(
orderTabs.value.findIndex((v) => v.orderState === +query.type)
)

pagesOrder/list/list.vue

1
2
3
4
const query = defineProps<{
type: string
}>()
const activeIndex = ref(orderTabs.value.findIndex(v => v.orderState === Number(query.type)))

获取订单列表

当前页面是多 Tabs 列表的情况,每个 Tabs 都是独立的列表,并支持分页加载。

  1. 定义类型,types/order.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { PageParams } from '@/types/global'
/** 订单列表参数 */
export type OrderListParams = PageParams & { orderState: number }

/** 订单列表 */
export type OrderListResult = {
/** 总记录数 */
counts: number
/** 数据集合 [ 订单信息 ] */
items: OrderItem[]
/** 当前页码 */
page: number
/** 总页数 */
pages: number
/** 页尺寸 */
pageSize: number
}

/** 订单列表项 */
export type OrderItem = OrderResult & {
/** 总件数 */
totalNum: number
}
  1. 接口封装,services/order.ts
1
2
3
4
5
6
7
8
9
10
11
12
import type { OrderListParams, OrderListResult } from '@/types/order'
/**
* 获取订单列表
* @param data orderState 订单状态
*/
export const getMemberOrderAPI = (data: OrderListParams) => {
return http<OrderListResult>({
method: 'GET',
url: `/member/order`,
data,
})
}

订单列表渲染

为了更好维护多 Tabs 列表,把列表抽离成业务组件,在组件内部独立维护列表数据,包括分页,下拉刷新等业务。

  1. 封装列表组件,pagesOrder/list/components/OrderList.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">
import { OrderState } from '@/services/constants'
import { orderStateList } from '@/services/constants'
import { getMemberOrderAPI } from '@/services/order'
import type { OrderItem } from '@/types/order'
import type { OrderListParams } from '@/types/order'
import { onMounted, ref } from 'vue'

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

// 定义 porps
const props = defineProps<{
orderState: number
}>()

// 请求参数
const queryParams: OrderListParams = {
page: 1,
pageSize: 5,
orderState: props.orderState,
}

// 获取订单列表
const orderList = ref<OrderItem[]>([])
const getMemberOrderData = async () => {
const res = await getMemberOrderAPI(queryParams)
orderList.value = res.result.items
}
// 组件
onMounted(() => {
getMemberOrderData()
})
</script>

<template>
<scroll-view scroll-y class="orders">
<view class="card" v-for="order in orderList" :key="order.id">
<!-- 订单信息 -->
<view class="status">
<text class="date">{{ order.createTime }}</text>
<!-- 订单状态文字 -->
<text>{{ orderStateList[order.orderState].text }}</text>
<!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
<text v-if="order.orderState >= OrderState.DaiPingJia" class="icon-delete"></text>
</view>
<!-- 商品信息,点击商品跳转到订单详情,不是商品详情 -->
<navigator
v-for="item in order.skus"
:key="item.id"
class="goods"
:url="`/pagesOrder/detail/detail?id=${order.id}`"
hover-class="none"
>
<view class="cover">
<image mode="aspectFit" :src="item.image"></image>
</view>
<view class="meta">
<view class="name ellipsis">{{ item.name }}</view>
<view class="type">{{ item.attrsText }}</view>
</view>
</navigator>
<!-- 支付信息 -->
<view class="payment">
<text class="quantity">共{{ order.totalNum }}件商品</text>
<text>实付</text>
<text class="amount"> <text class="symbol">¥</text>{{ order.payMoney }}</text>
</view>
<!-- 订单操作按钮 -->
<view class="action">
<!-- 待付款状态:显示去支付按钮 -->
<template v-if="order.orderState === OrderState.DaiFuKuan">
<view class="button primary">去支付</view>
</template>
<template v-else>
<navigator
class="button secondary"
:url="`/pagesOrder/create/create?orderId=id`"
hover-class="none"
>
再次购买
</navigator>
<!-- 待收货状态: 展示确认收货 -->
<view v-if="order.orderState === OrderState.DaiShouHuo" class="button primary"
>确认收货</view
>
</template>
</view>
</view>
<!-- 底部提示文字 -->
<view class="loading-text" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
{{ true ? '没有更多数据~' : '正在加载...' }}
</view>
</scroll-view>
</template>
  1. 订单列表页,把订单状态传递给列表组件(父传子),pagesOrder/list/list.vue
1
2
import OrderList from './components/OrderList.vue'
// ...
1
2
3
4
5
6
7
<swiper class="swiper" :current="activeIndex" @change="activeIndex = $event.detail.current">
<!-- 滑动项 -->
<swiper-item v-for="item in orderTabs" :key="item.title">
<!-- 订单列表 -->
<OrderList :order-state="item.orderState" />
</swiper-item>
</swiper>

小结一下

分页加载

分页加载的逻辑在之前我们已经学习过,本节就不再重复讲义的内容了,下拉刷新业务同理。

订单支付

订单支付功能之前我们已经学习过,也不再重复,确认收货,删除订单等按钮的业务同理。

pagesOrder/list/components/OrderList.vue

1
2
3
4
5
6
7
8
9
10
11
const onOrderPay = async (id: string) => {
if (import.meta.env.DEV) {
await getPayMockAPI({ orderId: id })
} else {
const res = await getPayWxPayMiniPayAPI({ orderId: id })
wx.requestPayment(res.result)
}
uni.showToast({ title: '支付成功' })
const order = orderList.value.find(v => v.id === id)
order!.orderState = OrderState.DaiFaHuo
}
1
<view class="button primary" @tap="onOrderPay(order.id)">去支付</view>
1
2
3
4
5
6
7
<navigator
class="button secondary"
:url="`/pagesOrder/create/create?orderId=${order.id}`"
hover-class="none"
>
再次购买
</navigator>

微信小程序端

把当前 uni-app 项目打包成微信小程序端,并发布上线。

核心步骤

  1. 运行打包命令 pnpm build:mp-weixin

  2. 预览和测试,微信开发者工具导入生成的 /dist/build/mp-weixin 目录。

  3. 上传小程序代码。

  4. 提交审核和发布。

步骤图示

项目打包上线需要使用到多个工具,注意工具之间的职责。

1
VSCode ----> 微信开发者工具 ----> 微信公众平台

了解:开发者可独立使用 miniprogram-ci 进行小程序代码的上传等操作!

打包成其他小程序端的步骤类似,只是更换了 打包命令开发者工具

小结一下

条件编译

基本了解

Q:按照 uni-app 规范开发可保证多平台兼容,但每个平台有自己的一些特性,该如何处理?

A:通过条件编译,让代码按条件编译到指定平台。

多端登录

网页端不支持微信平台授权登录等功能,可通过 条件编译,实现不同端渲染不同的登录界面。

条件编译语法

通过特殊注释,以 #ifdef#ifndef 加 平台名称 开头,以 #endif 结尾,支持 vue、ts、js、scss、css、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
<script setup lang="ts">
// 微信平台特有 API,需要条件编译
// #ifdef MP-WEIXIN
wx.login()
wx.requestPayment()
// #endif
</script>

<template>
<!-- 微信开发能力按钮,需要条件编译 -->
<!-- #ifdef MP-WEIXIN -->
<button open-type="openSetting">授权管理</button>
<button open-type="feedback">问题反馈</button>
<button open-type="contact">联系我们</button>
<!-- #endif -->
</template>

<style>
/* 如果出现样式兼容,也可添加条件编译 */
page {
/* #ifdef H5 */
background-color: pink;
/* #endif */
}
</style>

了解:#ifdef H5 || MP-WEIXIN 表示在 H5 平台 微信小程序平台存在的代码。

可通过搜索 wx.open-type 等平台关键词,快速查找需要添加编译模式的代码。

操作代码

pnpm dev:h5

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
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { postLoginWxMinAPI, postLoginWxMinSimpleAPI } from '@/services/login'
import type { LoginResult } from '@/types/member'
import { useMemberStore } from '@/stores'

// #4
// #ifdef MP-WEIXIN
let code = ''
onLoad(async () => {
const res = await uni.login()
code = res.code
})

const loginSuccess = (profile: LoginResult) => {
// 保存会员信息
const memberStore = useMemberStore()
memberStore.setProfile(profile)
// 成功提示
uni.showToast({ icon: 'success', title: '登录成功' })
setTimeout(() => {
// 页面跳转
// uni.switchTab({ url: '/pages/my/my' })
uni.navigateBack()
}, 500)
}

const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = async (ev) => {
const encryptedData = ev.detail.encryptedData!
const iv = ev.detail.iv!
const res = await postLoginWxMinAPI({ code, encryptedData, iv })
loginSuccess(res.result)
}

const onGetphonenumberSimple = async () => {
const res = await postLoginWxMinSimpleAPI('13123456789')
loginSuccess(res.result)
}
// #endif
</script>
<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">
<!-- #2 -->
<!-- #ifdef H5 -->
<input type="text" class="input" placeholder="请输入用户名/手机号码" />
<input type="text" class="input" password placeholder="请输入密码" />
<button class="button phone">登录</button>
<!-- #endif -->

<!-- #1 -->
<!-- #ifdef MP-WEIXIN -->
<button
class="button phone"
open-type="getPhoneNumber"
@getphonenumber="onGetphonenumber"
>
<text class="icon icon-phone"></text>
手机号快捷登录
</button>
<!-- #endif -->
<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">
// ...
.login {
display: flex;
flex-direction: column;
height: 60vh;
padding: 40rpx 20rpx 20rpx;
/* #3 */
.input {
width: 100%;
height: 80rpx;
font-size: 28rpx;
border-radius: 72rpx;
border: 1px solid #ddd;
padding-left: 30rpx;
margin-bottom: 20rpx;
}
// ...
}
</style>

打包为 H5 端

把当前 uni-app 项目打包成网页(H5)端,并配置路由基础路径。

核心步骤

  1. 运行打包命令 pnpm build:h5

  2. 预览和测试,使用浏览器打开 /dist/build/h5 目录下的 index.html 文件。

  3. 由运维部署到服务器。

路由基础路径

默认的路由基础路径为 / 根路径,部分网站并不是部署到根路径,需要按运维要求调整,manifest.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
/* 小程序特有相关 */

"mp-weixin": {
// …省略
},
/* 网页端特有配置 */
"h5": {
"router": {
// 基础路径:./ 为相对路径
"base": "./"
}
}
"vueVersion": "3"
}

小结一下

打包为 APP 端

App 端 的打包,预览、测试、发行,使用 HBuilderX 工具。

login.vue

1
2
<!-- #ifdef MP-WEIXIN -->
<!-- #endif -->