危险

为之则易,不为则难

0%

11_购物车

今日目标

✔ 购物车。

购物车功能分析

image-20220226233037218

思路流程

  1. 购物车的各种操作都会有两种状态的区分,登录和未登录。

  2. 所有操作都封装到 Pinia 中,组件只需要触发 actions 函数。

  3. 在 actions 中通过 user 信息去区分登录状态。

    1. 已登录,通过调用接口去服务端操作,响应成功会通过 actions 修改 Pinia 中的数据即可。

    2. 未登录时,通过 actions 修改 Pinia 中的数据即可, Pinia 实现持久化,同步保存在本地。

首页购物车-已登录

准备购物车 Store

目标:为购物车业务定义专属的 Store,多个组件都要用到购物车数据,store/modules/cart.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { defineStore } from 'pinia'

const useCartStore = defineStore('cart', {
// 状态
state() {
return {
list: [], // 购物车数组
}
},
// 计算
getters: {},
// 方法
actions: {},
})

export default useCartStore

合并购物车模块,src/store/index.ts

1
2
3
4
5
6
import useCartStore from './modules/cart'
export default function useStore() {
return {
cart: useCartStore(),
}
}

加入购物车-校验

  1. 给加入购物车按钮注册点击事件,views/goods/index.vue
1
<XtxButton type="primary" style="margin-top:20px;" @click="addCart"> 加入购物车 </XtxButton>
1
2
3
const addCart = () => {
console.log('添加购物车')
}
  1. 当全选了就传递对应的 skuId,否则传空字符串,views/goods/components/goods-sku.vue
1
2
3
4
5
6
7
if (selected.length === props.goods.specs.length) {
const key = selected.join('★')
const [skuId] = pathMap[key]
emit('changeSku', skuId)
} else {
emit('changeSku', '')
}
  1. 修改 changeSku 逻辑,记录选中的 sku。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 保存 skuId
const currentSkuId = ref('')
const changeSku = (skuId: string) => {
// 1. 根据接收到的 skuId 找到对应的 sku
// 2. 修改商品的价钱库存
currentSkuId.value = skuId
const sku = info.value.skus.find((item) => item.id === skuId)
if (sku) {
info.value.price = sku.price
info.value.oldPrice = sku.oldPrice
info.value.inventory = sku.inventory
}
}
  1. 判断 sku 是否完整。
1
2
3
4
5
6
7
const addCart = () => {
// 判断是否是完整的sku
if (!currentSkuId.value) {
return Message.warning('请选择完整信息')
}
console.log('加入购物车')
}

加入购物车

目标: 完成商品详情的添加购物车操作。

image-20220227200120916

实现步骤

  1. actions 中封装加入购物车的接口。

  2. 在商品详情页实现添加逻辑触发 actions 函数调用接口。

核心代码

  1. 封装 action,store/modules/cart.ts
1
2
3
async addCart(data: { skuId: string; count: number }) {
await request.post('/member/cart', data)
}
  1. 点击按钮调用接口,views/goods/index.vue
1
2
3
4
5
6
7
8
9
10
11
const count = ref(1)
const addCart = () => {
// 判断是否是完整的sku
if (!currentSkuId.value) {
return Message.warning('请选择完整信息')
}
cart.addCart({
skuId: currentSkuId.value,
count: count.value,
})
}

src/utils/request.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
instance.interceptors.request.use(
function (config) {
const { user } = useStore()
if (user.profile.token) {
config.headers!.Authorization = `Bearer ${user.profile.token}`
}
// 在发送请求之前做些什么
return config
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error)
}
)

头部购物车结构

目标:在网站头部购物车图片处,鼠标经过展示购物车列表。

  1. 新建头部购物车组件,src/views/layout/components/app-header-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
<script setup lang="ts" name="AppHeaderCart"></script>

