前端购物车&订单结算模块详解

Vue
42
0
0
2024-12-06
标签   Vue实践

前置加入购物车

购物车唤起&加入购物车

通过点击加入购物车

img

首先, 我们需要在vant中找到对应的组件, 这里是ActionSheet组件。 通过对ActionSheet组件的修改, 从而得到我们需要的内容。

这里我将已经修改过的代码展示出来

<van-action-sheet v-model="showPannel" :title="mode === 'cart' ? '加入购物车' : '立刻购买'">
     <div class="product">
       <div class="product-title">
         <div class="left">
           <img src="http://cba.itlike.com/public/uploads/10001/20230321/8f505c6c437fc3d4b4310b57b1567544.jpg" alt="">
         </div>
         <div class="right"> 
           <div class="price">
             <span>¥</span>
             <span class="nowprice">9.99</span>
           </div>
           <div class="count">
             <span>库存</span>
             <span>55</span>
           </div>
         </div>
       </div>
       <div class="num-box">
         <span>数量</span>
         数字框占位
       </div>
       <div class="showbtn" v-if="true">
         <div class="btn" v-if="true">加入购物车</div>
         <div class="btn now" v-else>立刻购买</div>
       </div>
       <div class="btn-none" v-else>该商品已抢完</div>
     </div>
   </van-action-sheet>
   

通过上述的代码, 然后就可以v-model="showPannel"来进行控制, 如果为true显示。(当然showPannel需要我们在data中去定义)

接下来我们就可以通过在页面中点击购买或者添加购物车按钮中通过点击来实现唤起弹层的效果。

<div @click="addFn" class="btn-add">加入购物车</div>
<div @click="buyFn" class="btn-buy">立刻购买</div>


  data () {
    return {
      showPannel: false , // 默认弹层
      node: 'cart' , // 用来标记弹层状态的, 默认是cart状态

    }
  },

  methods: {
    addFn() {
      this.showPannel = true
      this.node = 'cart'
    },
    buyFn() { 
      this.showPannel = true
      this.node = 'buyNo' // 立刻购买
    },
  }

同理, 对于不同的点击效果, 我们需要使用不同的内容来显示, 加入购物车和 立即购买 就需要两个不同的弹层来显示效果。所以这里就还需要在data中定义不同的弹层显示状态。通过node变量来改变。

<van-action-sheet v-model="showPannel" :title="mode === 'cart' ? '加入购物车' : '立刻购买'">

封装弹层数字框组件

组件名 CountBox

  1. 静态结构,左中右三部分
  2. 数字框的数字,应该是外部传递进来的 (父传子)
  3. 点击 + - 号,可以修改数字 (子传父)
  4. 使用 v-model 实现封装 (:value 和 @input 的简写)
  5. 数字不能减到小于 1
  6. 可以直接输入内容,输入完成判断是否合法

prodetail/index.vue中调用组件

<div class="num-box">
  <span>数量</span>
  <CountBox v-model="addCount"></CountBox>
</div>	

然后就是创建组件, 按照对应的要求完成。

注意 使用v-model可以实现双向绑定, 但是如果直接使用v-model会导致数据流向不清晰,使得后期的开发乃至维护都变的异常煎难, 所以我们在父组件中通过v-model来进行维护, 在子组件中通过props来接收。

但是在子组件中我们需要解析v-model从而使用:value@input/change来将输入框中改变的内容来实时传输通过$emit显示到父组件中, 然后展示出来。

<template>
  <div class="count-box">
    <button @click="handleSub" class="minus">-</button>
    <input :value="value" @change="handleChange" class="inp" type="text">
    <button @click="handleAdd" class="add">+</button>
  </div>
