小兔鲜儿 - 购物车模块
完成加入购物车,购物车列表交互,计算结算金额等业务。
加入购物车
在商品详情页把 选中规格后的商品(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";
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: string; 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
|
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
|
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 = { modelValue: number; min: number; max: number; step: number; disabled: boolean; inputWidth: string | number; inputHeight: string | number; 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
|
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 温馨提示
:::