Vue2项目之移动端购物商城(四) 商品详情页的实现,购物车实现

文章目录

一.商品详情页的实现

1.静态页面布局
<template>
  <div class="productdetail">
    <van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
    <!-- 1.轮拨图  -->
    <!-- 轮拨图:实现自动播放和右下角序号正确显示 -->
    <van-swipe :autoplay="3000" @change="onChange">
      <van-swipe-item v-for="(image, index) in images" :key="index">
        <img :src="image" />
      </van-swipe-item>
      <!-- 轮拨图右下角的:(1/2) -->
      <template #indicator>
        <div class="custom-indicator">{{ current + 1 }} / {{ images.length }}</div>
      </template>
    </van-swipe>

    <!-- 2.商品信息说明 -->
    <div class="info">
      <div class="title">
        <div class="price">
          <span class="now">¥0.01</span>
          <span class="oldprice">¥6699.00</span>
        </div>
        <div class="sellcount">已售1001件</div>
      </div>
      <div class="msg text-ellipsis-2">
        三星手机 SAMSUNG Galaxy S23 8GB+256GB 超视觉夜拍系统 超清夜景 悠雾紫 5G手机 游戏拍照旗舰机s23
      </div>
      <div class="service">
        <div class="left-words">
          <span><van-icon name="passed" />七天无理由退货</span>
          <span><van-icon name="passed" />48小时发货</span>
        </div>
        <div class="right-icon">
          <van-icon name="arrow" />
        </div>
      </div>
    </div>

    <!-- 3.商品评价 -->
    <div class="comment">
      <div class="comment-title">
        <div class="left">商品评价 (5条)</div>
        <div class="right">查看更多 <van-icon name="arrow" /> </div>
      </div>
      <div class="comment-list">
        <div class="comment-item" v-for="item in 3" :key="item">
          <div class="top">
            <img src="http://cba.itlike.com/public/uploads/10001/20230321/a0db9adb2e666a65bc8dd133fbed7834.png" alt="">
            <div class="name">神雕大侠</div>
            <!-- 此处要在vant-ui.js中按需引入Rate组件 -->
            <van-rate :size="16" :value="5" color="#ffd21e" void-icon="star" void-color="#eee"/>
          </div>
          <div class="content">
            质量很不错 挺喜欢的
          </div>
          <div class="time">
            2023-03-21 15:01:35
          </div>
        </div>
      </div>
    </div>

    <!-- 4.商品描述 -->
    <div class="desc">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/kHgx21fZMWwqirkMhawkAw.jpg" alt="">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/0rRMmncfF0kGjuK5cvLolg.jpg" alt="">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/2P04A4Jn0HKxbKYSHc17kw.jpg" alt="">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/MT4k-mPd0veQXWPPO5yTIw.jpg" alt="">
    </div>

    <!-- 5.底部 -->
    <div class="footer">
      <div class="icon-home">
        <van-icon name="wap-home-o" />
        <span>首页</span>
      </div>
      <div class="icon-cart">
        <van-icon name="shopping-cart-o" />
        <span>购物车</span>
      </div>
      <div class="btn-add">加入购物车</div>
      <div class="btn-buy">立刻购买</div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'productDetailIndex',
  components: {},
  props: {},
  data () {
    return {
      images: [//静态临时数据,后续动态渲染
        'https://img01.yzcdn.cn/vant/apple-1.jpg',
        'https://img01.yzcdn.cn/vant/apple-2.jpg'
      ],
      current: 0
    }
  },
  watch: {},
  computed: {},
  methods: {
    onChange (index) {//实现:图片轮拨时,右下角的序号也改变
      this.current = index
    }
  },
  created () {
    // 查询参数传参:接收从GoodsItem传过来的数据
    console.log('从GoodsITem中获取到了数据:', this.$route.query.detail)
  },
  mounted () {
  }
}
</script>

<style lang="less" scoped>
.productdetail {
  padding-top: 46px;
  ::v-deep .van-icon-arrow-left {
    color: #333;
  }
  img {
    display: block;
    width: 100%;
  }
  .custom-indicator {
    position: absolute;
    right: 10px;
    bottom: 10px;
    padding: 5px 10px;
    font-size: 12px;
    background: rgba(0, 0, 0, 0.1);
    border-radius: 15px;
  }
  .desc {
    width: 100%;
    overflow: scroll;
    ::v-deep img {
      display: block;
      width: 100%!important;
    }
  }
  .info {
    padding: 10px;
  }
  .title {
    display: flex;
    justify-content: space-between;
    .now {
      color: #fa2209;
      font-size: 20px;
    }
    .oldprice {
      color: #959595;
      font-size: 16px;
      text-decoration: line-through;
      margin-left: 5px;
    }
    .sellcount {
      color: #959595;
      font-size: 16px;
      position: relative;
      top: 4px;
    }
  }
  .msg {
    font-size: 16px;
    line-height: 24px;
    margin-top: 5px;
  }
  .service {
    display: flex;
    justify-content: space-between;
    line-height: 40px;
    margin-top: 10px;
    font-size: 16px;
    background-color: #fafafa;
    .left-words {
      span {
        margin-right: 10px;
      }
      .van-icon {
        margin-right: 4px;
        color: #fa2209;
      }
    }
  }

  .comment {
    padding: 10px;
  }
  .comment-title {
    display: flex;
    justify-content: space-between;
    .right {
      color: #959595;
    }
  }

  .comment-item {
    font-size: 16px;
    line-height: 30px;
    .top {
      height: 30px;
      display: flex;
      align-items: center;
      margin-top: 20px;
      img {
        width: 20px;
        height: 20px;
      }
      .name {
        margin: 0 10px;
      }
    }
    .time {
      color: #999;
    }
  }

  .footer {
    position: fixed;
    left: 0;
    bottom: 0;
    width: 100%;
    height: 55px;
    background-color: #fff;
    border-top: 1px solid #ccc;
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    .icon-home, .icon-cart {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      font-size: 14px;
      .van-icon {
        font-size: 24px;
      }
    }
    .btn-add,
    .btn-buy {
      height: 36px;
      line-height: 36px;
      width: 120px;
      border-radius: 18px;
      background-color: #ffa900;
      text-align: center;
      color: #fff;
      font-size: 14px;
    }
    .btn-buy {
      background-color: #fe5630;
    }
  }
}

