vue实践之商场app - 购物车

该部分与购物支付功能直接相关,涉及接口比较多。

定义接口

在apis文件夹下新建cart.js文件,定义以下接口。即更新账户购物车的操作都需要与服务器同步。

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
import request from "@/utils/http"

export const insertCartAPI = ({ skuId, count }) => {
return request({
url: '/member/cart',
method: 'POST',
data: {
skuId,
count
}
})
}

export const findNewCartListAPI = () => {
return request({
url: "member/cart",
})
}

export const delCartAPI = (ids) => {
return request({
url: '/member/cart',
method: 'DELETE',
data: {
ids
}
})
}

export const mergeCartAPI = (data) => {
return request({
url: 'member/cart/merge',
method: 'POST',
data
})
}

一级导航上的购物车组件

在Layout/components下新进文件HeaderCart.vue源码见下
该部分作为整个组件嵌在LayoutHeader中,一开始只是个购物车图标,鼠标悬停在图标上时,剩余的部分透明度由0变成1。接口部分较为简单,直接调用store即可

1
2
3
4
5
6
&:hover {
.layer {
opacity: 1;
transform: none;
}
}

后面的layer部分分条列举购物车项,最后在页脚显示商品总数和总价格。

效果图

购物车整个浏览页面

在views/CartList文件夹下新建文件index.vue源码见下

效果图

可以看到左侧有复选框的功能,其中全选等部分功能的实现也是在store中定义好的。

pinia管理购物车数据

可以看出这部分是重头戏。在stores文件夹下新建文件cartStore.js文件,源码如下

登录状态确认

此处使用计算方法,若能检测到用户的token值则已登录

1
const isLogin = computed(() => userStore.userInfo.token)

获取更新后的购物车数据

经常要用到,在更新购物车状态后需要再获取一次服务器数据,重新渲染

1
2
3
4
const updateNewList = async () => {
const res = await findNewCartListAPI()
cartList.value = res.result
}

增加购物车内容

在商品详情页中用过一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const addCart = () => {
if (skuObj.skuId) {
cartStore.addCart({
id: goods.value.id,
name: goods.value.name,
picture: goods.value.mainPictures[0],
price: goods.value.price,
count: count.value,
skuId: skuObj.skuId,
attrsText: skuObj.specsText,
selected: true
})
} else {
ElMessage.warning('请选择规格')
}
}

此处传入了goods的多个参数,在store中需要用到两个(skuId和count)。
此处需要检测是否已经登录。若登录了就直接扔给后端处理,否则在本地处理。
如果在本地的购物车中找不到类似的商品(表现为skuId找不到一致的),就新增一项;若找到相似商品,就增加数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const addCart = async (goods) => {
const { skuId, count } = goods
if (isLogin.value) {
await insertCartAPI({ skuId, count })
updateNewList()
} else {
const item = cartList.value.find((item) => goods.skuId === item.skuId)
if (item) {
item.count++
} else {
cartList.value.push(goods)
}
}
}

删除购物车中物品

与增加的逻辑一致,若登录就交给后端处理,否则在本地处理

1
2
3
4
5
6
7
8
9
const delCart = async (skuId) => {
if (isLogin.value) {
await delCartAPI([skuId])
updateNewList()
} else {
const idx = cartList.value.findIndex((item) => skuId === item.skuId)
cartList.value.splice(idx, 1)
}
}

清除购物车中物品

用于在退出登录后对本地购物车清空,直接清空数组就好

1
2
3
const clearCart = () => {
cartList.value = []
}

单选

改变指定项的selected选项(用于单击商品的复选框)

1
2
3
4
const singleCheck = (skuId, selected) => {
const item = cartList.value.find((item) => item.skuId === skuId)
item.selected = selected
}

全选

每一个的selected选项都赋指定值(用于点击全选栏的反应,不一定是真,看selected的参数值)

1
2
3
const allCheck = (selected) => {
cartList.value.forEach(item => item.selected = selected)
}

一些计算方法

reduce()是js数组中的一个方法,用于对数组中的元素进行累积计算。
如在

1
array.reduce((a, c) => a + c.count, 0)

中,a是累加器,表示当前的累加值;c是当前遍历的数组元素。该句表示累加器a累次与c.count相加,其中a的初始值为0。

  • 计算数量和

    1
    const allCount = computed(() => cartList.value.reduce((a, c) => a + c.count, 0))
  • 计算价值和

    1
    const allPrice = computed(() => cartList.value.reduce((a, c) => a + c.count * c.price, 0))
  • 计算是否为全选(用于在单项点完后全选栏是否勾选)

    1
    const isAll = computed(() => cartList.value.every((item) => item.selected))
  • 计算已选商品的数量

    1
    const selectedCount = computed(() => cartList.value.filter(item => item.selected).reduce((a, c) => a + c.count, 0))
  • 计算已选商品的价格

    1
    const selectedPrice = computed(() => cartList.value.filter(item => item.selected).reduce((a, c) => a + c.count * c.price, 0))

