小兔鲜儿 - 购物车模块

完成加入购物车,购物车列表交互,计算结算金额等业务。

加入购物车

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

购物车

接口相关

接口详情

接口地址:/member/cart

请求方式:POST

登录权限:

请求参数:

Body

字段名称 是否必须 默认值 备注
skuId 商品库存单位
count 购买商品数量

接口封装

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 事件,获取加入购物车时所需的参数。

{3-7,15}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
// 加入购物车事件
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
381
382
383
384
385
386
387
388
<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
open-type="switchTab"
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: 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;
box-sizing: content-box;

.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 {
margin-left: auto;
display: flex;
justify-content: space-between;
text-align: center;
line-height: 72rpx;
font-size: 13px;
color: #fff;

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

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

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

登录状态

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

{5,11}
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>

列表渲染

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

接口信息

接口地址:/member/cart

请求方式:GET

登录权限:

请求参数:无

接口封装

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/services/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;
};

参考代码

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

{21-23}
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>

温馨提示:自行完成猜你喜欢分页加载。

删除购物车

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

接口相关

接口详情

接口地址:/member/cart

请求方式:DELETE

登录权限:

请求参数:

Body

字段名称 是否必须 类型 备注
ids string[] SKUID 集合

接口封装

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
28
<script setup lang="ts">
// 点击删除按钮
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>

修改商品信息

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

接口相关

接口详情

接口地址:/member/cart/:id

请求方式:PUT

登录权限:

请求参数:

路径参数

字段名称 是否必须 默认值 备注
id 商品的 skuId

Body

字段名称 是否必须 默认值 备注
selected 非必须 是否选中
count 非必须 数量

接口封装

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 插件中的 步进器组件 修改商品数量,补充类型声明文件让组件类型更安全。

声明文件

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" {
export interface GlobalComponents {
"vk-data-input-number-box": InputNumberBox;
}
}

参考代码

{5-7,13-19}
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>

修改商品选中/全选

修改单个商品选中会影响全选状态,修改全选状态同理。

全选商品

接口地址:/member/cart/selected

请求方式:PUT

登录权限:

请求参数:

路径参数

字段名称 是否必须 默认值 备注
id skuId

Body

字段名称 是否必须 默认值 备注
selected 是否全选
ids 商品集合

接口封装

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

参考代码

{5,7,11-13,18,20-22,24,32,37}
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
<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>

底部结算信息

计算总钱数(总金额)

计算并展示购物车中选中商品所要支付的总金额,在用户切换商品选中状态和改变购数量后总的金额也要相应的进行重新计算,要实现这个功能我们仍然借助计算属性来实现:

{4,9,16-16,20-29}
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>

带返回按钮的购物车

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

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

购物车页

目录结构如下:

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

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

::: tip 温馨提示

  • 小程序 跳转到 tabBar 页面 时,会关闭其他所有非 tabBar 页面。
  • 小程序的 tabBar 页没有后退按钮,可通过 getCurrentPages() 验证,结果仅有一个页面,意味着历史记录被清空了。
  • 有历史记录的普通页才显示后退按钮。

:::