<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="i in 4" :key="i">
<RouterLink to="">
<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">&yen;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>&yen;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>
  1. 使用购物车组件,src/views/Layout/components/app-header.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script lang="ts" setup name="AppHeader">
import AppHeaderNavVue from './app-header-nav.vue'
// #1
import AppHeaderCart from './app-header-cart.vue'
</script>

<template>
<header class="app-header">
<div class="container">
<h1 class="logo"><RouterLink to="/">小兔鲜</RouterLink></h1>
<AppHeaderNavVue />
<div class="search">
<i class="iconfont icon-search"></i>
<input type="text" placeholder="搜一搜" />
</div>
<!-- !#2 -->
<AppHeaderCart />
</div>
</header>
</template>

头部购物车渲染

  1. 提供购物车的数据类型,types/cart.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 单个购物车商品
export interface CartItem {
id: string
skuId: string
name: string
attrsText: string
picture: string
price: string
nowPrice: string
nowOriginalPrice: string
selected: boolean
stock: number
count: number
isEffective: boolean
// discount?: any;
isCollect: boolean
postFee: number
}
  1. 新增 action,用于获取购物车列表数据,store/modules/cart.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import request from '@/utils/request'
import { defineStore } from 'pinia'
import { CartItem } from '@/types/cart'
import { ApiRes } from '@/types/data'
const useCartStore = defineStore({
id: 'cart',
// 状态
state: () => ({
// 购物车列表
list: [] as CartItem[],
}),
// 方法
actions: {
// 获取购物车列表
async getCartList() {
const res = await request.get<ApiRes<CartItem[]>>('/member/cart')
this.list = res.data.result
},
},
})

export default useCartStore
  1. 提供计算属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 计算
getters: {
// 计算有效商品列表 isEffective = true filter
effectiveList(): CartItem[] {
return this.list.filter((item) => item.stock > 0 && item.isEffective)
},
// 有效商品总数量 把effctiveList中的每一项的count叠加起来
effectiveListCounts(): number {
return this.effectiveList.reduce((sum, item) => sum + item.count, 0)
},
// 总钱数 = 所有单项的钱数累加 单项的钱数 = 数量 * 单价
effectiveListPrice(): string {
return this.effectiveList
.reduce((sum, item) => sum + item.count * Number(item.nowPrice), 0)
.toFixed(2)
}
},
  1. 渲染头部购物车,views/layout/components/app-header-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
<script setup lang="ts" name="AppHeaderCart">
import useStore from '@/store'
const { cart } = useStore()
cart.getCartList()
</script>

<template>
<div class="cart">
<a class="curr" href="javascript:;"> <i class="iconfont icon-cart"></i><em>{{ cart.effectiveListCounts }}</em> </a>
<div class="layer">
<div class="list">
<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">&yen;{{ item.price }}</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.effectiveListCounts }} 件商品</p>
<p>&yen;{{ cart.effectiveListPrice }}</p>
</div>
<XtxButton type="plain">去购物车结算</XtxButton>
</div>
</div>
</div>
</template>

加入购物车优化

  1. 发送请求添加成功后,需要重新渲染。

store/modules/cart.ts

1
2
3
4
async addCart(data: { skuId: string; count: number }) {
await request.post('/member/cart', data)
this.getCartList()
}
  1. 提示消息,views/goods/index.vue
1
2
3
4
5
6
7
const addCart = async () => {
if (!currentSkuId) {
return Message.warning('请选择完整的商品信息')
}
await cart.addCart(currentSkuId, count.value)
Message.success('添加成功')
}

删除功能实现

  1. Pinia 的 action 代码,src/store/modules/cart.ts
1
2
3
4
5
6
async deleteCart(skuIds: string[]) {
await request.delete('/member/cart', {
data: { ids: skuIds }
})
this.getCartList()
}
  1. 注册事件,views/layout/components/app-header-cart.vue
