该部分与购物支付功能直接相关,涉及接口比较多。
定义接口
在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属性。
源码
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">¥ {{ 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>¥ {{ 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>¥ {{ i.price }}</p> </td> <td class="tc"><el-input-number v-model="i.count" :min="0" /></td> <td class="tc"> <p class="f16 red">¥ {{ (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"> ¥ {{ 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 })
|