这个数据和用户数据一样,持久化是最合适的,因此下面也有persist属性。

源码

HeaderCart

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
<script setup>
import { useCartStore } from '@/stores/cartStore'
const cartStore = useCartStore()

</script>

<template>
<div class="cart">
<a class="curr" href="javascript:;">
<i class="iconfont icon-shoppingcart"></i><em>{{ cartStore.cartList.length }}</em>
</a>
<div class="layer">
<div class="list">

<div class="item" v-for="i in cartStore.cartList" :key="i">
<RouterLink to="">
<img :src="i.picture" alt="" />
<div class="center">
<p class="name ellipsis-2">{{ i.name }}</p>
<p class="attr ellipsis">{{ i.attrText }}</p>
</div>
<div class="right">
<p class="price">&yen; {{ i.price }}</p>
<p class="count">x{{ i.count }}</p>
</div>
</RouterLink>
<i class="iconfont icon-close-new" @click="cartStore.delCart(i.skuId)"></i>
</div>

</div>
<div class="foot">
<div class="total">
<p>共 {{ cartStore.allCount }} 件商品</p>
<p>&yen; {{ cartStore.allPrice.toFixed(2) }}</p>
</div>
<el-button size="large" type="primary" @click="$router.push('/cartlist')">去购物车结算</el-button>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.cart {
width: 50px;
position: relative;
z-index: 600;

.curr {
height: 32px;
line-height: 32px;
text-align: center;
position: relative;
display: block;

.icon-cart {
font-size: 22px;
}

em {
font-style: normal;
position: absolute;
right: 0;
top: 0;
padding: 1px 6px;
line-height: 1;
background: $helpColor;
color: #fff;
font-size: 12px;
border-radius: 10px;
font-family: Arial;
}
}

&:hover {
.layer {
opacity: 1;
transform: none;
}
}

.layer {
opacity: 0;
transition: all 0.4s 0.2s;
transform: translateY(-200px) scale(1, 0);
width: 400px;
height: 400px;
position: absolute;
top: 50px;
right: 0;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
background: #fff;
border-radius: 4px;
padding-top: 10px;

&::before {
content: "";
position: absolute;
right: 14px;
top: -10px;
width: 20px;
height: 20px;
background: #fff;
transform: scale(0.6, 1) rotate(45deg);
box-shadow: -3px -3px 5px rgba(0, 0, 0, 0.1);
}

.foot {
position: absolute;
left: 0;
bottom: 0;
height: 70px;
width: 100%;
padding: 10px;
display: flex;
justify-content: space-between;
background: #f8f8f8;
align-items: center;

.total {
padding-left: 10px;
color: #999;

p {
&:last-child {
font-size: 18px;
color: $priceColor;
}
}
}
}
}

.list {
height: 310px;
overflow: auto;
padding: 0 10px;

&::-webkit-scrollbar {
width: 10px;
height: 10px;
}

&::-webkit-scrollbar-track {
background: #f8f8f8;
border-radius: 2px;
}

&::-webkit-scrollbar-thumb {
background: #eee;
border-radius: 10px;
}

&::-webkit-scrollbar-thumb:hover {
background: #ccc;
}

.item {
border-bottom: 1px solid #f5f5f5;
padding: 10px 0;
position: relative;

i {
position: absolute;
bottom: 38px;
right: 0;
opacity: 0;
color: #666;
transition: all 0.5s;
}

&:hover {
i {
opacity: 1;
cursor: pointer;
}
}

a {
display: flex;
align-items: center;

img {
height: 80px;
width: 80px;
}

.center {
padding: 0 10px;
width: 200px;

.name {
font-size: 16px;
}

.attr {
color: #999;
padding-top: 5px;
}
}

.right {
width: 100px;
padding-right: 20px;
text-align: center;

.price {
font-size: 16px;
color: $priceColor;
}

.count {
color: #999;
margin-top: 5px;
font-size: 16px;
}
}
}
}
}
}
</style>

index.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
<script setup>
import { useCartStore } from '../../stores/cartStore';
const cartStore = useCartStore()

const singleCheck = (i, selected) => {
cartStore.singleCheck(i.skuId, selected)
}

const allCheck = (selected) => {
cartStore.allCheck(selected)
}

const delCart = (skuId) => {
cartStore.delCart(skuId)
}
</script>

<template>
<div class="xtx-cart-page">
<div class="container m-top-20">
<div class="cart">
<table>
<thead>
<tr>
<th width="120"><el-checkbox :model-value="cartStore.isAll" @change="allCheck" /></th>
<th width="440">商品信息</th>
<th width="220">单价</th>
<th width="180">数量</th>
<th width="180">小计</th>
<th width="140">操作</th>
</tr>
</thead>