1
<i class="iconfont icon-close-new" @click="cart.deleteCart([item.skuId])"></i>
  1. 小优化:购物车没有数据的时候或者当前页就在购物车页面,不需要显示移入鼠标效果。

views/layout/components/app-header-cart.vue

1
<div class="layer" v-if="cart.effectiveList.length && $route.path !== '/cart'"></div>

确认框组件封装

image-20220227220604125

目标:封装一个 confirm 弹出确认框,大致步骤。

  1. 实现组件基础结构和样式。

  2. 实现函数式调用组件方式和完成交互。

  3. 加上打开时动画效果。

  4. 基于 Promise 进行封装

  5. 给退出功能加上确认框。

基本结构

  1. 新建文件,src/components/confirm/confirm.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
<script lang="ts" setup name="XtxConfirm">
defineProps({
title: {
type: String,
default: '',
},
text: {
type: String,
required: true,
},
})
</script>
<template>
<div class="xtx-confirm">
<div class="wrapper">
<div class="header">
<h3>温馨提示</h3>
<a href="JavaScript:;" class="iconfont icon-close-new"></a>
</div>
<div class="body">
<i class="iconfont icon-warning"></i>
<span>您确认从购物车删除该商品吗?</span>
</div>
<div class="footer">
<XtxButton size="mini" type="gray">取消</XtxButton>
<XtxButton size="mini" type="primary">确认</XtxButton>
</div>
</div>
</div>
</template>

<style scoped lang="less">
.xtx-confirm {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 8888;
background: rgba(0, 0, 0, 0.5);
.wrapper {
width: 400px;
background: #fff;
border-radius: 4px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.header,
.footer {
height: 50px;
line-height: 50px;
padding: 0 20px;
}
.body {
padding: 20px 40px;
font-size: 16px;
.icon-warning {
color: @priceColor;
margin-right: 3px;
font-size: 16px;
}
}
.footer {
text-align: right;
.xtx-button {
margin-left: 20px;
}
}
.header {
position: relative;
h3 {
font-weight: normal;
font-size: 18px;
}
a {
position: absolute;
right: 15px;
top: 15px;
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
color: #999;
&:hover {
color: #666;
}
}
}
}
}
</style>
  1. 测试,views/playground/index.vue
1
2
3
4
5
6
7
<script lang="ts" setup name="PlayGround">
import Confirm from '@/components/confirm/confirm.vue'
</script>

<template>
<Confirm title="温馨提示" text="您确定删除该商品吗?" />
</template>

封装成函数-基础

  1. 新建文件,src/components/confirm/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
import { h, render } from 'vue'
import XtxConfirm from './confirm.vue'
type Props = { text: string; title?: string }

const div = document.createElement('div')
div.setAttribute('class', 'xtx-confirm-container')
document.body.appendChild(div)

export default function Confirm({ text, title }: Props) {
const vNode = h(XtxConfirm, { text, title })
render(vNode, div)
}
  1. 测试,views/playground/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script lang="ts" setup name="PlayGround">
import Confirm from '@/components/confirm'

const show = () => {
Confirm({
text: '你确定要退出吗',
title: '温馨提示',
})
}
</script>

<template>
<button @click="show">显示</button>
</template>
  1. 由于 XtxButton 组件超出了 App 的范围,因此不生效,需要手动导入。
1
2
3
<script lang="ts" setup name="XtxConfirm">
import XtxButton from '../button/index.vue'
</script>

封装成函数-组件交互

image-20220227222408933

  1. 修改函数,components/confirm/index.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
26
27
28
import { h, render } from 'vue'
import XtxConfirm from './confirm.vue'
type Props = { text: string; title?: string }

const div = document.createElement('div')
div.setAttribute('class', 'xtx-confirm-container')
document.body.appendChild(div)