</template>

    
<script>
export default {
  // 接收商品详情中用户下单的数量
  props: {
    // 通过value接收内容, 但是实际上使用的是v-model ,但是v-model封装了:value 和 @input
    value: {
      type: Number,
      default: 1,  // 如果用户不输入那么默认就是1
    }
  },
  methods: {
    handleSub () {
      if (this.value <= 1) {
        return
      }
      // 子传父,让在prodetail中的组件显示内容
      this.$emit('input', this.value - 1)
    },
    handleAdd () {
      this.$emit('input', this.value + 1)
    },
    handleChange (e) {
      // console.log(e.target.value)
      const num = +e.target.value // 转数字处理 (1) 数字 (2) NaN

      // 输入了不合法的文本 或 输入了负值,回退成原来的 value 值
      if (isNaN(num) || num < 1) {
        e.target.value = this.value
        return
      }

      this.$emit('input', num)
    }
  }
}
</script>
    

加入购物车操作

点击加入购物车需要登录, 如果用户未登录需要弹出组件来进行提醒用户登录, 我们这里是用的是vant组件库中的Dialog组件, 如下:

img

等用户登录完成还需要跳转至用户浏览的界面或者购物车界面, 这里就需要在dialog中的then中添加一个参数query

.then(() =>{
  // 跳转到登录
  this.$router.replace({
    path: '/login',
    query: {
      backUrl: this.$route.fullPath // 用这个可以包含查询参数
    }
  })

如果用户跳转到登录页面是从我们点击加入购物车这里跳转过去的, 那么就需要使用this.$route.fullPath来携带一个参数, 相当于表示符。 如果用户最后想要返回到对应的商品页面就需要在login/index.vue页面的点击登录方法中添加判断。

  //3.1 判断地址栏是否存在回弹地址 如果存在那么就需要跳转到对应的回弹地址上
if(this.$route.query.backUrl){
  this.$router.replace(this.$route.query.backUrl)
}else{
  this.$router.push('/')
}
this.$toast('登录成功')

通过上述的操作, 用户在商品页面添加到购物车里的, 那么登录之后还是能够跳转到对应的商品详情页面。

注意: 这里跳转回去我们使用的是replace而不是push这其中的好处就是不会增加额外的历史记录。

当然对于立即购买就不是登录之后跳转到对应的商品详情页面,而是跳转到购买的界面。

加入购物车请求接口封装

img

api/cart.js中封装请求对应的接口

// 购物车相关的接口请求。
import request from '@/utils/request'
//1.  加入购物车
// 参数: 商品id 商品编号 商品类型id
export const addCart = (goodsId, goodsNum, goodsSkuId) => {
  return request.post('/cart/add', {
    goodsId,
    goodsNum,
    goodsSkuId
  })
}

接下来就可以在页面中进行调用

data () {
  return {
      cartTotal: 0, // 用来标记加入购物车的物品的数量	
  }  
},

// 添加购物车时需要做的内容
async addCart() { 
  //  判断是否登录(有没有token) 需要跳转到对应的登录页面 或者跳转到支付结算界面
  if(!this.$store.getters.token){ // 获取token
    // 通过dialog组件来进行判断
    this.$dialog.confirm({
      title: '温馨提示',
      message: '此时需要先登录才能继续操作哦',
      confirmButtonText: '去登录',
      cancelButtonText: '再逛逛'
    }).then(() =>{
      // 跳转到登录
      this.$router.replace({
        path: '/login',
        query: {
          backUrl: this.$route.fullPath // 用这个可以包含查询参数
        }
      })
    }).catch(() => {})
    return
  }
  const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
  this.cartTotal = data.cartTotal
  this.$toast('加入购物车成功')
  this.showPannel = false // 关闭弹层
  console.log('加入购物车成功')
},

通过这样的方式可以实现加入购物车, 但是请求的时候会报错, 因为我们登录之后没有携带token。 所以需要在配置请求拦截器的时候携带对应user的token

//2.1  配置拦截器
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
  // 在发送请求之前做些什么
  const token = store.getters.token
  if (token) {
    config.headers['Access-Token'] = token
    config.headers.platform = 'H5'
  }
  // 开启loading
  Toast.loading({
    message: '加载中...',
    forbidClick: true, // 禁止背景点击( 节流防抖操作 )
    loadingType: 'spinner', 
    duration: 0   //不会自动消失
  })
  return config;

}, function (error) {
  // 对请求错误做些什么
  return Promise.reject(error);
});