<tbody>
<tr v-for="i in cartStore.cartList" :key="i.id">
<td>
<el-checkbox :model-value="i.selected" @change="(selected) => singleCheck(i, selected)" />
</td>
<td>
<div class="goods">
<router-link to="/"><img :src="i.picture" alt="" /></router-link>
<div>
<p class="name ellipsis">{{ i.name }}</p>
</div>
</div>
</td>
<td class="tc">
<p>&yen; {{ i.price }}</p>
</td>
<td class="tc"><el-input-number v-model="i.count" :min="0" /></td>
<td class="tc">
<p class="f16 red">&yen; {{ (i.price * i.count).toFixed(2) }}</p>
</td>
<td class="tc">
<p>
<el-popconfirm title="确认删除吗" confirm-button-text="确认" cancel-button-text="取消" @confirm="delCart(i.skuId)">
<template #reference><a href="javascript:;">删除</a></template>
</el-popconfirm>
</p>
</td>
</tr>
<tr v-if="cartStore.cartList.length === 0">
<td colspan="6">
<div class="cart-none">
<el-empty description="购物车列表为空">
<el-button type="primary" @click="$router.push('/')">随便逛逛</el-button>
</el-empty>
</div>
</td>
</tr>
</tbody>
</table>
</div>

<div class="action">
<div class="batch">
共 {{ cartStore.allCount }} 件商品,已选择 {{ cartStore.selectedCount }} 件,商品合计 <span class="red"> &yen; {{ cartStore.selectedPrice.toFixed(2) }} </span>
</div>
<div class="total">
<el-button size="large" type="primary" @click="$router.push('/checkout')">下单结算</el-button>
</div>
</div>
</div>
</div>
</template>

<style scoped lang="scss">
.xtx-cart-page {
margin-top: 20px;

.cart {
background: #fff;
color: #666;

table {
border-spacing: 0;
border-collapse: collapse;
line-height: 24px;

th,
td {
padding: 10px;
border-bottom: 1px solid #f5f5f5;

&:first-child {
text-align: left;
padding-left: 30px;
color: #999;
}
}

th {
font-size: 16px;
font-weight: normal;
line-height: 50px;
}
}
}

.cart-none {
text-align: center;
padding: 120px 0;
background: #fff;

p {
color: #999;
padding: 20px 0;
}
}

.tc {
text-align: center;

a {
color: $xtxColor;
}

.xtx-numbox {
margin: 0 auto;
width: 120px;
}
}

.red {
color: $priceColor;
}

.green {
color: $xtxColor;
}

.f16 {
font-size: 16px;
}

.goods {
display: flex;
align-items: center;

img {
width: 100px;
height: 100px;
}

>div {
width: 280px;
font-size: 16px;
padding-left: 10px;

.attr {
font-size: 14px;
color: #999;
}
}
}

.action {
display: flex;
background: #fff;
margin-top: 20px;
height: 80px;
align-items: center;
font-size: 16px;
justify-content: space-between;
padding: 0 30px;

.xtx-checkbox {
color: #999;
}

.batch {
a {
margin-left: 20px;
}
}

.red {
font-size: 18px;
margin-right: 20px;
font-weight: bold;
}
}

.tit {
color: #666;
font-size: 16px;
font-weight: normal;
line-height: 50px;
}

}
</style>

CartStore

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
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useUserStore } from './user'
import { insertCartAPI, findNewCartListAPI, delCartAPI } from '@/apis/cart'

export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore()
const isLogin = computed(() => userStore.userInfo.token)
const cartList = ref([])

const updateNewList = async () => {
const res = await findNewCartListAPI()
cartList.value = res.result
}

const addCart = async (goods) => {
const { skuId, count } = goods
if (isLogin.value) {
await insertCartAPI({ skuId, count })
updateNewList()
} else {
const item = cartList.value.find((item) => goods.skuId === item.skuId)
if (item) {
item.count++
} else {
cartList.value.push(goods)
}
}

}

const delCart = async (skuId) => {
if (isLogin.value) {
await delCartAPI([skuId])
updateNewList()
} else {
const idx = cartList.value.findIndex((item) => skuId === item.skuId)
cartList.value.splice(idx, 1)
}
}

const clearCart = () => {
cartList.value = []
}

const singleCheck = (skuId, selected) => {
const item = cartList.value.find((item) => item.skuId === skuId)
item.selected = selected
}

const allCheck = (selected) => {
cartList.value.forEach(item => item.selected = selected)
}

const allCount = computed(() => cartList.value.reduce((a, c) => a + c.count, 0))
const allPrice = computed(() => cartList.value.reduce((a, c) => a + c.count * c.price, 0))
const isAll = computed(() => cartList.value.every((item) => item.selected))
const selectedCount = computed(() => cartList.value.filter(item => item.selected).reduce((a, c) => a + c.count, 0))
const selectedPrice = computed(() => cartList.value.filter(item => item.selected).reduce((a, c) => a + c.count * c.price, 0))

return {
cartList,
updateNewList,
delCart,
addCart,
clearCart,
singleCheck,
allCheck,

allCount,
allPrice,
isAll,
selectedCount,
selectedPrice,
}
}, {
persist: true
})

vue实践之商场app - 购物车
http://example.com/2025/05/06/mall-front-7/
作者
Ivan Chen
发布于
2025年5月6日
许可协议
IVAN