export default function Confirm({ text, title }: Props) {
// #mark
return new Promise((resolve, reject) => {
const confirmCallback = () => {
render(null, div)
resolve(undefined)
}
const cancelCallback = () => {
render(null, div)
reject(undefined)
}
const vNode = h(XtxConfirm, {
title,
text,
confirmCallback,
cancelCallback,
})
render(vNode, div)
})
}
  1. 修改组件,components/confirm/confirm.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
<script lang="ts" setup name="XtxConfirm">
import { PropType } from 'vue'
import XtxButton from '../button/index.vue'
defineProps({
title: {
type: String,
default: '',
},
text: {
type: String,
required: true,
},
// #1
confirmCallback: {
type: Function as PropType<() => void>,
},
cancelCallback: {
type: Function as PropType<() => void>,
},
})
</script>
<template>
<div class="xtx-confirm">
<div class="wrapper">
<div class="header">
<h3>{{ title }}</h3>
<a href="JavaScript:;" class="iconfont icon-close-new"></a>
</div>
<div class="body">
<i class="iconfont icon-warning"></i>
<span>{{ text }}</span>
</div>
<div class="footer">
<!-- #2 -->
<XtxButton size="mini" type="gray" @click="cancelCallback"> 取消 </XtxButton>
<XtxButton size="mini" type="primary" @click="confirmCallback"> 确认 </XtxButton>
</div>
</div>
</div>
</template>

components/button/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
<script lang="ts" setup name="XtxButton">
import { PropType } from 'vue'

const emit = defineEmits<{
(evName: 'click', e: MouseEvent): void
}>()

defineProps({
size: {
type: String as PropType<'large' | 'middle' | 'small' | 'mini'>,
default: 'middle',
},
type: {
type: String as PropType<'default' | 'primary' | 'plain' | 'gray'>,
default: 'default',
},
})
</script>
<template>
<button class="xtx-button ellipsis" :class="[size, type]" @click="emit('click', $event)">
<slot />
</button>
</template>
  1. 使用,views/playground/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script lang="ts" setup name="PlayGround">
import Confirm from '@/components/confirm'
const show = () => {
Confirm({
title: '温馨提示',
text: '你确定要删除',
})
.then(() => {
console.log('点击了确定')
})
.catch(() => {
console.log('点击了取消')
})
}
</script>

<template>
<button @click="show">显示</button>
</template>

动画处理

第一种方法

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
<script lang="ts" setup name="XtxConfirm">
import { PropType, ref, onMounted } from 'vue'
import XtxButton from '../button/index.vue'
// #1
const isShow = ref(false)
// #2
onMounted(() => (isShow.value = true))
defineProps({
title: {
type: String,
default: '',
},
text: {
type: String,
required: true,
},
confirmCallback: {
type: Function as PropType<() => void>,
},
cancelCallback: {
type: Function as PropType<() => void>,
},
})
</script>
<template>
<!-- #3 -->
<Transition name="confirm">
<!-- #4 -->
<div class="xtx-confirm" v-if="isShow">
<div class="wrapper">
<div class="header">
<h3>{{ title }}</h3>
<a href="JavaScript:;" class="iconfont icon-close-new"></a>
</div>
<div class="body">
<i class="iconfont icon-warning"></i>
<span>{{ text }}</span>
</div>
<div class="footer">
<XtxButton size="mini" type="gray" @click="cancelCallback"> 取消 </XtxButton>
<XtxButton size="mini" type="primary" @click="confirmCallback"> 确认 </XtxButton>
</div>
</div>
</div>
</Transition>
</template>
<style scoped lang="less">
// #5
.confirm-enter-from {
opacity: 0;
}
.confirm-enter-active {
transition: all 0.5s;
}
.confirm-enter-to {
opacity: 1;
}
// ...
</style>