.tips {
  padding: 10px;
}
</style>

2.【商品信息】查看文档和封装请求接口

在这里插入图片描述
封装接口:product.js

// 获取商品描述
export const getProductDetail = (goodsId) => {
  return request.get('/goods/detail', {
    params: {
      goodsId
    }
  })
}
3.【商品信息】在页面中调用
 data:{
     detail: {}, // 保存获取到的商品详情数据
 },
 computed: {
    // 接收从GoodsItem传过来的数据:封装成goodsId
    goodsId () {
      return this.$route.query.detail
    }
  },
  methods: {
    onChange (index) {
      this.current = index
    },
    async getProductDetailMethods () {
      const { data: { detail } } = await getProductDetail(this.goodsId)
      console.log('获取res.data.detail:', detail)
      this.detail=detail//保存商品详情信息
      this.images=detail.goods_images//保存轮拨图片数组
      
    }
  },
  created () {
    // 查询参数传参:接收从GoodsItem传过来的数据
    // console.log('从GoodsITem中获取到了数据:', this.$route.query.detail)
    this.getProductDetailMethods()
  },
4.【商品信息】动态渲染
  • 商品描述渲染

需要用detail下的content取代商品描述中的图片,content包含完整的HTML结构,此时需要用v-html把它解析出来
在这里插入图片描述

    <!-- 4.商品描述 -->
    <div class="desc">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/kHgx21fZMWwqirkMhawkAw.jpg" alt="">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/0rRMmncfF0kGjuK5cvLolg.jpg" alt="">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/2P04A4Jn0HKxbKYSHc17kw.jpg" alt="">
      <img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/MT4k-mPd0veQXWPPO5yTIw.jpg" alt="">
    </div>

//上面的代码注释掉,改成:
    <!-- 4.商品描述 -->
    <div class="desc" v-html="goodsId.content"></div>
  • 轮拨图渲染

用到了vant的懒加载组件Lazyload,需要在utils/vant-ui.js 中引入(过程略)

    <!-- 1.轮拨图  -->
    <!-- 轮拨图:实现自动播放和右下角序号正确显示 -->
    <van-swipe :autoplay="3000" @change="onChange">
      <van-swipe-item v-for="(item, index) in images" :key="index">
        <img v-lazy="item.external_url" />
      </van-swipe-item>
      <!-- 轮拨图右下角的:(1/2) -->
      <template #indicator>
        <div class="custom-indicator">{{ current + 1 }} / {{ images.length }}</div>
      </template>
    </van-swipe>
......
  data () {
    return {
      images: [//注释掉静态数据
        // 'https://img01.yzcdn.cn/vant/apple-1.jpg',
        // 'https://img01.yzcdn.cn/vant/apple-2.jpg'
      ],
    }
  }
  • 商品信息说明渲染
    <!-- 2.商品信息说明 -->
    <div class="info">
      <div class="title">
        <div class="price">
          <span class="now">¥{{detail.goods_price_min
}}</span>
          <span class="oldprice">¥{{detail.goods_price_max
}}</span>
        </div>
        <div class="sellcount">已售{{detail.goods_sales}}件</div>
      </div>
      <div class="msg text-ellipsis-2">
        {{detail.goods_name}}
      </div>
      <div class="service">
        <div class="left-words">
          <span><van-icon name="passed" />七天无理由退货</span>
          <span><van-icon name="passed" />48小时发货</span>
        </div>
        <div class="right-icon">
          <van-icon name="arrow" />
        </div>
      </div>
    </div>
5.【商品评价】查看文档和封装请求接口

在这里插入图片描述

6.【商品评价】在页面中调用
data{}{
	return{
		...
	  commentList: [], // 保存获取到的商品评价数据
      total: 0, // 保存获取到的评价总数量
      defaultImg: 'https://p1.ssl.qhimgs1.com/sdr/400__/t01b375d536094dff9b.png'// 当评论的用户没有上传头像时使用这个默认头像
	}
},
methods:{
	...
   async getProductCommentMethods () {
      const { data: { list, total } } = await getProductComment(this.goodsId, 3)//传值:goodsId商品ID和limit评价总数
      // console.log('获取商品评论数据:', res)
      this.commentList = list
      this.total = total
    }
},
created(){
	...
    this.getProductCommentMethods()
}
7.【商品评价】动态渲染
    <!-- 3.商品评价 -->
    <!-- v-if:有评价时才渲染 -->
    <div class="comment" v-if="total>0">
      <div class="comment-title">
        <div class="left">商品评价 ({{total}})</div>
        <div class="right">查看更多 <van-icon name="arrow" /> </div>
      </div>
      <div class="comment-list">
        <div class="comment-item" v-for="item in commentList" :key="item.comment_id">
          <div class="top">
            <img :src="item.user.avatar_url||defaultImg" alt="">
            <div class="name">{{item.nick_name}}</div>
            <!-- 此处要在vant-ui.js中按需引入Rate组件 -->
            <van-rate :size="16" :value="item.score/2" color="#ffd21e" void-icon="star" void-color="#eee"/>
          </div>
          <div class="content">
            {{item.content}}
          </div>
          <div class="time">
            {{item.create_time}}
          </div>
        </div>
      </div>
    </div>