通过上述的方式就可以成功完成加入购物车请求

购物车

基本静态结构 (快速实现)

详细看项目代码 https://github.com/Ray2310/MallProject/blob/main/src/views/layout/cart.vue

构建 vuex cart 模块,获取数据存储

img

所有的购物车数据每个用户登录之后 ,一旦点击加入购物车, 那么数据就是不仅限于模块内部了, 所以需要对数据做公共处理,构建vuex的cart模块, 在模块中, 我们使用的cartList来接受数据请求获取的数据, 从而实现数据全局化。

  1. 构建vuex的cart模块, 并实现挂载模块
import { getCartList } from "@/api/cart"
export default {
  namespaced: true,
  state(){  
    return {
      // 购物车列表
      cartList: [],
    }
  },
  getters: { 
  },
  mutations: { 
  },
  // 处理异步请求
  actions: { 
  }
}
  1. 通过异步请求获取用户的购物车数据, 然后存储到cartList中。 同时还需要能够在页面中调用
异步请求需要在actions中完成, 同时需要将获取用户购物车数据的请求封装到api/cart
  // 处理异步请求
  actions: { 
    async getCartAction(context) {
       const { data }  = await getCartList()
        // 后台返回的数据中, 不包括复选框的选中状态, 为了实现将来的功能,需要手动维护, 给每个项添加一个isChecked状态 
        data.list.forEach(element => {
            element.isChecked = true
        });
        // 调用mutations来进行设置cartList
       context.commit('setCartList', data.list)
      }
  }


// api/cart.js中
// 2. 购物车列表接口
export const getCartList = () =>{ 
  return request.get('/cart/list')
}

如果想要实现页面调用并显示,需要使用dispatch, 同时提供一个设置cartList的mutation。

这里我们先在页面加载的created中调用, 同时需要做用户登录的校验处理
created() { 
   // 获取购物车列表
   //需要用户登录
   if(this.$store.getters.token){
     this.$store.dispatch('cart/getCartAction')
   }
 }
mutations: { 
  // 提供一个设置cartList的mutation
  setCartList(state, newList){
    state.cartList = newList
  }
},

基于 数据 动态渲染(通过mapState) 购物车列表

 <!-- 购物车列表 -->
    <div class="cart-list">
      <div class="cart-item" v-for="item in cartList" :key="item.goods_id">
   // ..... 后续省略, 都是基于数据进行渲染即可。

<script>
import { mapState } from 'vuex'
export default {
  name: 'CartIndex',
  //  将数据映射到页面
  computed: {
    ...mapState('cart', ['cartList'])
  },
}

封装 getters 实现动态统计

封装 getters:商品总数 / 选中的商品列表 / 选中的商品总数 / 选中的商品总价
//封装 getters:商品总数  / 选中的商品列表  /   选中的商品总数  /   选中的商品总价
getters: { 
  // 商品累加总数
  cartTotal(state) {
    return state.cartList.reduce((sum, item, index) => sum + item.goods_num, 0)
  },
  // 选中的商品列表
  selectCartList(state) { 
    return state.cartList.filter((item)=> item.isChecked === true)
  },
  // 选中的商品总数
  selectCount (state, getters) {
    return getters.selectCartList.reduce((sum, item, index) => sum + item.goods_num, 0)

  },
  // 选中的商品总价
  selectCartPrice(state, getters) { 
    return getters.selectCartList.reduce((sum, item, index) => sum + item.goods_num * item.goods.goods_price_min, 0)
  }  
},

在页面中应用, 通过mapgetters来映射getters

 <div class="all-total">
        <div class="price">
          <span>合计:</span>
          <span>¥ <i class="totalPrice">{{ selectCartPrice }}</i></span>
        </div>
        <div v-if="true" class="goPay">结算({{ selectCount }})</div>
        <div v-else class="delete">删除</div>
      </div>
  // .... 上面是templates的内容