第二种方法

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
<script lang="ts" setup name="XtxConfirm">
import { PropType, ref, onMounted } from 'vue'
import XtxButton from '../button/index.vue'
defineProps({
title: {
type: String,
default: '',
},
text: {
type: String,
required: true,
},
confirmCallback: {
type: Function as PropType<() => void>,
},
cancelCallback: {
type: Function as PropType<() => void>,
},
})
// #5
const show = ref(false)
onMounted(() => {
setTimeout(() => {
show.value = true
}, 20)
})
</script>
<template>
<!-- #6 -->
<div class="xtx-confirm" :class="{ fade: show }">
<div class="wrapper" :class="{ fade: show }">
<div class="header">
<h3>{{ title }}</h3>
<a href="JavaScript:;" class="iconfont icon-close-new"></a>
</div>
<div class="body">
<i class="iconfont icon-warning"></i>
<span>{{ text }}</span>
</div>
<div class="footer">
<XtxButton size="mini" type="gray" @click="cancelCallback"> 取消 </XtxButton>
<XtxButton size="mini" type="primary" @click="confirmCallback"> 确认 </XtxButton>
</div>
</div>
</div>
</template>

<style scoped lang="less">
.xtx-confirm {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 8888;
// #1
background: rgba(0, 0, 0, 0);
// #2
&.fade {
transition: all 0.4s;
background: rgba(0, 0, 0, 0.5);
}
.wrapper {
width: 400px;
background: #fff;
border-radius: 4px;
position: absolute;
top: 50%;
left: 50%;
// #3
transform: translate(-50%, -60%);
opacity: 0;
// #4
&.fade {
transition: all 0.4s;
transform: translate(-50%, -50%);
opacity: 1;
}
.header,
.footer {
height: 50px;
line-height: 50px;
padding: 0 20px;
}
.body {
padding: 20px 40px;
font-size: 16px;
.icon-warning {
color: @priceColor;
margin-right: 3px;
font-size: 16px;
}
}
.footer {
text-align: right;
.xtx-button {
margin-left: 20px;
}
}
.header {
position: relative;
h3 {
font-weight: normal;
font-size: 18px;
}
a {
position: absolute;
right: 15px;
top: 15px;
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
color: #999;
&:hover {
color: #666;
}
}
}
}
}
</style>

购物车页面-已登录

购物车页面-路由与基本结构

目标:完成购物车组件基础布局和路由配置与跳转链接。

image-20220227204058941

  1. 新建购物车页面组件,src/views/cart/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
<script setup lang="ts" name="Cart">
//
</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 /></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>&yen;200.00</p>
</td>
<td class="tc">
<XtxNumbox :model-value="1" />
</td>
<td class="tc"><p class="f16 red">&yen;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>
  1. 准备路由,router/index.ts
1
2
3
4
{
path: '/cart',
component: () => import('@/views/cart/index.vue')
}
  1. 点击购物车按钮跳转,views/layout/components/app-header-cart.vue
1
2
3
<RouterLink to="/cart" class="curr"> <i class="iconfont icon-cart"></i><em>{{ cart.effectiveListCounts }}</em> </RouterLink>

<XtxButton @click="$router.push('/cart')" type="plain">去购物车结算</XtxButton>

购物车页面-渲染列表

目标:实现购物车商品列表展示功能。

views/cart/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
<script setup lang="ts" name="Cart">
import useStore from '@/store'

const { cart } = useStore()
</script>
<template>
<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="item in cart.effectiveList" :key="item.skuId">
<td><XtxCheckbox :model-value="item.selected" /></td>
<td>
<div class="goods">
<RouterLink :to="`/goods/${item.id}`">
<img :src="item.picture" alt="" />
</RouterLink>
<div>
<p class="name ellipsis">{{ item.name }}</p>
<p class="attr">{{ item.attrsText }}</p>
</div>
</div>
</td>
<td class="tc">
<p>&yen;{{ item.nowPrice }}</p>
</td>
<td class="tc">
<XtxNumbox :model-value="item.count" :max="item.stock" />
</td>
<td class="tc">
<p class="f16 red">&yen;{{ (Number(item.nowPrice) * item.count).toFixed(2) }}</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>
</template>