二.加入购物车功能和立即购买功能的实现

在商品详情页中点击"加入购物车"和"立即购买",实现相关功能
在这里插入图片描述
实现位置:

//ProductDetail/index.vue
    <!-- 5.底部 -->
    <div class="footer">
      <div class="icon-home">
        <van-icon name="wap-home-o" />
        <span>首页</span>
      </div>
      <div class="icon-cart">
        <van-icon name="shopping-cart-o" />
        <span>购物车</span>
      </div>
      <div class="btn-add">加入购物车</div>
      <div class="btn-buy">立刻购买</div>
    </div>
1.点击【加入购物车】和【立即购买】显示弹层

使用vant的ActionSheet组件作为弹出页面,需要在utils/vant-ui.js中按需导入(过程略)
绑定点击事件和引入ActionSheet

	......
      <div class="btn-add" @click='addFn'>加入购物车</div>
      <div class="btn-buy" @click='buyFn'>立刻购买</div>
      <!-- ActionSheet弹层 -->
      <van-action-sheet v-model="showPannel" title="标题">
        	<div class="content">内容</div>
      </van-action-sheet>
 	......
 data(){
	return{
		...
	  mode: 'cart', // 标记弹层状态:cart还是buynow
      showPannel: false// 控制弹层的显隐
    }
 },
 methods:{
	...
	// 点击"加入购物车"
    addFn () {
      this.mode = 'cart'
      this.showPannel = true
    },
    // 点击"立即购买"
    buyFn () {
      this.mode = 'buynow'
      this.showPannel = true
    }
 }
2.完善ActionSheet弹层的静态结构
  • 最终效果
    在这里插入图片描述

  • HTML

<!-- ActionSheet弹层 -->
<van-action-sheet v-model="showPannel" :title="mode==='cart'?'加入购物车':'立即购买'">
  <div class="product">
    <!-- 加入购物车的商品 -->
    <div class="product-title">
      <div class="left">
      	<img :src="detail.goods_image" alt="">
      </div>
    <div class="right">
      <div class="price">
        <span>¥</span>
        <span class="noprice">{{detail.goods_price_min}}</span>
      </div>
      <div class="count">
        <span>库存</span>
        <span>{{detail.stock_total}}</span>
      </div>
    </div>
  </div>
    </div>
  <!-- 数字框 -->
  <div class="num-box">
    <span>数量</span>
    <!-- 待封装<CountBox></CountBox> -->
    <div class="count-box">
      <button class="minus">-</button>
      <input type="text" class="inp" :value='1'>
      <button class="add">+</button>
    </div>
  </div>
  <!-- v-if有库存 -->
  <div class="showbtn" v-if="detail.stock_total===0">
    <div class="btn" v-if="mode==='cart'">加入购物车</div>
    <div class="btn" v-else>立即购买</div>
  </div>
  <!-- v-else没库存 -->
  <div class="btn-none" v-else>该商品已抢完</div>
</van-action-sheet>
  • CSS
/************actionSheet的样式************ */
.productdetail {
  .product-title {
    display: flex;
    .left {
      img {
        width: 90px;
        height: 90px;
      }
      margin: 10px;
    }
    .right {
      flex: 1;
      padding: 10px;
      .price {
        font-size: 14px;
        color: #fe560a;
        .nowprice {
          font-size: 24px;
          margin: 0 5px;
        }
      }
    }
  }

  .num-box {
    display: flex;
    justify-content: space-between;
    padding: 10px;
    align-items: center;
  }

  .btn, .btn-none {
    height: 40px;
    line-height: 40px;
    margin: 20px;
    border-radius: 20px;
    text-align: center;
    color: rgb(255, 255, 255);
    background-color: rgb(255, 148, 2);
  }
  .btn.now {
    background-color: #fe5630;
  }
  .btn-none {
    background-color: #cccccc;
  }
}
3.封装组件CountBox和实现数据传递

这个点击增减商品数量的按钮可以在多个场景中使用到,现在把这一部分的代码从ActionSheet中提取出来,封装到组件CountBox中,并实现组件通信(点击时能改变商品数量)

3.1.封装和引入组件
  • 新建components/CountBox.vue
<template>
      <div class="count-box">
      <button class="minus">-</button>
      <input type="text" class="inp" :value='1'>
      <button class="add">+</button>
    </div>
</template>
<style lang="less" scoped>
.count-box {
  width: 110px;
  display: flex;
  .add, .minus {
    width: 30px;
    height: 30px;
    outline: none;
    border: none;
    background-color: #efefef;
  }
  .inp {
    width: 40px;
    height: 30px;
    outline: none;
    border: none;
    margin: 0 5px;
    background-color: #efefef;
    text-align: center;
  }
}
</style>
  • ProductDetail/index.vue中引入
3.2.父传子:数字框的数字是从父组件传过来的

思路:
v-model:value@input的简写
在父传子的过程中用到了:value,把value作为自定义属性名传给子组件
在子传父的过程中用到了@input,在子组件中把input作为自定义事件名,通过$emit传回父组件
当父组件用到:value@input时,简写成v-model即可

