今日目标
✔ 购物车。
购物车功能分析

思路流程
购物车的各种操作都会有两种状态的区分,登录和未登录。
所有操作都封装到 Pinia
中,组件只需要触发 actions
函数。
在 actions 中通过 user 信息去区分登录状态。
已登录,通过调用接口去服务端操作,响应成功会通过 actions
修改 Pinia
中的数据即可。
未登录时,通过 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(), } }
|
加入购物车-校验
- 给加入购物车按钮注册点击事件,
views/goods/index.vue
。
1
| <XtxButton type="primary" style="margin-top:20px;" @click="addCart"> 加入购物车 </XtxButton>
|
1 2 3
| const addCart = () => { console.log('添加购物车') }
|
- 当全选了就传递对应的 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', '') }
|
- 修改 changeSku 逻辑,记录选中的 sku。
1 2 3 4 5 6 7 8 9 10 11 12 13
| const currentSkuId = ref('') const changeSku = (skuId: string) => { 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 } }
|
- 判断 sku 是否完整。
1 2 3 4 5 6 7
| const addCart = () => { if (!currentSkuId.value) { return Message.warning('请选择完整信息') } console.log('加入购物车') }
|
加入购物车
目标: 完成商品详情的添加购物车操作。

实现步骤
actions
中封装加入购物车的接口。
在商品详情页实现添加逻辑触发 actions
函数调用接口。
核心代码
- 封装 action,
store/modules/cart.ts
。
1 2 3
| async addCart(data: { skuId: string; count: number }) { await request.post('/member/cart', data) }
|
- 点击按钮调用接口,
views/goods/index.vue
。
1 2 3 4 5 6 7 8 9 10 11
| const count = ref(1) const addCart = () => { 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) } )
|
头部购物车结构
目标:在网站头部购物车图片处,鼠标经过展示购物车列表。
- 新建头部购物车组件,
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">¥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>
|
- 使用购物车组件,
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' 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> <AppHeaderCart /> </div> </header> </template>
|
头部购物车渲染
- 提供购物车的数据类型,
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 isCollect: boolean postFee: number }
|
- 新增 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| getters: { effectiveList(): CartItem[] { return this.list.filter((item) => item.stock > 0 && item.isEffective) }, 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) } },
|
- 渲染头部购物车,
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">¥{{ 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>¥{{ cart.effectiveListPrice }}</p> </div> <XtxButton type="plain">去购物车结算</XtxButton> </div> </div> </div> </template>
|
加入购物车优化
- 发送请求添加成功后,需要重新渲染。
store/modules/cart.ts
1 2 3 4
| async addCart(data: { skuId: string; count: number }) { await request.post('/member/cart', data) this.getCartList() }
|
- 提示消息,
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('添加成功') }
|
删除功能实现
- 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() }
|
- 注册事件,
views/layout/components/app-header-cart.vue
。
1
| <i class="iconfont icon-close-new" @click="cart.deleteCart([item.skuId])"></i>
|
- 小优化:购物车没有数据的时候或者当前页就在购物车页面,不需要显示移入鼠标效果。
views/layout/components/app-header-cart.vue
1
| <div class="layer" v-if="cart.effectiveList.length && $route.path !== '/cart'"></div>
|
确认框组件封装

目标:封装一个 confirm 弹出确认框,大致步骤。
实现组件基础结构和样式。
实现函数式调用组件方式和完成交互。
加上打开时动画效果。
基于 Promise 进行封装
给退出功能加上确认框。
基本结构
- 新建文件,
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>
|
- 测试,
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>
|
封装成函数-基础
- 新建文件,
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) }
|
- 测试,
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>
|
- 由于 XtxButton 组件超出了 App 的范围,因此不生效,需要手动导入。
1 2 3
| <script lang="ts" setup name="XtxConfirm"> import XtxButton from '../button/index.vue' </script>
|
封装成函数-组件交互