购物车页面-删除操作

目标:实现商品删除功能。

思路分析

  1. 点击删除按钮记录当前点击的商品 skuId。

  2. 调用 action 函数实现删除即可。

代码落地

  1. 删除按钮绑定事件触发删除 action 函数,views/cart/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup lang="ts" name="Cart">
import useStore from '@/store'
import Confirm from '@/components/confirm'
import Message from '@/components/message'

const { cart } = useStore()
const delCart = async (skuIds: string[]) => {
await Confirm({
title: '温馨提示',
text: '你确定要删除吗?',
})
await cart.deleteCart(skuIds)
Message.success('删除成功')
}
</script>
<template>
<p>
<a @click="delCart([item.skuId])" class="green" href="javascript:;">删除</a>
</p>
</template>
  1. 删除光购物车之后使用元素占位。
1
2
3
4
5
6
7
8
9
10
11
12
<!-- 删除光购物车之后使用元素占位 -->
<tr v-if="cart.effectiveList.length === 0">
<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>

购物车页面-单选操作

目标:实现的商品单选功能。

从单选开始,我们进入到一个 Pinia + 表单数据的交互功能实现。

image-20220227211742692

  1. 封装修改商品的接口,store/modules/cart.ts
1
2
3
4
5
async updateCart(skuId: string,data: { selected?: boolean; count?: number }) {
await request.put(`/member/cart/${skuId}`, data)
// 重新获取最新购物车列表
this.getCartList()
}
  1. 组件中注册点击事件,views/cart/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts" name="Cart">
import useStore from '@/store'
const { cart } = useStore()

const changeSelected = async (skuId: string, checked: boolean) => {
await cart.updateCart(skuId, {
selected: checked,
})
Message.success('修改状态成功')
}
</script>

<template>
<XtxCheckbox :model-value="item.selected" @update:modelValue="changeSelected(item.skuId, $event)" />
</template>

购物车页面-修改数量

views/cart/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
<script setup lang="ts" name="Cart">
const changeCount = (skuId: string, count: number) => {
cart.updateCart(skuId, {
count,
})
}
</script>
<template>
<td class="tc">
<XtxNumbox :model-value="item.count" :max="item.stock" @update:model-value="changeCount(item.skuId, $event)" />
</td>
</template>

购物车页面-全选切换实现

  1. 封装接口,store/modules/cart.ts
1
2
3
4
5
6
// 计算
getters: {
isAllSelected(): boolean {
return (this.effectiveList.length !== 0 && this.effectiveList.every((item) => item.selected))
}
},
1
2
3
4
5
6
7
8
// 修改全选或者全不选
async updateCartAllSelected(isSelected: boolean) {
await request.put('/member/cart/selected', {
selected: isSelected,
})
// 获取购物车列表
this.getCartList()
},
  1. 页面中调用,views/cart/index.vue
1
2
3
<th width="120">
<XtxCheckbox :model-value="cart.isAllSelected" @update:model-value="cart.updateCartAllSelected(!cart.isAllSelected)"> 全选 </XtxCheckbox>
</th>

购物车页面-操作栏渲染

  1. 提供计算属性,store/modules/cart.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
getters: {
// 已选择的列表
selectedList(): CartItem[] {
return this.effectiveList.filter((item) => item.selected)
},
// 已选择的商品总数
selectedListCounts(): number {
return this.selectedList.reduce((sum, item) => sum + item.count, 0)
},
// 已选择的列表总价
selectedListPrice(): string {
return this.selectedList
.reduce((sum, item) => sum + item.count * Number(item.nowPrice), 0)
.toFixed(2)
}
},
  1. 渲染操作栏,views/cart/index.vue