//父:ProductDetail/index.vue
//html>ActionSheet
<CountBox v-model='addCount'></CountBox>//等价于:value='addCount' @input='addCount' 把value当做自定义属性传过去
//data
addCount:1

//子:CountBox.vue
//props
value:{
	type:Number,
	default:1
}
//html
<input type='text' class='inp' :value='value'>
3.3.子传父:点击加减号能修改数字
//子组件:在绑定的点击事件中通过$emit把input作为自定义事件名传值回父组件
<div class="count-box">
	<button class="minus" @click="handleSub">-</button>
	<input type="text" class="inp" :value="value">
	<button class="add" @click="handleAdd">+</button>
</div>
....
    handleSub () {
      if (this.value <= 1) return// 数量不能小于1
      this.$emit('input', this.value - 1)// input作为自定义事件名传给父组件,然后在父组件中@事件名,最后@input和:value简写为v-model
    },
    handleAdd () {
      this.$emit('input', this.value + 1)
    }

//父组件:啥也不用做,把:value和@input组合成v-model即可
3.4.子传父:input框直接输入数字传值给父组件
 <input type="text" class="inp" :value="value" @click="handleInput">
      ...
  handleInput (e) {
      const num = +e.target.value// 做显示转换:num只可能是数字Number或非数字NaN
      if (isNaN(num) || num < 1) return/// /输入非数字或小于1的数字时返回
      this.$emit('input', num)// 把符合条件的数字返回父组件
  }

4.给没登录的用户添加登录提示

思路:
把当前商品加入购物车须先登录,通过用户信息中是否存在token

- 存在:继续加入购物车操作
- 不存在:提示用户未登录,引导到登录页面,登陆完后回跳
使用mapState把token从子模块中映射到当前页面
//在之前的代码实现中,用户的token放在:`store/modules/user.js`中
state(){
	return{
	  // userInfo: {
      //   userId: '',
      //   token: ''
      // }
		userInfo: getInfo()
	}
}

//映射到详情页ProductDetail/index.vue
computed:{
    ...mapState('user', ['userInfo'])
}
为详情页中的"加入购物车"和"点击购买"按钮添加点击事件并验证token
/*使用到了vant的确认框Dialog,需要在vant-ui.js中按需引入,过程略*/
    // 点击"加入购物车"
    addFn () {
      this.mode = 'cart'
      this.showPannel = true
      // 验证token是否存在
      console.log('我是token', this.userInfo.token)

      if (!this.userInfo.token) { // 不存在:弹出提示框
        // Dialog确认框
        this.$dialog.confirm({
          title: '温馨提示',
          message: '此时需要先登录才能继续操作哦',
          confirmBUttomText: '去登录',
          cancelButtonText: '再逛逛'
        }).then(() => {
          this.$router.push({
            path: '/login',
            query: {
              backUrl: this.$route.fullPath
            }
          })
        })
      }
    },
    // 点击"立即购买"
    buyFn () {
      this.mode = 'buynow'
      this.showPannel = true

      // 验证token是否存在
      console.log('我是token', this.userInfo.token)

      if (!this.userInfo.token) { // 不存在:弹出提示框
        // Dialog确认框
        this.$dialog.confirm({
          title: '温馨提示',
          message: '此时需要先登录才能继续操作哦',
          confirmButtomText: '去登录',
          cancelButtonText: '再逛逛'
        }).then(() => {
          this.$router.push({
            path: '/login',
            query: {
              backUrl: this.$route.fullPath
            }
          })
        })
      }
    }
如何确保登录成功后跳转回当前页面?
//详情页:
          this.$router.push({
            path: '/login',
            query: {
              backUrl: this.$route.fullPath//传参:携带当前参数的路径地址
            }
          })
          
//登录页:
    // 点击登录
    login () {
		......
      const url = this.$route.query.backUrl
      this.$router.push(url)// 登录成功后,跳回原先页面
    }
优化:为什么上例中最好不要用push方式跳转?

当前的购物流程: 商品列表页>商品详情页>登录页>商品详情页
当登录成功并跳回详情页后,此时点击商品详情页上面的"<"按钮返回,会回到登录页,带来不好的用户体验
优化目的:确保在详情页的每一次返回都是列表页
如何实现?将两边的push跳转改成replace即可,replace不会添加新的历史记录

//详情页:
          this.$router.replace({
            path: '/login',
            query: {
              backUrl: this.$route.fullPath//传参:携带当前参数的路径地址
            }
          })
          
//登录页:
    // 点击登录
    login () {
		......
      const url = this.$route.query.backUrl
      this.$router.replace(url)// 登录成功后,跳回原先页面
    }
5.【加入购物车(弹层中的按钮)】接口封装请求和页面调用接口
step1:查看接口文档确定请求方式,请求参数,请求路径

在这里插入图片描述

step2:封装接口
import request from '@/utils/request'

export const addCart = (goodsId, goodsNum, goodsSkuId) => {
  return request.post('/cart/add', {
    goodsId,
    goodsNum,
    goodsSkuId
  })
}
step3.1:页面调用
methods:{
	addFn(){
		...
		this.addCartMethods()
	},
    async addCartMethods () {
      const res = await addCart(this.goodsId, this.addCount, this.goodsSkuId)
      console.log('获取添加购物车的res:', res)
    }
}

报错:没有token
在这里插入图片描述
解决方法:在request的请求响应器中统一携带token

