购物车模块
购物车功能分析
思路流程
- 购物车的各种操作都会有两种状态的区分,登录和未登录
- 所有操作都封装到
Pinia中,组件只需要触发actions函数 - 在
actions中通过member信息去区分登录状态- 已登录,通过调用接口去服务端操作,响应成功会通过
actions修改Pinia中的数据即可 - 未登录时,通过
actions修改Pinia中的数据即可,Pinia实现持久化,同步保存在本地
- 已登录,通过调用接口去服务端操作,响应成功会通过
会员路由、Store 和 类型声明
定义路由
新建页面组件:src\views\Cart\index.vue
<template>
<h1>购物车页</h1>
</template>
添加路由配置:src\router\index.ts
// 购物车(Layout的二级路由)
{
path: '/cart',
component: () => import('@/views/Cart/index.vue'),
},
定义 Store
定义新 Store: src\store\modules\cart.ts
import { defineStore } from 'pinia';
// 定义新 Store
export const useCartStore = defineStore('cart', () => {
// 记得 return
return {};
});
合并新 Store: src\store\index.ts
export * from './modules/cart';
定义类型声明
新建类型声明文件:src\types\modules\cart.d.ts
// 类型声明文件
合并类型声明:src\types\index.d.ts
// 统一导出所有类型文件
export * from "./modules/cart";
添加购物车功能实现-已登录
本节目标: 完成商品详情的添加购物车操作。
实现步骤
actions中封装加入购物车的接口。- 在商品详情页实现添加逻辑触发
actions函数调用接口。
接口:加入购物车
接口基本信息
Path: /member/cart
Method: POST
请求参数
Body
| 名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
|---|---|---|---|---|---|
| skuId | string | 必须 | SKUID | ||
| count | integer | 必须 | 数量 |
封装接口:src\store\modules\cart.ts
// 加入购物车
const addCart = async (data: object) => {
// 已登录情况:调用接口
const res = await http('POST', '/member/cart', data);
console.log('POST', '/member/cart', res);
// 成功提示
message({ type: 'success', text: '加入购物车成功' });
};
// 记得 return 返回
点击按钮调用接口
在商品详情组件调用 actions 函数加入购物车。
- 准备好加入购物车接口所需的参数:
skuId和count - 点击按钮,调用接口。
- 没有
skuId需提示用户。
<script setup lang="ts">
// 加入购物按钮点击
const addCartBtn = () => {
// 没有 skuId,提醒用户并退出函数
if (!skuId.value) {
return message({ type: "warn", text: "请选择商品规格~" });
}
// 获取购物车 Store
const cart = useCartStore();
// 调用加入购物车接口
cart.addCart({
skuId: skuId.value,
count: count.value,
});
};
</script>
<XtxButton @click="addCartBtn()" type="primary" size="middle">加入购物车</XtxButton>
头部购物车实现-已登录
1. 基础布局
本节目标: 在网站头部购物车图片处,鼠标经过展示购物车列表
实现步骤
- 提取头部购物车组件,完成基础布局
- 渲染头部购物车组件
代码落地
1)新建头部购物车组件:src/views/Layout/components/app-header-cart.vue
<script setup lang="ts">
//
</script>
<template>
<div class="cart">
<a class="curr" href="javascript:;">
<i class="iconfont icon-cart"></i><em>20</em>
</a>
<div class="layer">
<div class="list">
<div class="item" v-for="i in 4" :key="i">
<RouterLink :to="`/goods/:id`">
<img
src="https://yanxuan-item.nosdn.127.net/ead73130f3dbdb3cabe1c7b0f4fd3d28.png"
alt=""
/>
<div class="center">
<p class="name ellipsis-2">
和手足干裂说拜拜 ingrams手足皲裂修复霜
</p>
<p class="attr ellipsis">颜色:修复绿瓶 容量:150ml</p>
</div>
<div class="right">
<p class="price">¥45.00</p>
<p class="count">x2</p>
</div>
</RouterLink>
<i class="iconfont icon-close-new"></i>
</div>
</div>
<div class="foot">
<div class="total">
<p>共 3 件商品</p>
<p>¥135.00</p>
</div>
<XtxButton type="plain">去购物车结算</XtxButton>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.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.2s 0.1s;
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>
2)使用购物车组件:src/views/Layout/components/header/index.vue
<template>
<header class="app-header">
<div class="container">
<h1 class="logo">
<RouterLink to="/">小兔鲜</RouterLink>
</h1>
<AppHeaderNav />
<div class="search">
<i class="iconfont icon-search"></i>
<input type="text" placeholder="搜一搜" />
</div>
- <!-- 删除简单结构 -->
- <div class="cart">
- <a class="curr" href="#">
- <i class="iconfont icon-cart"></i>
- <em>2</em>
- </a>
- </div>
+ <!-- 购物车 -->
+ <AppHeaderCart />
</div>
</header>
</template>
2. 准备数据
接口:购物车列表
Path: /member/cart
Method: GET
请求参数:无
- 封装接口:
src\store\modules\cart.ts
// 获取购物车列表
const getCartList = async () => {
const res = await http('GET', '/member/cart');
console.log('GET', '/member/cart', res);
};
- 调用接口:
src\views\Layout\components\app-header-cart.vue
<script setup lang="ts">
import { useCartStore } from '@/store';
// 获取购物车Store
const cart = useCartStore();
// 调用接口获取购物车列表
cart.getCartList();
</script>
类型声明文件
- 根据后端返回值,完善类型声明:
src\types\modules\cart.d.ts
// 单个购物车商品
export interface CartItem {
id: string;
skuId: string;
name: string;
attrsText: string;
// specs: any[];
picture: string;
price: string;
nowPrice: string;
nowOriginalPrice: string;
selected: boolean;
stock: number;
count: number;
isEffective: boolean;
// discount?: any;
isCollect: boolean;
postFee: number;
}
// 购物车列表
export type CartList = CartItem[];
- 指定类型
src\store\modules\cart.ts
// 获取购物车列表
+const list = ref<CartList>([]);
const getCartList = async () => {
- const res = await http('GET', '/member/cart');
+ const res = await http<CartList>('GET', '/member/cart');
console.log('GET', '/member/cart', res);
+ list.value = res.data.result;
};
3. 列表渲染
🚨注意事项:
- 列表渲染的时候
key值用skuId,RouterLink跳转的时候用产品id - 购物车列表的
price为加入时价格,最新价格是nowPrice - 加入购物车后,需要主动获取最新列表。
<template>
<div class="cart">
<a class="curr" href="javascript:;">
<i class="iconfont icon-cart"></i><em>2</em>
</a>
<div class="layer">
<div class="list">
+ <div class="item" v-for="item in cart.list" :key="item.skuId">
+ <RouterLink :to="`/product/${item.id}`">
+ <img :src="item.picture" alt="" />
<div class="center">
<p class="name ellipsis-2">
+ {{ item.name }}
</p>
+ <p class="attr ellipsis">{{ item.attrsText }}</p>
</div>
<div class="right">
+ <p class="price">¥{{ item.nowPrice }}</p>
+ <p class="count">x{{ item.count }}</p>
</div>
</RouterLink>
<i class="iconfont icon-close-new"></i>
</div>
</div>
<div class="foot">
<div class="total">
<p>共 3 件商品</p>
<p>¥135.00</p>
</div>
<XtxButton type="plain">去购物车结算</XtxButton>
</div>
</div>
</div>
</template>
- 完善加入购物车后,主动获取服务器最新列表。
// 加入购物车
const addCart = async (data: object) => {
// 已登录情况:调用接口
await http('POST', '/member/cart', data);
// 成功提示
message({ type: 'success', text: '加入购物车成功' });
+ // 主动获取购物车列表
+ getCartList()
};
4. 列表计算
本节目标: 使用
Pinia的computed计算 有效商品列表,有效商品总数,有效商品总金额。
说明:
- 库存动态变化的,必须
stock库存大于 0。 - 商品可能会下架,必须
isEffective有效为 true。
1)使用 computed 得到有效商品列表,总数量, 总价
// 计算出有效商品列表
const effectiveList = computed(() => {
// 库存动态变化的,必须 stock 库存大于 0
// 商品可能会下架,必须 isEffective 有效为 true
return list.value.filter((v) => v.stock > 0 && v.isEffective);
});
// 计算商品总件数
const effectiveListCount = computed(() => {
let sum = 0;
effectiveList.value.forEach((item) => {
sum += item.count;
});
return sum;
});
// 计算商品总钱数
const effectiveListPrice = computed(() => {
let sum = 0;
effectiveList.value.forEach((item) => {
sum += item.count * Number(item.nowPrice);
});
return sum.toFixed(2);
});
2)渲染头部购物车模板
<template>
<div class="cart">
<a class="curr" href="javascript:;">
+ <i class="iconfont icon-cart"></i><em>{{ cart.effectiveListCount }}</em>
</a>
<!-- 显示隐藏的弹层 -->
<div class="layer">
<div class="list">
- <div class="item" v-for="item in cart.list" :key="item.skuId">
+ <div class="item" v-for="item in cart.effectiveList" :key="item.skuId">
<RouterLink :to="`/goods/${item.id}`">
<img :src="item.picture" alt="">
<div class="center">
<p class="name ellipsis-2">{{ item.name }}</p>
<p class="attr ellipsis">{{ item.attrsText }}</p>
</div>
<div class="right">
<p class="price">¥{{ item.nowPrice }}</p>
<p class="count">x{{ item.count }}</p>
</div>
</RouterLink>
<i class="iconfont icon-close-new"></i>
</div>
</div>
<div class="foot">
<div class="total">
+ <p>共 {{ cart.effectiveListCount }} 件商品</p>
+ <p>¥{{ cart.effectiveListPrice }}</p>
</div>
<XtxButton type="plain">去购物车结算</XtxButton>
</div>
</div>
</div>
</template>
5. 删除功能实现
本节目标: 实现头部购物车中的列表删除功能
接口:删除/清空购物车商品
基本信息
Path: /member/cart
Method: DELETE
请求参数
Body
| 名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
|---|---|---|---|---|---|
| ids | string [] | 必须 | SKUID 集合 | item 类型: string |
实现步骤
- Store 中封装删除操作接口
- 在头部购物车进行调用,注意数据类型
- 删除成功后,主动获取购物车最新列表
- 优化:购物车没有数据的时候,不需要渲染列表
代码落地
1) Store 中封装接口:src/store/modules/cart.js
// 删除商品
const deleteCart = async (ids: string[]) => {
// 删除请求,注意 ids 的数据类型
await http('DELETE', '/member/cart', { ids: ids });
// 🔔删除请求发送后,主动获取购物车最新列表
getCartList();
};
2)在头部购物车进行调用:src\views\Layout\components\app-header-cart.vue
<i @click="cart.deleteCart([item.skuId])" class="iconfont icon-close-new"></i>
3)小优化,购物车没有数据的时候,不需要渲染列表
<div class="layer" v-if="cart.effectiveList.length > 0">
列表购物车实现-已登录
1. 路由和组件
本节目标:完成购物车组件基础布局和路由配置与跳转链接。
1)购物车页面组件: src/views/Cart/index.vue
<script setup lang="ts">
//
</script>
<template>
<div class="xtx-cart-page">
<div class="container">
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
<XtxBreadItem>购物车</XtxBreadItem>
</XtxBread>
<div class="cart">
<table>
<thead>
<tr>
<th width="120"><XtxCheckBox>全选</XtxCheckBox></th>
<th width="400">商品信息</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 3" :key="i">
<td><XtxCheckBox :model-value="true" /></td>
<td>
<div class="goods">
<RouterLink to="/">
<img
src="https://yanxuan-item.nosdn.127.net/13ab302f8f2c954d873f03be36f8fb03.png"
alt=""
/>
</RouterLink>
<div>
<p class="name ellipsis">
和手足干裂说拜拜 ingrams手足皲裂修复霜
</p>
<p class="attr">商品规格</p>
</div>
</div>
</td>
<td class="tc">
<p>¥200.00</p>
</td>
<td class="tc">
<XtxCount :model-value="1" />
</td>
<td class="tc"><p class="f16 red">¥200.00</p></td>
<td class="tc">
<p><a href="javascript:;">移入收藏夹</a></p>
<p><a class="green" href="javascript:;">删除</a></p>
<p><a href="javascript:;">找相似</a></p>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 操作栏 -->
<div class="action">
<div class="batch"></div>
<div class="total">
共 7 件有效商品,已选择 2 件,商品合计:
<span class="red">¥400</span>
<XtxButton type="primary">下单结算</XtxButton>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.tc {
text-align: center;
.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;
}
.xtx-cart-page {
.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;
}
}
}
}
</style>
2)购物车页面路由:src\router\index.ts
{
path: '/cart',
component: () => import('@/views/Cart/index.vue')
}
- 点击购物车按钮跳转:
src\views\Layout\components\app-header-cart.vue
<RouterLink to="/cart" class="curr">
<i class="iconfont icon-cart"></i><em>{{ cart.effectiveListCount }}</em>
</RouterLink>
<XtxButton @click="$router.push('/cart')" type="plain">去购物车结算</XtxButton>
- 条件隐藏头部购物车:
src\views\Layout\components\app-header-cart.vue
<div
class="layer"
v-if="effectiveList.length > 0"
+ :hidden="$route.path === '/cart'"
>
2.列表数据展示
本节目标:实现购物车商品列表展示功能
- 渲染有效商品
- 最新价格是
nowPrice - 计数器需要设置最大库存值
max - 价格小计需要
toFixed(2)保留两位小数,避免出现精度异常
<!-- 有效商品 -->
<tbody>
+ <tr v-for="goods in cart.effectiveList" :key="goods.skuId">
+ <td><XtxCheckBox :model-value="goods.selected" /></td>
<td>
<div class="goods">
+ <RouterLink :to="`/product/${goods.id}`">
+ <img :src="goods.picture" :alt="goods.name" />
</RouterLink>
<div>
+ <p class="name ellipsis">{{ goods.name }}</p>
+ <p class="attr">{{ goods.attrsText }}</p>
</div>
</div>
</td>
<td class="tc">
+ <p>¥{{ goods.nowPrice }}</p>
</td>
<td class="tc">
+ <XtxCount :model-value="goods.count" :max="goods.stock" />
</td>
<td class="tc">
<p class="f16 red">
+ ¥{{ (Number(goods.nowPrice) * goods.count).toFixed(2) }}
</p>
</td>
<td class="tc">
<p><a class="green" href="javascript:;">删除</a></p>
</td>
</tr>
</tbody>
3. 删除操作实现
本节目标:实现商品删除功能
思路分析
- 点击删除按钮记录当前点击的商品
skuId - 调用 action 函数实现删除即可。
代码落地
删除按钮绑定事件触发删除action函数
<p><a @click="cart.deleteCart([goods.skuId])" class="green" href="javascript:;">删除</a>
- 删除所有商品后,显示空状态占位
<!-- 删除所有商品后,显示空状态占位 -->
<tr>
<td colspan="6">
<div class="cart-none" style="text-align: center">
<img src="@/assets/images/none.png" alt="" />
<p>购物车内暂时没有商品</p>
<div class="btn" style="margin: 20px">
<XtxButton type="primary">
继续逛逛
</XtxButton>
</div>
</div>
</td>
</tr>
4. 单选操作实现🚨
本节目标: 实现的商品单选功能
从单选开始,我们进入到一个 Pinia + 表单数据的交互功能实现。
我们不是简单的通过 v-model 修改本地状态,而是数据变化时,发送请求让后端实现更新。
vue3 中 v-model 语法糖可拆分为 :model-value 和 @update:model-value 两部分使用。
思路分析
- 获取小选框的
selected的最新状态,通过@update:model-value获取 - 点击小选框的时候,还要获取商品
skuId,可通过$event实现传递多个参数。 - 调用接口让后端修改购物车商品信息。
接口:修改购物车商品
Path: /member/cart/:id
Method: PUT
请求参数
路径参数
| 参数名称 | 示例 | 备注 |
|---|---|---|
| id | SKUID |
Body
| 名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
|---|---|---|---|---|---|
| selected | boolean | 非必须 | 是否选中 | ||
| count | integer | 非必须 | 数量 |
代码落地
1)Store 中添加接口:src\store\modules\cart.ts
// 修改购物车商品(是否选中,商品数量)
const updateCart = async (skuId: string, data: object) => {
// 已登录,调用接口
await http('PUT', `/member/cart/${skuId}`, data);
// 主动获取最新列表
getCartList();
};
2)购物车单选按钮调用接口:src\views\Cart\index.vue
<XtxCheckBox
:model-value="item.selected"
@update:model-value="cart.updateCart(item.skuId, { selected: $event })"
/>
5. 修改数量实现
本节目标:实现商品数量修改功能
思路分析
-
🔔温馨提醒:XtxUI 组件库为了方便开发者使用,提供两种自定义事件获取值
@update:model-value和@change功能是等价的。 -
参考单选操作实现。
代码落地
2)绑定事件触发 action
<td class="tc">
<XtxCount
:max="goods.stock"
:model-value="goods.count"
@change="cart.updateCart(item.skuId, { count: $event })"
/>
</td>
6. 全选切换实现🚨🚨
本节目标:实现商品全选切换功能。
接口:购物车全选/取消全选
Path: /member/cart/selected
Method: PUT
接口描述:
ids参数如果不传,表示用户访问的是全选和取消全选操作,后端根据 selected 确定用户是全选和取消全选
请求参数
Body
| 名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
|---|---|---|---|---|---|
| selected | boolean | 必须 | 是否选中 | ||
| ids | string [] | 非必须 | skuId集合 | item 类型: string | |
| ├─ | 非必须 | skuId |
思路分析
- 通过
getters计算出选中状态 (注意:Pinia的getters没有set) - 封装调用修改全选接口的
actions。 - 实现全选效果:
:model-value和@update:model-value组合使用。
代码落地
1)在 src\store\modules\cart.ts 中封装 actions 和 getters 。
// 计算全选状态
const isAllSelected = computed(() => {
return (
effectiveList.value.length > 0 &&
effectiveList.value.every((v) => v.selected)
);
});
// 购物车全选/取消全选
const updateCartAllSelected = async (selected: boolean) => {
// 已登录,调用接口
await http('PUT', '/member/cart/selected', { selected });
// 获取购物车列表
getCartList();
};
2)页面中调用
<XtxCheckBox
:model-value="cart.isAllSelected"
@change="cart.updateCartAllSelected(!cart.isAllSelected)"
>
全选
</XtxCheckBox>
7. 操作栏数据计算和渲染
// 已选择的列表
const selectedList = computed(() => {
return effectiveList.value.filter((item) => item.selected);
});
// 已选择的商品总件数
const selectedListCount = computed(() => {
return selectedList.value.reduce((sum, item) => {
return sum + item.count
}, 0);
});
// 已选择的列表总钱数
const selectedListPrice = computed(() => {
return selectedList.value
.reduce((sum, item) => sum + item.count * Number(item.nowPrice), 0)
.toFixed(2);
});
<!-- 操作栏 -->
<div class="action">
<div class="total">
共 {{ cart.effectiveListCount }} 件有效商品,已选择 {{ cart.selectedListCount }} 件,有效商品合计:
<span class="red">¥{{ cart.selectedListPrice }}</span>
<XtxButton type="primary">下单结算</XtxButton>
</div>
</div>
【转折点】退出登录
本节目标: 退出登录,为实现未登录版购物车做准备。
问题思考
- 思考1:退出登录后,还能看到购物车数据,要怎么处理?
- 退出登录后,要清空 Store 中购物车列表数据。
- 思考2:退出登录后,直接调用购物车系列接口会报错,怎么办?
- 调用接口前,需判断是否已登录。
- 思考3:如何判断用户登录还是未登录,要如何处理??
- 获取
member模块的是否登录即可。
- 获取
- 思考4:根据上面需求,该如何设计程序?
- 给操作购物车数据的
actions内部都添加判断,已登录调用接口,未登录本地操作。
- 给操作购物车数据的
退出登录清空购物车
购物车模块:src\store\modules\cart.ts
// 清空购物车
const clearCart = () => {
// 退出登录需清空购物车
list.value = [];
}
会员中心模块:src\store\modules\member.ts
// 退出登录
const logout = () => {
// ...省略其他代码
// 清空购物车
const cart = useCartStore();
cart.clearCart();
};
已登录和未登录程序设计
- 所有购物车接口,都要先判断用户已登录(有token),再调用购物车接口。
+ import { useMemberStore } from './member';
export const useCartStore = defineStore('cart', () => {
+ // 获取会员 Store
+ const member = useMemberStore();
// 加入购物车
const addCart = async (data: object) => {
+ // 判断是否已登录
+ if (member.isLogin) {
// 已登录情况:调用接口
const res = await http('POST', '/member/cart', data);
console.log('POST', '/member/cart', res);
+ } else {
+ console.log('🟠未登录:本地版加入购物车-相当于高级版 todos');
+ }
// 成功提示
message({ type: 'success', text: '加入购物车成功' });
// 加入成功后,主动获取最新购物车列表
getCartList();
};
// 获取购物车列表
const getCartList = async () => {
+ // 判断是否已登录
+ if (member.isLogin) {
const res = await http<CartList>('GET', '/member/cart');
console.log('GET', '/member/cart', res);
list.value = res.data.result;
+ } else {
+ console.log('🟢未登录:本地版查询列表');
+ }
};
});
购物车操作-未登录
1. 加入购物车🚨🚨🚨
本节目标:实现加入购物车业务。
实现步骤
- 点击加入购物车的时候,从商品详情中收集购物车商品展示所需数据。(🚨🚨字段很多,操作小心)
- actions 中完成添加操作(未登录)。
落地代码
商品详情页:src\views\Goods\index.vue
<script setup lang="ts">
...
// 选中的商品规格文本
const specsText = ref("");
// 加入购物按钮点击
const addCart = () => {
// 没有 skuId,提醒用户并退出函数
if (!skuId.value) {
return message({ type: "warn", text: "请选择商品规格~" });
}
// 🚨🚨 高级版todos而已,但是数据收集字段名很多坑,小心操作,容易出错
if (!goodsDetail.value) return;
const cartItem = {
// 第一部分:商品详情中有的
id: goodsDetail.value.id, // 商品id
name: goodsDetail.value.name, // 商品名称
picture: goodsDetail.value.mainPictures[0], // 图片
price: goodsDetail.value.oldPrice, // 旧价格
nowPrice: goodsDetail.value.price, // 新价格
stock: goodsDetail.value.inventory, // 库存
// 第二部分:商品详情中没有的,自己通过响应式数据收集
count: count.value, // 商品数量
skuId: skuId.value, // skuId
attrsText: specsText.value, // 商品规格文本
// 第三部分:设置默认值即可
selected: true, // 默认商品选中
isEffective: true, // 默认商品有效
} as CartItem; // 📌 as 断言防止类型报错
console.log('📌cartItem 数据终于准备完毕了', cartItem);
// 调用加入购物车接口
cart.addCart(cartItem);
};
</script>
购物车模块:src/store/modules/cart.ts
// 获取会员 Store
const member = useMemberStore();
// 加入购物车 object 修改成 CartItem,类型更精确
const addCart = async (data: CartItem) => {
if (member.isLogin) {
// 解构出后端接口所需的两个参数
const { count, skuId } = data;
// 已登录情况:调用接口
const res = await http('POST', '/member/cart', { count, skuId });
console.log('POST', '/member/cart', res);
} else {
// 未登录:本地操作,判断当前商品是否已存在
const index = list.value.findIndex((v) => v.skuId === data.skuId);
// 已存在:数量叠加
if (index > -1) {
list.value[index].count += data.count;
} else {
// 不存在:数组前添加
list.value.unshift(data);
}
}
// 成功提示
message({ type: 'success', text: '加入购物车成功' });
// 加入成功后,主动获取最新购物车列表
getCartList();
};
2. 购物车列表持久化存储
本节目标:实现Pinia购物车列表的持久化存储。
实现步骤
- 前面我们已经安装了
Pinia持久化存储插件,在模块中开启插件功能即可。
代码落地
src/store/modules/cart.ts
{
// 🔔购物车 state/ref 持久化存储
persist: true,
}
步骤验证:开启持久化设置后,打开浏览器本地存储面板检查是否生效。
3. 删除购物车
本节目标:实现删除操作。
实现步骤
- actions 中完成删除操作(未登录)。
落地代码
完善分支判断,实现删除业务:src/store/modules/cart.ts
// 🔔以下代码添加到:未登录分支内
list.value = list.value.filter((v) => !ids.includes(v.skuId));
4. 选中状态切换&修改数量
本节目标: 实现商品选中状态的切换和修改商品数量。
实现步骤
- actions 中完成修改操作(未登录)。
- 注意点:由于修改数量和修改选择状态都是同一个 action 完成,所以本地修改前需要判断。
代码落地
完善业务:src/store/modules/cart.ts
🚨注意:需要把 data: object 类型升级成 data: { selected?: boolean; count?: number; }
// 🔔以下代码添加到:未登录分支内
// 根据 skuId 查找待修改的商品项
const index = list.value.findIndex((v) => v.skuId === skuId);
// 解构出需要修改的字段,需升级 data 类型,否则无法从 object 中解构出字段
const { selected, count } = data as { selected?: boolean; count?: number; };
// 由于修改数量和修改选择状态都是同一个 action 完成,所以本地修改前需要判断
if (count !== undefined) list.value[index].count = count;
if (selected !== undefined) list.value[index].selected = selected;
5. 全选切换-本地
本节目标:实现商品选中状态的切换。
实现步骤
- actions 中完成全选切换操作(未登录)。
代码落地
完善业务:src/store/modules/cart.ts
// 🔔以下代码添加到:未登录分支内
list.value.forEach((item) => {
item.selected = selected;
});
6. 更新本地购物车商品关键信息🚨🚨
本节目标: 未登录情况下,更新本地购物车商品关键信息。
原因解释:本地存储的库存信息和价格不是服务器最新的,所以需要主动更新最新商品关键信息。
关键信息主要包括:商品最新价格,商品最新库存,商品是否还有效。
接口:查询商品库存价格信息
Path: /goods/stock/:id
Method: GET
请求参数
路径参数
| 参数名称 | 示例 | 备注 |
|---|---|---|
| id | 1352956998412406785 | SKU_ID |
实现思路
- 获取购物车列表的 action 中,未登录情况 下主动获取商品库存价格。
- 购物车的商品库存,价格,是否有效,更新成最新的。
代码落地
获取商品列表信息,完善业务:src/store/modules/cart.ts
// 🔔以下代码添加到:未登录分支内
// 🚨未登录情况,增删改 操作也要同步 最新价格,库存,有效状态
list.value.forEach(async (item) => {
// 根据 skuId 获取最新商品信息
const res = await http<CartItem>('GET', `/goods/stock/${item.skuId}`);
// 保存最新商品信息
const newCartItemInfo = res.data.result;
// 更新商品现价
item.nowPrice = newCartItemInfo.nowPrice;
// 更新商品库存
item.stock = newCartItemInfo.stock;
// 更新商品是否有效
item.isEffective = newCartItemInfo.isEffective;
});
两个版本购物车同步
1. 登录后合并购物车🚨🚨
本节目标: 登录后把本地的购物车数据合并到后端服务
本地已经存好的所有购物车列表数据都需要和服务器合并一下。
实现思路
-
编写并调用 合并购物车的
action函数 (将对于购物车的处理,统一到Pinia中)思考:在哪调用 合并购物车的
action函数?答:登录完成后。
接口:合并购物车
基本信息
Path: /member/cart/merge
Method: POST
请求参数
Body
| 名称 | 类型 | 是否必须 | 默认值 | 备注 | 其他信息 |
|---|---|---|---|---|---|
| object [] | 必须 | 购物车sku集合 | item 类型: object | ||
| ├─ skuId | string | 必须 | skuId | ||
| ├─ selected | boolean | 必须 | 是否选中 | ||
| ├─ count | integer | 必须 | 数量 |
登录后合并购物车
落地代码
1)编写合并购物车的 actions 函数
- 映射接口所需参数
- 调用合并购物车的接口
- 主动获取最新列表
购物车模块:src/store/modules/cart.ts
// 合并购物车
const mergeCart = async () => {
// 映射接口所需参数
const data = list.value.map(({ skuId, selected, count }) => ({
skuId,
selected,
count,
}));
// 调用合并购物车的接口
await http('POST', '/member/cart/merge', data);
// 合并成功,重新获取购物车列表
getCartList();
};
2)在登录完成功后,调用合并购物车 actions 函数
用户模块:src\store\modules\member.ts
// 把登录成功后续逻辑封装成通用函数
const loginSuccess = (result: Profile) => {
// ...省略其他登录成功代码
// 合并购物车
const cart = useCartStore();
cart.mergeCart();
};