1
2
3
4
5
6
7
8
9
<!-- 操作栏 -->
<div class="action">
<div class="batch"></div>
<div class="total">
共 {{ cart.effectiveListCounts }} 件有效商品,已选择 {{ cart.selectedListCounts }} 件,商品合计:
<span class="red">¥{{ cart.selectedListPrice }}</span>
<XtxButton type="primary">下单结算</XtxButton>
</div>
</div>

退出登录-清空购物车

需求:退出后清除本地购物车数据。

  1. 准备 action,store/modules/cart.ts
1
2
3
clearCart() {
this.list = []
}
  1. 完善 logout,store/modules/user.ts
1
2
3
4
5
6
logout() {
this.profile = {} as Profile
removeProfile()
const { cart } = useStore()
cart.clearCart()
}

购物车操作-未登录

区分未登录和已登录

  1. cart 模块增加计算属性 isLogin,store/modules/cart.ts
1
2
3
4
5
6
getters: {
isLogin(): boolean {
const { user } = useStore()
return !!user.profile.token
}
},
  1. 修改添加购物车和获取购物车的逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 添加购物车
async addCart(skuId: string, count: number) {
if (this.isLogin) {
await request.post('/member/cart', {
skuId,
count
})
// 重新获取购物车数据
this.getCartList()
} else {
console.log('本地添加购物车')
}
},
// 获取购物车列表
async getCartList() {
if (this.isLogin) {
const res = await request.get<ApiRes<CartItem[]>>('/member/cart')
this.list = res.data.result
} else {
console.log('获取本地购物车')
}
},

加入购物车

目标:实现加入购物车业务。

实现步骤

  1. 点击加入购物车的时候,从商品详情中收集购物车商品展示所需数据。

  2. actions 中完成添加操作(未登录)。

落地代码

  1. 商品详情页,src/views/goods/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const addCart = async () => {
if (!currentSkuId.value) {
return Message.warning('请选择完整信息')
}
await cart.addCart({
id: info.value.id,
name: info.value.name,
picture: info.value.mainPictures[0],
price: info.value.price,
count: count.value,
skuId: currentSkuId.value,
attrsText: '',
selected: true,
nowPrice: info.value.price,
stock: info.value.inventory,
isEffective: true,
} as CartItem)
Message.success('添加成功')
}
  1. 修改加入购物车的逻辑,src/store/modules/cart.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 添加购物车
async addCart(data: CartItem) {
if (this.isLogin) {
const { skuId, count } = data
await request.post('/member/cart', {
skuId,
count,
})
// 重新获取购物车数据
this.getCartList()
} else {
// 同 skuId 累加,否则新增
// 1. 判断购物车列表中是否有该商品数据
const goods = this.list.find((item) => item.skuId === data.skuId)
if (goods) {
// 2. 如果商品存在,直接把数量加起来
goods.count += data.count
} else {
// 3. 如果商品不存在,直接添加该商品
this.list.unshift(data)
}
}
}
  1. 拼接 attrsText。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const addCart = async () => {
if (!currentSkuId.value) {
return Message.warning('请选择完整信息')
}
const sku = info.value.skus.find((item) => item.id === currentSkuId.value)!
const attrsText = sku.specs.map((item) => item.name + ':' + item.valueName).join(' ')
await cart.addCart({
id: info.value.id,
name: info.value.name,
picture: info.value.mainPictures[0],
price: info.value.price,
count: count.value,
skuId: currentSkuId.value,
attrsText: attrsText,
selected: true,
nowPrice: info.value.price,
stock: info.value.inventory,
isEffective: true,
} as CartItem)
Message.success('添加成功')
}

购物车数据持久化

目标:实现 Pinia 购物车列表的持久化存储,https://github.com/prazdevs/pinia-plugin-persistedstate。

实现步骤

  1. 安装 Pinia 持久化存储插件,在模块中开启插件功能即可。
1
yarn add pinia-plugin-persistedstate
  1. main.ts 中。
1
2
3
4
5
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
  1. 通过 persist 开启自动持久化插件,store/modules/cart.ts