import store from '@/store/index'
instance.interceptors.request.use(config => {
  // 加入购物车报错没有token的解决方法:请求拦截器中统一携带token
  const token = store.state.user.userInfo.token
  if (token) {
    config.headers['Access-Token'] = token//带有特殊字符,不能使用obj.属性名,要obj[属性名]
    config.headers.platform = 'H5'
  }
  ......
  return config
}
step3.2.再次调用

踩坑:此处的"加入购物车"是弹层中的按钮,而非详情页面内的
在这里插入图片描述
在这里插入图片描述

//html
<!-- ActionSheet弹层 -->
<van-action-sheet v-model="showPannel" :title="mode==='cart'?'加入购物车':'立即购买'" @click='handlePannel'></van-action-sheet>
//js
data(){
	return:{
		cartTotal:0 //保存获取到的购物车商品总数
	    msg: ''// 保存获取到的加入购物车成功或失败的提示信息
	}
},
methods:{
    // 点击"加入购物车"
    addFn () {
      this.mode = 'cart'
      this.showPannel = true
    },
    // 点击"立即购买"
    buyFn () {
      this.mode = 'buynow'
      this.showPannel = true
    },
    // 点击弹层中的按钮:"加入购物车"或"立即购买"
    handlePannel () {
      // 验证token是否存在
      // console.log('我是token', this.userInfo.token)
      
	/**********把确认框也挪到这里来*************/
      if (!this.userInfo.token) { // 不存在:弹出提示框
        // Dialog确认框
        this.$dialog.confirm({
          title: '温馨提示',
          message: '此时需要先登录才能继续操作哦',
          confirmButtomText: '去登录',
          cancelButtonText: '再逛逛'
        }).then(() => {
          this.$router.replace({
            path: '/login',
            query: {
              backUrl: this.$route.fullPath
            }
          })
        }).catch(() => {})
      }
      this.addCartMethods()
      this.$toast(this.msg)
      this.showPannel = false
    },
    async addCartMethods () {
      const res = await addCart(this.goodsId, this.addCount, this.goodsSkuId)
      this.cartTotal = res.data.cartTotal
      this.msg = res.message
      console.log(this.cartTotal, this.msg)
      // console.log('获取添加购物车的cartTotal:',cartTotal)
    }
}
step4:动态渲染

使用获取到的cartTotal来渲染购物车图标右上角的数字,让其同步显示
在这里插入图片描述

//html
      <!-- 购物车角标 -->
      <div class="icon-cart">
        <span class="num" v-if="cartTotal">{{cartTotal}}</span>
        <van-icon name="shopping-cart-o" />
        <span>购物车</span>
      </div>
//less
/**********购物车角标的样式**********/
/**********购物车角标的样式**********/
.footer .icon-cart {
  position: relative;
  padding: 0 6px;
  .num {
    z-index: 999;
    position: absolute;
    top: -2px;
    right: 0;
    min-width: 16px;
    padding: 0 4px;
    color: #fff;
    text-align: center;
    background-color: #ee0a24;
    border-radius: 50%;
  }
}

在这里插入图片描述

6.给首页角标和购物车角标添加跳转
      <!-- 首页角标 -->
      <div class="icon-home"  @click="$router.push('/')">
        <van-icon name="wap-home-o" />
        <span>首页</span>
      </div>
      <!-- 购物车角标 -->
      <div class="icon-cart" @click="$router.push('/cart')">
        <span class="num" v-if="cartTotal">{{cartTotal}}</span>
        <van-icon name="shopping-cart-o" />
        <span>购物车</span>
      </div>

三.购物车页的实现

购物车cart页是嵌套在Layout下的二级路由,
购物车的数据联动关系较多,而且通常会封装一些小组件,
因此为了便于维护,一般将购物车的数据基于vuex分模块管理

0.搭建vuex的子模块cart.js
//新建store/modules/cart.js
export default {
    namespaced:true,
    state(){
        return{
            cartList:[]
        }
    }
}

//在store/index.js中导入
import cart from "./modules/cart"
export default new Vuex.Store({
    modules:{
        cart
    }
})
1.【Cart页】静态页面布局

使用到了vant的Checkbox组件,要在vant-ui.js中按需引入,过程略

<template>
  <div class="cart">
    <van-nav-bar title="购物车" fixed />
    <!-- 购物车开头 -->
    <div class="cart-title">
      <span class="all"><i>4</i>件商品</span>
      <span class="edit">
        <van-icon name="edit" />
        编辑
      </span>
    </div>

    <!-- 购物车列表 -->
    <div class="cart-list">
      <div class="cart-item" v-for="item in 10" :key="item">
        <van-checkbox></van-checkbox>
        <div class="show">
          <img src="http://cba.itlike.com/public/uploads/10001/20230321/a072ef0eef1648a5c4eae81fad1b7583.jpg" alt="">
        </div>
        <div class="info">
          <span class="tit text-ellipsis-2">新Pad 14英寸 12+128 远峰蓝 M6平板电脑 智能安卓娱乐十核游戏学习二合一 低蓝光护眼超清4K全面三星屏5GWIFI全网通 蓝魔快本平板</span>
          <span class="bottom">
            <div class="price">¥ <span>1247.04</span></div>
            <div class="count-box">
              <button class="minus">-</button>
              <input class="inp" :value="4" type="text" readonly>
              <button class="add">+</button>
            </div>
          </span>
        </div>
      </div>
    </div>

    <div class="footer-fixed">
      <div  class="all-check">
        <van-checkbox  icon-size="18"></van-checkbox>
        全选
      </div>

      <div class="all-total">
        <div class="price">
          <span>合计:</span>
          <span>¥ <i class="totalPrice">99.99</i></span>
        </div>
        <div v-if="true" class="goPay">结算(5)</div>
        <div v-else class="delete">删除</div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CartPage'
}
</script>