<script>
import { mapState, mapGetters } from 'vuex'
export default {
  name: 'CartIndex',
  //  将vuex模块中存储的数据映射到页面
  computed: {
    ...mapState('cart', ['cartList']),
    ...mapGetters('cart', ['cartTotal','selectCartList','selectCount','selectCartPrice'])
  },

全选反选功能

点击全选或者全不选

img

单个复选框操作
<van-checkbox :value="item.isChecked" @click="selectOne(item.goods_id)"></van-checkbox>

点击复选框之后, 直接对相应的内容取反即可

对应的方法

methods: { 
  // 点击复选框 是否选中
  selectOne(goodsId) { 
    this.$store.commit('cart/changeChecked',goodsId)
  },
  // 点击全选框
     selectAll() { 
    this.$store.commit('cart/changeCheckedAll')
  },

然后需要使用vuex来对cartList中的每个isChecked属性检查然后操作

getters: {
     // 是否全选
   isAllChecked(state) { 
     return state.cartList.every(item => item.isChecked === true)
   }  
 }

mutations: { 
   // 提供一个设置cartList的mutation
   setCartList(state, newList){
     state.cartList = newList
   },
   changeChecked(state, goodsId,getters) { 
     // 让对应的id的项 取反
     const goods = state.cartList.find((item) => item.goods_id === goodsId )
     goods.isChecked = !goods.isChecked
     // 如果检查所有的isChecked都是true, 那么就需要将下面的全选框点击上.我们通过getters来进行补充
   },
 }
全选的复选框 操作
<div  class="all-check">
    <van-checkbox  icon-size="18" :value="isAllChecked" @click="selectAll()"></van-checkbox>
    全选
  </div>

全选复选框有些麻烦, 我们需要通过getter来判断是否cartList中的所有元素都被选中(也就是isChecked === true),如果是, 那么就我们的全选复选框也需要选中, 所以这里用到了:value="isAllChecked" ,但是我们想要点击之后也同样能够全部选中所有的内容, 这就需要使用this.$store.commit('cart/changeCheckedAll')来操作vuex中的数据了。

mutations: { 
  changeChecked(state, goodsId,getters) { 
    // 让对应的id的项 取反
    const goods = state.cartList.find((item) => item.goods_id === goodsId )
    goods.isChecked = !goods.isChecked
    // 如果检查所有的isChecked都是true, 那么就需要将下面的全选框点击上.我们通过getters来进行补充
  },
  // 全选, 修改其中的内容
  changeCheckedAll(state) { 
    state.cartList.forEach(element => {
      element.isChecked = !element.isChecked
    });
  }
},

数字框修改数量功能

img

数字框是通过之前封装的子组件(CountBox), 所以需要使用到父传子,子传父的操作。

首先,操作数量,我们需要对数据库进行操作, 所以点击之后就需要进行对应的请求来修改后台数据库的操作。 但是这里因为hm哪里服务器的问题, 我们暂时修改保存本地的内容。

  1. 封装 api 接口api/cart.js中定义修改购物车数量的接口
// 更新购物车商品数量
export const changeCount = (goodsId, goodsNum, goodsSkuId) => {
  return request.post('/cart/update', {
    goodsId,
    goodsNum,
    goodsSkuId
  })
}
  1. 页面中注册点击事件,传递数据 (重点, @input使用箭头函数)
<CountBox :value="item.goods_num" @input="value => changeCount(value, item.goods_id, item.goods_sku_id)"></CountBox>
// 通过监听input得到用户输入的值
// 即希望保留原本的形参, 但是又需要通过调用函数传递参数 === 这里就可以用到箭头函数
changeCount (value, goodsId, skuId) {
  this.$store.dispatch('cart/changeCountAction', {
    value,
    goodsId,
    skuId
  })
},
  1. 提供 action 发送请求, commit mutation
mutations: {
  changeCount (state, { goodsId, value }) {
    const obj = state.cartList.find(item => item.goods_id === goodsId)
    obj.goods_num = value
  }
},
actions: {
   // 改变用户输入的商品数量
  async changeCountAction(context, obj) { 
    const { goodsNum ,goodsId, goodsSkuId } = obj
    // 先本地修改, 然后再同步到后台
    context.commit('changeCount', {goodsId, goodsNum })
    //同步到后台
    // const res = await changeCount(goodsNum, goodsId, goodsSkuId)
    // console.log(res)
  }
}

编辑删除功能

点击编辑之后, 可以对购物车中的内容做删除操作。

  1. 查看接口,封装 API ( 注意:此处 id 为获取回来的购物车数据的 id )
// 删除购物车
export const delSelect = (cartIds) => {
  return request.post('/cart/clear', {
    cartIds
  })
}
  1. 注册删除点击事件
  <div v-else :class="{ disabled: selectCount === 0 }" @click="handleDel" class="delete">
          删除({{ selectCount }})
    </div>

async handleDel () {
  if (this.selCount === 0) return
  await this.$store.dispatch('cart/delSelect')
  this.isEdit = false
},
  1. 提供 actions
actions: {
    // 删除购物车数据
  async delSelect (context) {
    const selCartList = context.getters.selectCartList
    const cartIds = selCartList.map(item => item.id)
    await delSelectShop(cartIds)
    Toast("删除成功")

    // 重新拉取最新的购物车数据 (重新渲染)
    context.dispatch('getCartAction')
  }
},

空购物车处理

对于未登录或者已经登录 ,但是没有内容。同样需要处理

img

修改为这种观感舒服的界面, 只需要一下判断即可。

img

<!-- 使用一个大盒子来封装处理, 如果未登录则显示不同的页面 -->
    <div v-if="isLogin && cartList.length > 0">
  ....
  </div>
   <!-- 如果未登录 或者 购物车为空, 那么就给一个其他的样式 -->
    <div v-else>
      <div class="cart-box" v-if="isLogin && cartList.length > 0">
        <!-- 购物车开头 -->
        <div class="cart-title">
          ...
        </div>
        <!-- 购物车列表 -->
        <div class="cart-list">
          ...
        </div>
        <div class="footer-fixed">
          ...
        </div>
      </div>
      
      <div class="empty-cart" v-else>
        <img src="@/assets/empty.png" alt="">
        <div class="tips">
          您的购物车是空的, 快去逛逛吧
        </div>
        <div class="btn" @click="$router.push('/')">去逛逛</div>
      </div>
    </div>
</div>

订单结算台

点击结算之后, 就会跳转到订单结算台, 并且需要携带对单的相关参数。

img

注意:从立即购买和订单结算中跳转到订单结算台的参数是不相同的。

获取收货地址列表

1 封装获取地址的接口(在api/address.js

import request from '@/utils/request'

// 获取地址列表
export const getAddressList = () => {
  return request.get('/address/list')
}

2 页面中 - 调用获取地址

data () {
  return {
    addressList: []
  }
},
computed: {
  selectAddress () {
    // 这里地址管理不是主线业务,直接获取默认第一条地址
    return this.addressList[0] 
  }
},
async created () {
  this.getAddressList()
},
methods: {
  async getAddressList () {
    const { data: { list } } = await getAddressList()
    this.addressList = list
  }
}

3 页面中 - 进行渲染

因为后端对订单的内容并没有做处理, 所以这里我们并没有做渲染内容

computed: {
  longAddress () {
    const region = this.selectAddress.region
    return region.province + region.city + region.region + this.selectAddress.detail
  }
},

<div class="info" v-if="selectAddress?.address_id">
  <div class="info-content">
    <span class="name">{{ selectAddress.name }}</span>
    <span class="mobile">{{ selectAddress.phone }}</span>
  </div>
  <div class="info-address">
    {{ longAddress }}
  </div>
</div>

订单结算的跳转传参以及渲染

  1. 购物车结算,需要两个参数 ① mode=”cart” ② cartIds=”cartId, cartId”
  2. 立即购买结算,需要三个参数 ① mode=”buyNow” ② goodsId=”商品id” ③ goodsSkuId=”商品skuId”
购物车订单结算

跳转传参在购物车的订单结算中通过点击事件触发

<div @click="goPay">结算({{ selCount }})</div>

goPay () {
  if (this.selCount > 0) {
    this.$router.push({
      path: '/pay',
      query: {
        mode: 'cart',
        cartIds: this.selCartList.map(item => item.id).join(',')
      }
    })
  }
}

重点: 看传递参数的方式, 通过拼接用户购物车列表中的商品id,作为一个字符串进行传递。

支付界面解析请求内容
页面中接收参数, 调用接口,获取数据

通过使用计算属性的方式来接受参数, 实现动态获取参数

data () {
  return {
    order: {},
    personal: {}
  }
},
    
computed: {
  mode () {
    return this.$route.query.mode
  },
  cartIds () {
    return this.$route.query.cartIds
  }
}

async created () {
  this.getOrderList()
},

async getOrderList () {
  if (this.mode === 'cart') {
    const { data: { order, personal } } = await checkOrder(this.mode, { cartIds: this.cartIds })
    this.order = order
    this.personal = personal
  }
}

最后就是将请求的内容解析, 然后基于res进行渲染。

立即购物的方法结算
和在购物车中的请求结算一样, 只是传递的参数不同而已

1 点击跳转传参

prodetail/index.vue
<div class="btn" v-if="mode === 'buyNow'" @click="goBuyNow">立刻购买</div>

goBuyNow () {
  this.$router.push({
    path: '/pay',
    query: {
      mode: 'buyNow',
      goodsId: this.goodsId,
      goodsSkuId: this.detail.skuList[0].goods_sku_id,
      goodsNum: this.addCount
    }
  })
}

2 计算属性处理参数

computed: {
  ...
  goodsId () {
    return this.$route.query.goodsId
  },
  goodsSkuId () {
    return this.$route.query.goodsSkuId
  },
  goodsNum () {
    return this.$route.query.goodsNum
  }
}

3 基于请求时携带参数发请求渲染

async getOrderList () {
  ...
  
  if (this.mode === 'buyNow') {
    const { data: { order, personal } } = await checkOrder(this.mode, {
      goodsId: this.goodsId,
      goodsSkuId: this.goodsSkuId,
      goodsNum: this.goodsNum
    })
    this.order = order
    this.personal = personal
  }
}

提交订单并支付

1 封装 API 通用方法(统一余额支付)

// 提交订单
export const submitOrder = (mode, params) => {
  return request.post('/checkout/submit', {
    mode,
    delivery: 10, // 物流方式  配送方式 (10快递配送 20门店自提)
    couponId: 0, // 优惠券 id
    payType: 10, // 余额支付
    isUsePoints: 0, // 是否使用积分
    ...params
  })
}

2 买家留言绑定

data () {
  return {
    remark: ''
  }
},
<div class="buytips">
  <textarea v-model="remark" placeholder="选填:买家留言(50字内)" name="" id="" cols="30" rows="10">
  </textarea>
</div>

3 注册点击事件,提交订单并支付

<div class="tipsbtn" @click="submitOrder">提交订单</div>

// 提交订单
async submitOrder () {
  if (this.mode === 'cart') {
    await submitOrder(this.mode, {
      remark: this.remark,
      cartIds: this.cartIds
    })
  }
  if (this.mode === 'buyNow') {
    await submitOrder(this.mode, {
      remark: this.remark,
      goodsId: this.goodsId,
      goodsSkuId: this.goodsSkuId,
      goodsNum: this.goodsNum
    })
  }
  this.$toast.success('支付成功')
  this.$router.replace('/myorder')
}
后续的订单管理界面, 通过使用组件的方式实现。 具体实现其实和前面的都一样