1
2
3
4
5
6
const useCartStore = defineStore({
id: 'cart',
persist: {
key: '@_@',
},
})

删除购物车

src/store/modules/cart.ts

1
2
3
4
5
6
7
8
9
10
11
12
async delCart(skuIds: string[]) {
if (this.isLogin) {
await request.delete('/member/cart', {
data: {
ids: skuIds
}
})
this.getCartList()
} else {
this.list = this.list.filter((item) => !skuIds.includes(item.skuId))
}
},

选中状态切换&修改数量

实现商品选中状态的切换和修改商品数量,src/store/modules/cart.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 修改更新购物车商品(数量, 选中状态)
async updateCart(skuId: string, data: { selected?: boolean; count?: number }) {
if (this.isLogin) {
await request.put(`/member/cart/${skuId}`, data)
// 重新获取购物车列表
this.getCartList()
} else {
const goods = this.effectiveList.find((item) => item.skuId === skuId)
if (data.selected !== undefined) {
goods!.selected = data.selected
}
if (data.count !== undefined) {
goods!.count = data.count
}
}
},

全选切换-本地

目标:实现商品选中状态的切换。

src/store/modules/cart.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 修改全选或者不全选
async updateCartAllSelected(isSelected: boolean) {
if (this.isLogin) {
await request.put('/member/cart/selected', {
selected: isSelected,
})
// 获取最新的购物车列表
this.getCartList()
} else {
this.list.forEach((item) => {
item.selected = isSelected
})
}
},

优化-主动更新本地购物车信息

目标:页面刷新时,需要获取服务端最新的购物车信息并同步到本地。

实现思路

  1. 获取购物车列表的 action 中,未登录情况 下主动查询商品库存价格。

  2. 购物车的商品库存价格,更新成最新的库存价格信息。

src/store/modules/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
// 获取购物车列表
async getCartList() {
if (this.isLogin) {
const res = await request.get<ApiRes<CartItem[]>>('/member/cart')
this.list = res.data.result
} else {
// 遍历发送请求, 校验更新sku商品的库存和价格, 是否有效
this.list.forEach(async (cartItem) => {
const { skuId } = cartItem
// 根据 skuId 获取最新商品信息
const res = await request.get<ApiRes<CartItem>>(
`/goods/stock/${skuId}`
)
// 保存最新商品信息
const lastCartInfo = res.data.result
// 更新商品现价
cartItem.nowPrice = lastCartInfo.nowPrice
// 更新商品库存
cartItem.stock = lastCartInfo.stock
// 更新商品是否有效
cartItem.isEffective = lastCartInfo.isEffective
})
}
},

登录后合并购物车

目标:登录后把本地的购物车数据合并到后端服务。

实现思路

  1. 编写合并购物车的 action 函数 (将对于购物车的处理,统一到 Pinia 中)。

  2. 调用合并购物车的 action 函数 (思考:在哪调用?答:登录完成后)。

落地代码

  1. 编写合并购物车的 actions 函数,store/modules/cart.ts
1
2
3
4
5
6
7
8
9
10
11
// 合并购物车
async mergeLocalCart() {
const data = this.list.map(({ skuId, selected, count }) => ({
skuId,
selected,
count
}))
await request.post('/member/cart/merge', data)
// 合并成功,重新获取购物车列表
this.getCartList()
}
  1. 在登录完成功后,调用合并购物车 actions 函数,views/login/components/login-form.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const login = async () => {
const res = await validate()
if (type.value === 'account') {
// 账号登录
if (res.errors.account || res.errors.password || res.errors.isAgree) return
await user.login(account.value, password.value)
} else {
// 验证码登录
if (res.errors.mobile || res.errors.code || res.errors.isAgree) return
await user.mobileLogin(mobile.value, code.value)
}
// 登录成功后,合并购物车
cart.mergeLocalCart()
Message.success('登录成功')
router.push('/')
}