<style lang="less" scoped>
// 主题 padding
.cart {
  padding-top: 46px;
  padding-bottom: 100px;
  background-color: #f5f5f5;
  min-height: 100vh;
  .cart-title {
    height: 40px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0 10px;
    font-size: 14px;
    .all {
      i {
        font-style: normal;
        margin: 0 2px;
        color: #fa2209;
        font-size: 16px;
      }
    }
    .edit {
      .van-icon {
        font-size: 18px;
      }
    }
  }

  .cart-item {
    margin: 0 10px 10px 10px;
    padding: 10px;
    display: flex;
    justify-content: space-between;
    background-color: #ffffff;
    border-radius: 5px;

    .show img {
      width: 100px;
      height: 100px;
    }
    .info {
      width: 210px;
      padding: 10px 5px;
      font-size: 14px;
      display: flex;
      flex-direction: column;
      justify-content: space-between;

      .bottom {
        display: flex;
        justify-content: space-between;
        .price {
          display: flex;
          align-items: flex-end;
          color: #fa2209;
          font-size: 12px;
          span {
            font-size: 16px;
          }
        }
        .count-box {
          display: flex;
          width: 110px;
          .add,
          .minus {
            width: 30px;
            height: 30px;
            outline: none;
            border: none;
          }
          .inp {
            width: 40px;
            height: 30px;
            outline: none;
            border: none;
            background-color: #efefef;
            text-align: center;
            margin: 0 5px;
          }
        }
      }
    }
  }
}

.footer-fixed {
  position: fixed;
  left: 0;
  bottom: 50px;
  height: 50px;
  width: 100%;
  border-bottom: 1px solid #ccc;
  background-color: #fff;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 10px;

  .all-check {
    display: flex;
    align-items: center;
    .van-checkbox {
      margin-right: 5px;
    }
  }

  .all-total {
    display: flex;
    line-height: 36px;
    .price {
      font-size: 14px;
      margin-right: 10px;
      .totalPrice {
        color: #fa2209;
        font-size: 18px;
        font-style: normal;
      }
    }

    .goPay, .delete {
      min-width: 100px;
      height: 36px;
      line-height: 36px;
      text-align: center;
      background-color: #fa2f21;
      color: #fff;
      border-radius: 18px;
      &.disabled {
        background-color: #ff9779;
      }
    }
  }

}
</style>

2.【Cart页】封装接口

在这里插入图片描述

//api/cart.js
// 获取购物车列表
export const getCartList = () => {
  return request.get('/cart/list')
}
3.【Cart页】页面调用
  • modules/cart.js子模块中调用数据
  mutations: {
    setCartListMethods (state, newList) {
      state.cartList = newList
    }
  },
  actions: {
    async getCartListActions (ctx) {
      const { data } = await getCartList()
      ctx.commit('setCartListMethods', data.list)
      // console.log('获取到的data.list:', data.list)
    }
  }
  • 在页面中调用actions异步函数
import { mapState } from 'vuex'
......
  created () {
    if (!this.userInfo.token) return//验证token
    this.$store.dispatch('cart/getCartListActions')
  },
  computed: {
    // token () {return this.$store.state.user.userInfo.token},//直接访问
    ...mapState('user', ['userInfo'])//辅助函数:通过辅助函数mapState把包含token的userInfo对象映射到当前页面
  }
  • 优化:在返回数据中添加checked字段

此时已获得所有数据,但不包括购物车商品前面的复选框,返回的数据中并不包含checked属性,需要自己添加

//modules/cart.js
  actions: {
    async getCartListActions (ctx) {
      const { data } = await getCartList()
      // 手动维护数据:给每一项添加一个isChecked状态,用于标记当前商品是否选中
      data.list.forEach(item => {
        item.isCheck = true// 默认选中
      })
      ctx.commit('setCartListMethods', data.list)
      // console.log('获取到的data.list:', data.list)
    }
  }
  • 最后,把cartList映射到Cart页
  computed: {
    // token () {return this.$store.state.user.userInfo.token},
    ...mapState('user', ['userInfo']),
    ...mapState('cart', ['cartList'])// 把后台获取到并存在仓库中的cartList映射到当前页面
  }
4.【Cart页】动态渲染页面
  • 使用cartList对页面数据进行动态渲染
    <!-- 购物车列表 -->
    <div class="cart-list">
      <div class="cart-item" v-for="item in cartList" :key="item.goods_id">
        <!-- 此处不能用v-model,因为vuex中的数据不支持双向绑定,故使用:value -->
        <van-checkbox :value="item.isChecked"></van-checkbox>
        <div class="show">
          <img :src="item.goods.goods_image" alt="">
        </div>
        <div class="info">
          <span class="tit text-ellipsis-2">{{item.goods.goods_name}}</span>
          <span class="bottom">
            <div class="price">¥ <span>{{item.goods.goods_price_min}}</span></div>
            <div class="count-box">
              <button class="minus">-</button>
              <input class="inp" :value="item.goods_num" type="text" readonly>
              <button class="add">+</button>
            </div>
          </span>
        </div>
      </div>
    </div>
  • 购物车的动态计算展示:封装getters
    如图,此时有三个地方的显示需要从state派生的数据中同步显示,因此要封装三个getters并映射到当前页面

在这里插入图片描述