- 修改函数,
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) { 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) }) }
|
- 修改组件,
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, }, 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"> <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>
|
- 使用,
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' const isShow = ref(false) 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> <Transition name="confirm"> <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>, }, }) const show = ref(false) onMounted(() => { setTimeout(() => { show.value = true }, 20) }) </script> <template> <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>
|
购物车页面-已登录
购物车页面-路由与基本结构
目标:完成购物车组件基础布局和路由配置与跳转链接。

- 新建购物车页面组件,
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>¥200.00</p> </td> <td class="tc"> <XtxNumbox :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>
|
- 准备路由,
router/index.ts
。
1 2 3 4
| { path: '/cart', component: () => import('@/views/cart/index.vue') }
|
- 点击购物车按钮跳转,
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>¥{{ item.nowPrice }}</p> </td> <td class="tc"> <XtxNumbox :model-value="item.count" :max="item.stock" /> </td> <td class="tc"> <p class="f16 red">¥{{ (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>
|
购物车页面-删除操作
目标:实现商品删除功能。
思路分析
点击删除按钮记录当前点击的商品 skuId。
调用 action 函数实现删除即可。
代码落地
- 删除按钮绑定事件触发删除 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 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
+ 表单数据的交互功能实现。

- 封装修改商品的接口,
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() }
|
- 组件中注册点击事件,
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>
|
购物车页面-全选切换实现
- 封装接口,
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() },
|
- 页面中调用,
views/cart/index.vue
。
1 2 3
| <th width="120"> <XtxCheckbox :model-value="cart.isAllSelected" @update:model-value="cart.updateCartAllSelected(!cart.isAllSelected)"> 全选 </XtxCheckbox> </th>
|
购物车页面-操作栏渲染
- 提供计算属性,
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) } },
|
- 渲染操作栏,
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>
|
退出登录-清空购物车
需求:退出后清除本地购物车数据。
- 准备 action,
store/modules/cart.ts
。
1 2 3
| clearCart() { this.list = [] }
|
- 完善 logout,
store/modules/user.ts
。
1 2 3 4 5 6
| logout() { this.profile = {} as Profile removeProfile() const { cart } = useStore() cart.clearCart() }
|
购物车操作-未登录
区分未登录和已登录
- cart 模块增加计算属性 isLogin,
store/modules/cart.ts
。
1 2 3 4 5 6
| getters: { isLogin(): boolean { const { user } = useStore() return !!user.profile.token } },
|
- 修改添加购物车和获取购物车的逻辑。
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('获取本地购物车') } },
|
加入购物车
目标:实现加入购物车业务。
实现步骤
点击加入购物车的时候,从商品详情中收集购物车商品展示所需数据。
actions 中完成添加操作(未登录)。
落地代码
- 商品详情页,
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('添加成功') }
|
- 修改加入购物车的逻辑,
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 { const goods = this.list.find((item) => item.skuId === data.skuId) if (goods) { goods.count += data.count } else { this.list.unshift(data) } } }
|
- 拼接 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。
实现步骤
- 安装
Pinia
持久化存储插件,在模块中开启插件功能即可。
1
| yarn add pinia-plugin-persistedstate
|
- 在
main.ts
中。
1 2 3 4 5
| import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia() pinia.use(piniaPluginPersistedstate)
|
- 通过
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 }) } },
|
优化-主动更新本地购物车信息
目标:页面刷新时,需要获取服务端最新的购物车信息并同步到本地。
实现思路
获取购物车列表的 action 中,未登录情况 下主动查询商品库存价格。
购物车的商品库存价格,更新成最新的库存价格信息。
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 { this.list.forEach(async (cartItem) => { const { skuId } = cartItem 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 }) } },
|
登录后合并购物车
目标:登录后把本地的购物车数据合并到后端服务。
实现思路
编写合并购物车的 action
函数 (将对于购物车的处理,统一到 Pinia
中)。
调用合并购物车的 action
函数 (思考:在哪调用?答:登录完成后)。
落地代码
- 编写合并购物车的 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() }
|
- 在登录完成功后,调用合并购物车
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('/') }
|