//在modules/cart.js中封装派生的getters属性
  getters: {
    // 购物车商品总数量:所有商品的累加
    cartTotal (state) {
      return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
    },
    // 选中了哪些商品:从cartList中过滤【仅用于被selCount和selPrice调用】
    selCartList (state) {
      return state.cartList.filter(item => item.isChecked)
    },
    // 选中的商品数量
    selCount (state, getters) { // 在getters中调用另一个getters属性
      return getters.selCartList.reduce((sum, item) => sum + item.goods_num, 0)
    },
    // 选中的商品总价
    selPrice (state, getters) {
      return getters.selCartList.reduce((sum, item, index) => {
        return sum + item.goods_num * item.goods.goods_price_min
      }, 0).toFixed(2)// 总价保留两位小数
    }
  }

//映射到cart页并动态渲染:Layout/cart.vue
//映射
import { mapState, mapGetters } from 'vuex'
....
  computed: {
    // token () {return this.$store.state.user.userInfo.token},
    ...mapState('user', ['userInfo']),
    ...mapState('cart', ['cartList']), // 把后台获取到并存在仓库中的cartList映射到当前页面
    ...mapGetters('cart', ['cartTotal', 'selCount', 'selPrice'])
  }
//动态渲染
    <!-- 购物车开头 -->
    <div class="cart-title">
      <span class="all"><i>{{cartTotal}}</i>件商品</span>
		...
    </div>
    ......
      <div class="all-total">
        <div class="price">
          <span>合计:</span>
          <span>¥ <i class="totalPrice">{{selPrice}}</i></span>
        </div>
        <div v-if="true" class="goPay">结算({{selCount}})</div>
        <div v-else class="delete">删除</div>
      </div>

5.实现功能:全选和反选

当每个商品被勾选:全选按钮就勾选;当勾全选:每个商品就自动被勾选,反选也是

5.1.小选==>全选
  • 点击多选框实现选中和取消选中

核心思路:注册点击事件并传参,在点击事件中将当前点击元素和cartList匹配,对匹配到的cartList中的商品,改变其isChecked属性

//modules/cart.js
mutations:{
	toggleCheck(state,goodsId){
		const goods=this.cartList.find(item=>item.goods_id===goodsId)
		goods.isChecked=!goods.isChecked
	}
}

//cart.vue
<van-checkbox @click='toggleCheck(item.goods_id)' :value="item.isChecked"></van-checkbox>
......
import { mapMutations } from 'vuex'
methods:{
	 // toggleCheck (goodsId) {this.$store.commit('cart/toggleCheck', goodsId)}//直接调用
    ...mapMutations('cart', ['toggleCheck'])//使用辅助函数
}
  • 添加方法用于判断全选是否该勾选

此时小选全部选中,但全选没相应地勾选上,此时应该添加一个getters属性并绑在全选框上

//modules/cart.js
getters:{
	//是否该全选
	isAllChecked(state){
		return state.cartList.every(item=>item.isChecked)//返回一个布尔值:是否每个元素的isChec0ked=true
	}0
}

//cart.vue
<van-checkbox  icon-size="18" :value=isAllChecked></van-checkbox>全选
......
computed:{
	...mapGetters('cart',['isAllChecked'])
}
5.2.全选==>小选

思路:
用户点击全选框,触发点击事件,重置所有小选框的选中状态,
全选框的选中状态是派生在所有小选框的选中状态中实现的(getters),不用对该逻辑进行额外配置,
即:小选框被重置后isAllChecked会自动更改

//新建mutations方法
toggleAllCheck(state,flag){
	return state.cartList(item=>item.isChecked=flag)//重置所有小选框的选中状态:全部重置为!isAllChecked
}

//全选框绑定点击事件
<van-checkbox  icon-size="18" :value=isAllChecked @click="toggleAllCheck"></van-checkbox>
//在点击事件中映射mutations方法
methods:{
	toggleAllCheck(){
		this.$store.commit('cart',['toggleAllCheck'],!this.isAllChecked)
	}
}
5.3.优化:无商品被选中的情况下,结算按钮的文字取消高亮
//html
<div v-if="true" class="goPay" :disabled="selCount===0">结算({{selCount}})</div>
<div v-else class="delete" :disabled="selCount===0">删除</div>

//css:另外设置disabled状态的样式
.goPay, .delete {
      min-width: 100px;
      height: 36px;
      line-height: 36px;
      text-align: center;
      background-color: #fa2f21;
      color: #fff;
      border-radius: 18px;
      &[disabled] {/*格式:div[disabled]{}*/
        background-color: #ff9779;
      }
    }
6.实现功能:点击数字框修改数量

数字框中的数据放在vuex,因此需要在mutations中设置修改数量的方法,
另外,进行数量的加减时,要将数据同步到后台

  • 查看接口文档
    在这里插入图片描述
  • 封装更新接口
//a1pi/cart.js
// 更新购物车商品
export const updateCartList = (goodsId, goodsNum, goodsSkuId) => {
  return request.post('/cart/update', {
    goodsId,
    goodsNum,
    goodsSkuId
  })
  • 新增一个更新商品数量的mutation的方法
//store/modules/cart.js
mutations:{
	changeCount(state,{goodsId,goodsNum}){//参数不能超过一个,但能改写成对象形式
		const goods=state.cartList.find(item=>item.goods_id===goodsId)//找:通过goods_id匹配到cartList中符合条件的商品
		goods.goods_num=goodsNum//改:更新该商品的数量为形参goodsNum
	}
}
  • 新增一个actions方法,主要做两件事:更新state中的当前商品的数量,更新后台数据的当前商品的数量
import { updateCartList } from "@/api/cart.js"
actions:{
	async changeCountActions(ctx,obj){
		const {goodsId,goodsNum,goodsSkuId} = obj
		//更新state中的商品数量:调用mutations方法
		ctx.commit('changeCount',{
			goodsId,
			goodsNum
		})
		//更新后台数据的商品数量:调用封装的api
		const res = await updateCartList({
			goodsId,
			goodsNum,
			goodsSkuId
		})
		console.log("当前购物车的商品res:',res)//{status: 200, message: '更新成功', data: {}}

  • 页面中绑定@input事件并在事件中调用actions方法

在这里插入图片描述

//cart.vue
            <!-- 注释掉,引入此前封装好的CountBox组件 -->
            <!-- <div class="count-box">
              <button class="minus">-</button>
              <input class="inp" :value="item.goods_num" type="text" readonly>
              <button class="add">+</button>
            </div> -->

            <!-- 此时:既需要保留value参数,又需要用item的属性进行传参,这样写会报错--value未声明 -->
      <!--       <CountBox
                    :value="item.goods_num"
                    @input="changeCount(value,item.goods_id,item.goods_sku_id)"
                ></CountBox>
      -->
      <!-- 做法是在外面再套一层箭头函数-->
      <CountBox
			:value="item.goods_num"
			@input="(value)=>changeCount(value,item.goods_id,item.goods_sku_id"	
	  ></CountBox>   
            
import CountBox from "@components/CountBox"
......
    // @input事件:调用actions中的方法
    changeCount (goodsNum, goodsId, goodsSkuId) {
      this.$store.dispatch('cart/changeCountActions', { goodsNum, goodsId, goodsSkuId })
    }
7.实现功能:点击编辑切换状态并实现删除商品功能
7.1.编辑功能的实现

思路:用户点击编辑按钮,购物车切换"结算"和"删除"状态,通过绑定isEdit变量来实现

<!-- 购物车开头 -->
<div class="cart-title">
  <span class="all"><i>{{cartTotal}}</i>件商品</span>
  <span class="edit" @click="isEdit=!isEdit">
    <van-icon name="edit" />
        编辑
    </span>
</div>
......
<div v-if="!isEdit" class="goPay" :disabled="selCount===0">结算({{selCount}})</div>
<div v-else class="delete" :disabled="selCount===0">删除</div>
  data () {
    return {
      isEdit: false
    }
  },
  watch: {
    isEdit (bool) {
      if (bool) { // 删除:默认全不选中
        this.$store.commit('cart/toggleAllCheck', false)
      } else { // 结算:默认全选中
        this.$store.commit('cart/toggleAllCheck', true)
      }
    }
  }
7.2.删除功能的实现
  • 查看文档,封装接口
    在这里插入图片描述
//api/cart.js
// 删除选中的购物车商品
export const clearSelectGoods = (cartIds) => {
  return request.post('/cart/clear', {
    cartIds
  })
}
  • 在actions中调用接口
import { Toast } from 'vant'
actions:{
	async delSelGoods(ctx){
		//获取当前选中的购物车商品列表(数组)
		const selCartList=ctx.getters.selCartList
		//并将它们的商品id提取到一个数组中
		const cartIds=selCartList.map(item=>item.id)
		//执行删除操作:调用api接口
		await clearSelectgoods(cartIds)
		Toast.success('删除成功')
		//重新渲染,获取当前最新的购物车列表数据:删除成功b商品不会自动消失
		ctx.dispatch('getCartListActions')
	}
}
  • 给删除按钮绑定点击事件,并在事件中调用actions方法
<div v-else class="delete" :disabled="selCount===0" @click="handleDel">删除</div>
......
 // 删除购物车商品
 handleDel () {
      if (this.selCount === 0) return // 当前无选中商品
      this.$store.dispatch('cart/delSelectGoods')// 调用actions方法
 }
8.空购物车的处理

在这里插入图片描述
思路:
删除完商品后的购物车的样式见上图,带来不好的用户体验,可以编写一个新的HTML样式结构放在v-else中,当有商品时,渲染原先的HTML结构

  • 把原先的购物车代码用一个大的div包起来,并添加空购物车的html结构
    <!-- 非空购物车 -->
    <div class="cart-box" v-if="true">
      <!-- 购物车开头 -->
      <div class="cart-title">
		...
      </div>
      <!-- 购物车列表 -->
      <div class="cart-list">
  		...
      </div>

      <div class="footer-fixed">
		...
      </div>
    </div>
    <!-- 空购物车 -->
    <div class="empty-cart" v-else>
      <img src="https://pic.616pic.com/ys_img/00/76/85/MthQKs1K4b.jpg" alt="" />
      <div class="tips">您的购物车是空的, 快去逛逛吧</div>
      <div class="btn" @click="$router.push('/')">去逛逛</div>
    </div>
  • 设置css样式
/*****空购物车的样式***/
.empty-cart {
  padding: 80px 30px;
  img {
    width: 140px;
    height: 92px;
    display: block;
    margin: 0 auto;
  }
  .tips {
    text-align: center;
    color: #666;
    margin: 30px;
  }
  .btn {
    width: 110px;
    height: 32px;
    line-height: 32px;
    text-align: center;
    background-color: #fa2c20;
    border-radius: 16px;
    color: #fff;
    display: block;
    margin: 0 auto;
  }
}
  • 添加渲染条件
<!-- 非空购物车:当用户登录且购物车有商品 -->
<div class="cart-box" v-if="this.userInfo.token&&cartList.length>0"></div>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端OnTheRun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值