商品详情

商品详情-基础布局

目的:完成商品详情基础布局,路由配置,搭好页面架子。

image.png

大致步骤:

  • 准备组件结构容器
  • 提取商品推荐组件且使用
  • 配置路由和组件

落地代码:

  • 页面组件:src/views/goods/index.vue
  1. <template>
  2. <div class='xtx-goods-page'>
  3. <div class="container">
  4. <!-- 面包屑 -->
  5. <XtxBread>
  6. <XtxBreadItem to="/">首页</XtxBreadItem>
  7. <XtxBreadItem to="/">手机</XtxBreadItem>
  8. <XtxBreadItem to="/">华为</XtxBreadItem>
  9. <XtxBreadItem to="/">p30</XtxBreadItem>
  10. </XtxBread>
  11. <!-- 商品信息 -->
  12. <div class="goods-info"></div>
  13. <!-- 商品推荐 -->
  14. <GoodsRelevant />
  15. <!-- 商品详情 -->
  16. <div class="goods-footer">
  17. <div class="goods-article">
  18. <!-- 商品+评价 -->
  19. <div class="goods-tabs"></div>
  20. <!-- 注意事项 -->
  21. <div class="goods-warn"></div>
  22. </div>
  23. <!-- 24热榜+专题推荐 -->
  24. <div class="goods-aside"></div>
  25. </div>
  26. </div>
  27. </div>
  28. </template>
  29. <script>
  30. import GoodsRelevant from './components/goods-relevant'
  31. export default {
  32. name: 'XtxGoodsPage',
  33. components: { , GoodsRelevant }
  34. }
  35. </script>
  36. <style scoped lang='less'>
  37. .goods-info {
  38. min-height: 600px;
  39. background: #fff;
  40. }
  41. .goods-footer {
  42. display: flex;
  43. margin-top: 20px;
  44. .goods-article {
  45. width: 940px;
  46. margin-right: 20px;
  47. }
  48. .goods-aside {
  49. width: 280px;
  50. min-height: 1000px;
  51. }
  52. }
  53. .goods-tabs {
  54. min-height: 600px;
  55. background: #fff;
  56. }
  57. .goods-warn {
  58. min-height: 600px;
  59. background: #fff;
  60. margin-top: 20px;
  61. }
  62. </style>
  • 商品推荐组件:src/views/goods/components/goods-relevant.vue
  1. <template>
  2. <div class='goods-relevant'></div>
  3. </template>
  4. <script>
  5. export default {
  6. name: 'GoodsRelevant'
  7. }
  8. </script>
  9. <style scoped lang='less'>
  10. .goods-relevant {
  11. background: #fff;
  12. min-height: 460px;
  13. margin-top: 20px;
  14. }
  15. </style>
  • 路由配置:src/router/index.js
  1. const Goods = () => import('@/views/goods/index')
  1. children: [
  2. { path: '/', component: Home },
  3. { path: '/category/:id', component: TopCategory },
  4. { path: '/category/sub/:id', component: SubCategory },
  5. + { path: '/product/:id', component: Goods }
  6. ]

总结:

  1. 实现页面的基本结构
  2. 拆分推荐的商品的组件
  3. 配置路由

商品详情-渲染面包屑

目的:获取数据,渲染面包屑。

大致步骤:

  • 定义获取商品详情API函数
  • 在组件setup中获取商品详情数据
  • 定义一个useXxx函数处理数据

注意:如果携带一个错误的token,那么是获取不到数据的(后端的验证策略有问题)

落地代码:

  • API函数 src/api/product.js
  1. import request from '@/utils/request'
  2. // 获取商品的详细数据
  3. export const findGoods = (id) => {
  4. return request({
  5. method: 'get',
  6. url: '/goods',
  7. data: { id }
  8. })
  9. }
  • useGoods函数 src/views/goods/index.vue 在setup中使用
  1. import GoodsRelevant from './components/goods-relevant'
  2. import { nextTick, ref, watch } from 'vue'
  3. import { findGoods } from '@/api/product'
  4. import { useRoute } from 'vue-router'
  5. export default {
  6. name: 'XtxGoodsPage',
  7. components: { GoodsRelevant },
  8. setup () {
  9. const goods = useGoods()
  10. return { goods }
  11. }
  12. }
  13. // 获取商品详情
  14. const useGoods = () => {
  15. // 出现路由地址商品ID发生变化,但是不会重新初始化组件
  16. const goods = ref(null)
  17. const route = useRoute()
  18. watch(() => route.params.id, (newVal) => {
  19. if (newVal && `/product/${newVal}` === route.path) {
  20. findGoods(route.params.id).then(data => {
  21. // 让商品数据为null让后使用v-if的组件可以重新销毁和创建
  22. goods.value = null
  23. nextTick(() => {
  24. goods.value = data.result
  25. })
  26. })
  27. }
  28. }, { immediate: true })
  29. return goods
  30. }
  • 防止报错,加载完成goods再显示所有内容
  1. <div class='xtx-goods-page' v-if="goods">
  • 渲染面包屑
  1. <!-- 面包屑 -->
  2. <XtxBread>
  3. <XtxBreadItem to="/">首页</XtxBreadItem>
  4. <XtxBreadItem :to="'/category/'+goodsDetail.categories[1].id">{{goodsDetail.categories[1].name}}</XtxBreadItem>
  5. <XtxBreadItem :to="'/category/sub/'+goodsDetail.categories[0].id">{{goodsDetail.categories[0].name}}</XtxBreadItem>
  6. <XtxBreadItem>{{goodsDetail.name}}</XtxBreadItem>
  7. </XtxBread>

总结:

  1. 处理详情数据,需要先置空,再通过nextTick更新数据
  2. 把逻辑处理代码拆分为Hook函数
  3. 面包屑进行动态填充

商品详情-图片预览组件

目的:完成商品图片预览功能和切换

1610540938844.png

大致步骤:

  • 首先准备商品信息区块左右两侧的布局盒子
  • 在定义一个商品图片组件,用来实现图片预览

    • 首先组件布局,渲染
    • 实现切换图片


    落地代码:

  • 商品信息区块,布局盒子 src/views/goods/index.vue

  1. <!-- 商品信息 -->
  2. <div class="goods-info">
  3. <div class="media"></div>
  4. <div class="spec"></div>
  5. </div>
  1. .goods-info {
  2. min-height: 600px;
  3. background: #fff;
  4. display: flex;
  5. .media {
  6. width: 580px;
  7. height: 600px;
  8. padding: 30px 50px;
  9. }
  10. .spec {
  11. flex: 1;
  12. padding: 30px 30px 30px 0;
  13. }
  14. }
  • 商品图片组件,渲染和切换 src/views/goods/components/goods-image.vue
  1. <template>
  2. <div class="goods-image">
  3. <div class="middle">
  4. <img :src="images[currIndex]" alt="">
  5. </div>
  6. <ul class="small">
  7. <li v-for="(img,i) in images" :key="img" :class="{active:i===currIndex}">
  8. <img @mouseenter="currIndex=i" :src="img" alt="">
  9. </li>
  10. </ul>
  11. </div>
  12. </template>
  13. <script>
  14. import { ref } from 'vue'
  15. export default {
  16. name: 'GoodsImage',
  17. props: {
  18. images: {
  19. type: Array,
  20. default: () => []
  21. }
  22. },
  23. setup (props) {
  24. const currIndex = ref(0)
  25. return { currIndex }
  26. }
  27. }
  28. </script>
  29. <style scoped lang="less">
  30. .goods-image {
  31. width: 480px;
  32. height: 400px;
  33. position: relative;
  34. display: flex;
  35. .middle {
  36. width: 400px;
  37. height: 400px;
  38. background: #f5f5f5;
  39. }
  40. .small {
  41. width: 80px;
  42. li {
  43. width: 68px;
  44. height: 68px;
  45. margin-left: 12px;
  46. margin-bottom: 15px;
  47. cursor: pointer;
  48. &:hover,&.active {
  49. border: 2px solid @xtxColor;
  50. }
  51. }
  52. }
  53. }
  54. </style>

总结:

  1. 实现基本布局
  2. 封装图片预览组件,实现鼠标悬停切换效果(类似之前所作的Tab效果)

商品详情-图片放大镜

目的:实现图片放大镜功能

image.png

大致步骤:

  • 首先准备大图容器和遮罩容器
  • 然后使用@vueuse/coreuseMouseInElement方法获取基于元素的偏移量
  • 计算出 遮罩容器定位与大容器背景定位 暴露出数据给模板使用

落地代码:src/views/goods/components/goods-image.vue

  • 准备大图容器
  1. <div class='goods-image'>
  2. + <div class="large" :style="[{backgroundImage:`url(${images[currIndex]})`}]"></div>
  3. <div class="middle">
  1. .goods-image {
  2. width: 480px;
  3. height: 400px;
  4. position: relative;
  5. display: flex;
  6. + z-index: 500;
  7. + .large {
  8. + position: absolute;
  9. + top: 0;
  10. + left: 412px;
  11. + width: 400px;
  12. + height: 400px;
  13. + box-shadow: 0 0 10px rgba(0,0,0,0.1);
  14. + background-repeat: no-repeat;
  15. + background-size: 800px 800px;
  16. + background-color: #f8f8f8;
  17. + }

总结:实现右侧大图布局效果(背景图放大4倍)

  • 准备待移动的遮罩容器
  1. <div class="middle" ref="target">
  2. <img :src="images[currIndex]" alt="">
  3. + <div class="layer"></div>
  4. </div>
  1. .middle {
  2. width: 400px;
  3. height: 400px;
  4. + position: relative;
  5. + cursor: move;
  6. + .layer {
  7. + width: 200px;
  8. + height: 200px;
  9. + background: rgba(0,0,0,.2);
  10. + left: 0;
  11. + top: 0;
  12. + position: absolute;
  13. + }
  14. }
  • 使用vueuse提供的API获取鼠标偏移量
  1. import { reactive, ref, watch } from 'vue'
  2. import { useMouseInElement } from '@vueuse/core'
  1. const usePreviewImg = () => {
  2. const target = ref(null)
  3. const show = ref(false)
  4. // elementX 鼠标基于容器左上角X轴偏移
  5. // elementY 鼠标基于容器左上角Y轴偏移
  6. // isOutside 鼠标是否在模板容器外
  7. const { elementX, elementY, isOutside } = useMouseInElement(target)
  8. const position = reactive({ left: 0, top: 0 })
  9. const bgPosition = reactive({ backgroundPositionX: 0, backgroundPositionY: 0 })
  10. watch([elementX, elementY, isOutside], () => {
  11. // 控制X轴方向的定位 0-200 之间
  12. if (elementX.value < 100) position.left = 0
  13. else if (elementX.value > 300) position.left = 200
  14. else position.left = elementX.value - 100
  15. // 控制Y轴方向的定位 0-200 之间
  16. if (elementY.value < 100) position.top = 0
  17. else if (elementY.value > 300) position.top = 200
  18. else position.top = elementY.value - 100
  19. // 设置大背景的定位
  20. bgPosition.backgroundPositionX = -position.left * 2 + 'px'
  21. bgPosition.backgroundPositionY = -position.top * 2 + 'px'
  22. // 设置遮罩容器的定位
  23. position.left = position.left + 'px'
  24. position.top = position.top + 'px'
  25. // 设置是否显示预览大图
  26. show.value = !isOutside.value
  27. })
  28. return { position, bgPosition, show, target }
  29. }
  • 在setup中返回模板需要数据,并使用它
  1. setup () {
  2. const { currIndex, toggleImg } = useToggleImg()
  3. + const { position, bgPosition, show, target } = usePreviewImg()
  4. + return { currIndex, toggleImg, position, bgPosition, show, target }
  5. }
  1. <div class="large" v-show="show" :style="[{backgroundImage:`url(${images[currIndex]})`},bgPosition]"></div>
  2. <div class="middle" ref="target">
  3. <img :src="images[currIndex]" alt="">
  4. <div class="layer" v-show="show" :style="position"></div>
  5. </div>

总结:

  1. 基于Vueuse提供方法监控进入DOM内的坐标
  2. 基于坐标的变化控制遮罩层的移动
  3. 基于坐标的变化控制右侧预览图背景的变化
  4. 控制进入和离开时显示和隐藏效果

回顾

  • 二级分类
    • 排序和复选框参数的选择,并且传递给接口方法
    • 筛选条件参数的获取,并且传递给接口方法
    • 修复可视区没有数据的问题
  • 商品详情
    • 熟悉基本业务:查询商品数据进行展示
    • 基本组件结构布局
    • 调用接口获取商品详情数据
    • 顶部的面包屑动态填充
    • 商品图片预览的效果
      • 图片的切换
      • 放大镜效果
      • 基于vueuse提供的方法判断鼠标是否进入指定区域
      • 动态计算遮罩层和放大图片的背景的位置

商品详情-基本信息展示

目的:展示商品基本信息

image.png

大致步骤:

  • 商品销售属性组件
  • 商品名称信息组件

落地代码:

  • ⑴基础布局:

红色区域1 src/views/goods/components/goods-sales.vue

  1. <template>
  2. <ul class="goods-sales">
  3. <li>
  4. <p>销量人气</p>
  5. <p>200+</p>
  6. <p><i class="iconfont icon-task-filling"></i>销量人气</p>
  7. </li>
  8. <li>
  9. <p>商品评价</p>
  10. <p>400+</p>
  11. <p><i class="iconfont icon-comment-filling"></i>查看评价</p>
  12. </li>
  13. <li>
  14. <p>收藏人气</p>
  15. <p>600+</p>
  16. <p><i class="iconfont icon-favorite-filling"></i>收藏商品</p>
  17. </li>
  18. <li>
  19. <p>品牌信息</p>
  20. <p>苏宁电器</p>
  21. <p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p>
  22. </li>
  23. </ul>
  24. </template>
  25. <script>
  26. export default {
  27. name: 'GoodsSales'
  28. }
  29. </script>
  30. <style scoped lang='less'>
  31. .goods-sales {
  32. display: flex;
  33. width: 400px;
  34. align-items: center;
  35. text-align: center;
  36. height: 140px;
  37. li {
  38. flex: 1;
  39. position: relative;
  40. ~ li::after {
  41. position: absolute;
  42. top: 10px;
  43. left: 0;
  44. height: 60px;
  45. border-left: 1px solid #e4e4e4;
  46. content: "";
  47. }
  48. p {
  49. &:first-child {
  50. color: #999;
  51. }
  52. &:nth-child(2) {
  53. color: @priceColor;
  54. margin-top: 10px;
  55. }
  56. &:last-child {
  57. color: #666;
  58. margin-top: 10px;
  59. i {
  60. color: @xtxColor;
  61. font-size: 14px;
  62. margin-right: 2px;
  63. }
  64. &:hover {
  65. color: @xtxColor;
  66. cursor: pointer;
  67. }
  68. }
  69. }
  70. }
  71. }
  72. </style>

红色区域2 src/views/goods/components/goods-name.vue

  1. <template>
  2. <p class="g-name">2件装 粉釉花瓣心意点缀 点心盘*2 碟子盘子</p>
  3. <p class="g-desc">花瓣造型干净简约 多功能使用堆叠方便</p>
  4. <p class="g-price">
  5. <span>108.00</span>
  6. <span>199.00</span>
  7. </p>
  8. <div class="g-service">
  9. <dl>
  10. <dt>促销</dt>
  11. <dd>12月好物放送,App领券购买直降120元</dd>
  12. </dl>
  13. <dl>
  14. <dt>配送</dt>
  15. <dd></dd>
  16. </dl>
  17. <dl>
  18. <dt>服务</dt>
  19. <dd>
  20. <span>无忧退货</span>
  21. <span>快速退款</span>
  22. <span>免费包邮</span>
  23. <a href="javascript:;">了解详情</a>
  24. </dd>
  25. </dl>
  26. </div>
  27. </template>
  28. <script>
  29. export default {
  30. name: 'GoodName'
  31. }
  32. </script>
  33. <style lang="less" scoped>
  34. .g-name {
  35. font-size: 22px
  36. }
  37. .g-desc {
  38. color: #999;
  39. margin-top: 10px;
  40. }
  41. .g-price {
  42. margin-top: 10px;
  43. span {
  44. &::before {
  45. content: "¥";
  46. font-size: 14px;
  47. }
  48. &:first-child {
  49. color: @priceColor;
  50. margin-right: 10px;
  51. font-size: 22px;
  52. }
  53. &:last-child {
  54. color: #999;
  55. text-decoration: line-through;
  56. font-size: 16px;
  57. }
  58. }
  59. }
  60. .g-service {
  61. background: #f5f5f5;
  62. width: 500px;
  63. padding: 20px 10px 0 10px;
  64. margin-top: 10px;
  65. dl {
  66. padding-bottom: 20px;
  67. display: flex;
  68. align-items: center;
  69. dt {
  70. width: 50px;
  71. color: #999;
  72. }
  73. dd {
  74. color: #666;
  75. &:last-child {
  76. span {
  77. margin-right: 10px;
  78. &::before {
  79. content: "•";
  80. color: @xtxColor;
  81. margin-right: 2px;
  82. }
  83. }
  84. a {
  85. color: @xtxColor;
  86. }
  87. }
  88. }
  89. }
  90. }
  91. </style>
  • ⑵使用组件 src/views/goods/index.vue
  1. import GoodsSales from './components/goods-sales'
  2. import GoodsName from './components/goods-name'
  1. components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName },
  1. <!-- 商品信息 -->
  2. <div class="goods-info">
  3. <div class="media">
  4. <GoodsImage :images="goods.mainPictures" />
  5. + <GoodsSales />
  6. </div>
  7. <div class="spec">
  8. + <GoodsName :goods="goods"/>
  9. </div>
  10. </div>
  • ⑶渲染数据 src/views/goods/components/goods-name.vue
  1. <p class="g-name">{{goods.name}}</p>
  2. <p class="g-desc">{{goods.desc}}</p>
  3. <p class="g-price">
  4. <span>{{goods.price}}</span>
  5. <span>{{goods.oldPrice}}</span>
  6. </p>

总结:

  1. 准备商品销售信息组件
  2. 商品名称信息组件

商品详情-城市组件-基础布局

目的:完成城市组件的基础布局和基本显示隐藏切换效果。

image.png

大致步骤:

  • 准备基本组件结构
  • 完成切换显示隐藏
  • 完成点击外部隐藏

落地代码:

src/components/library/xtx-city.vue

  • 结构
  1. <template>
  2. <div class="xtx-city" ref="target">
  3. <div class="select" @click="toggle" :class="{active}">
  4. <span class="placeholder">请选择配送地址</span>
  5. <span class="value"></span>
  6. <i class="iconfont icon-angle-down"></i>
  7. </div>
  8. <div class="option" v-show='isShow'>
  9. <span class="ellipsis" v-for="i in 24" :key="i">北京市</span>
  10. </div>
  11. </div>
  12. </template>
  • 逻辑
  1. <script>
  2. import { ref } from 'vue'
  3. import { onClickOutside } from '@vueuse/core'
  4. export default {
  5. name: 'XtxCity',
  6. setup () {
  7. const isShow = ref(false)
  8. // 控制选择城市弹窗的显示和隐藏
  9. const toggle = () => {
  10. isShow.value = !isShow.value
  11. }
  12. return { isShow, toggle }
  13. }
  14. }
  15. </script>
  • 样式
  1. <style scoped lang="less">
  2. .xtx-city {
  3. display: inline-block;
  4. position: relative;
  5. z-index: 400;
  6. .select {
  7. border: 1px solid #e4e4e4;
  8. height: 30px;
  9. padding: 0 5px;
  10. line-height: 28px;
  11. cursor: pointer;
  12. &.active {
  13. background: #fff;
  14. }
  15. .placeholder {
  16. color: #999;
  17. }
  18. .value {
  19. color: #666;
  20. font-size: 12px;
  21. }
  22. i {
  23. font-size: 12px;
  24. margin-left: 5px;
  25. }
  26. }
  27. .option {
  28. width: 542px;
  29. border: 1px solid #e4e4e4;
  30. position: absolute;
  31. left: 0;
  32. top: 29px;
  33. background: #fff;
  34. min-height: 30px;
  35. line-height: 30px;
  36. display: flex;
  37. flex-wrap: wrap;
  38. padding: 10px;
  39. > span {
  40. width: 130px;
  41. text-align: center;
  42. cursor: pointer;
  43. border-radius: 4px;
  44. padding: 0 3px;
  45. &:hover {
  46. background: #f5f5f5;
  47. }
  48. }
  49. }
  50. }
  51. </style>

总结:

  1. 实现城市选择组件的基本布局
  2. 控制弹窗的显示和隐藏

商品详情-城市组件-获取数据

2目的:组件初始化的时候获取城市数据,进行默认展示。

image.png

大致步骤:

  • 获取数据函数封装且支持缓存。
  • 获取数据渲染且加上加载中效果。
  • 加上一个vue-cli配置,处理图片为base64

落地代码:src/components/library/xtx-city.vue

  • 获取数据的函数
  1. // 获取城市数据
  2. // 1. 数据在哪里?https://yjy-oss-files.oss-cn-zhangjiakou.aliyuncs.com/tuxian/area.json
  3. // 2. 何时获取?打开城市列表的时候,做个内存中缓存
  4. // 3. 怎么使用数据?定义计算属性,根据点击的省份城市展示
  5. export const getCityList = async () => {
  6. // 添加缓存,防止频繁加载列表数据
  7. if (window.cityList) {
  8. // 缓存中已经存在数据了
  9. return window.cityList
  10. }
  11. const ret = await axios.get(cityUrl)
  12. // 给window对象添加了一个属性cityList
  13. window.cityList = ret.data
  14. // 把数据返回
  15. return ret.data
  16. }
  • toggle使用函数
  1. <script>
  2. import { ref } from 'vue'
  3. import { getCityList } from '@/api/product.js'
  4. export default {
  5. name: 'XtxCity',
  6. setup () {
  7. // 城市列表数据
  8. const list = ref([])
  9. // 显示隐藏状态位
  10. const isShow = ref(false)
  11. // 控制选择城市弹窗的显示和隐藏
  12. const toggle = () => {
  13. isShow.value = !isShow.value
  14. // 打开弹窗是调用接口获取城市列表数据
  15. if (isShow.value) {
  16. getCityList().then(data => {
  17. list.value = data
  18. })
  19. }
  20. }
  21. return { isShow, toggle, list }
  22. }
  23. }
  24. </script>

总结:

  1. 点击选择城市按钮,调用接口获取城市列表数据
  2. 添加城市列表数据的缓存(基于window的全局属性进行缓存)
  • 加载中样式
  1. .option {
  2. // 省略...
  3. .loading {
  4. height: 290px;
  5. width: 100%;
  6. background: url(../../assets/images/loading.gif) no-repeat center;
  7. }
  8. }
  • 模板中使用
  1. <div class="option" v-if="visible">
  2. + <div v-if="loading" class="loading"></div>
  3. + <template v-else>
  4. + <span class="ellipsis" v-for="item in currList" :key="item.code">{{item.name}}</span>
  5. + </template>
  6. </div>

注意事项: 需要配置10kb下的图片打包成base64的格式 vue.config.js

  1. chainWebpack: config => {
  2. config.module
  3. .rule('images')
  4. .use('url-loader')
  5. .loader('url-loader')
  6. .tap(options => Object.assign(options, { limit: 10000 }))
  7. }

总结:

  1. 添加一个加载的状态效果
  2. 需要把小图片转换为base64数据,提高加载效率(基于webpack的配置进行处理)

商品详情-城市组件-交互逻辑

3目的:显示省市区文字,让组件能够选择省市区并且反馈给父组件。

大致步骤:

  • 明确和后台交互的时候需要产生哪些数据,省code,市code,地区code,它们组合再一起的文字。
  • 商品详情的默认地址,如果登录了有地址列表,需要获取默认的地址,设置商品详情的地址。
  • 然后默认的地址需要传递给xtx-city组件做默认值显示
  • 然后 xtx-city 组件产生数据的时候,需要给出:省code,市code,地区code,它们组合在一起的文字。

落的代码:

  • 第一步:父组件设置 省市区的code数据,对应的文字数据。

src/views/goods/components/goods-name.vue

  1. setup (props) {
  2. // 默认情况
  3. const provinceCode = ref('110000')
  4. const cityCode = ref('119900')
  5. const countyCode = ref('110101')
  6. const fullLocation = ref('北京市 市辖区 东城区')
  7. // 有默认地址
  8. if (props.goods.userAddresses) {
  9. const defaultAddr = props.goods.userAddresses.find(addr => addr.isDefault === 1)
  10. if (defaultAddr) {
  11. provinceCode.value = defaultAddr.provinceCode
  12. cityCode.value = defaultAddr.cityCode
  13. countyCode.value = defaultAddr.countyCode
  14. fullLocation.value = defaultAddr.fullLocation
  15. }
  16. }
  17. return { fullLocation }
  18. }
  1. <XtxCity :fullLocation="fullLocation" />

总结:获取后端的详情数据中默认的配送地址,进行显示

  • 第二步:监听用户点击 省,市 展示 市列表和地区列表。

src/components/xtx-city.vue

  1. <div class="option" v-show="visible">
  2. + <span @click="changeCity(city)" class="ellipsis"
  1. // 选中的省市区
  2. const changeResult = reactive({
  3. provinceCode: '',
  4. provinceName: '',
  5. cityCode: '',
  6. cityName: '',
  7. countyCode: '',
  8. countyName: '',
  9. fullLocation: ''
  10. })
  11. // 控制城市的切换
  12. const changeCity = (city) => {
  13. if (city.level === 0) {
  14. // 省级
  15. changeResult.provinceCode = city.code
  16. changeResult.provinceName = city.name
  17. } else if (city.level === 1) {
  18. // 市级
  19. changeResult.cityCode = city.code
  20. changeResult.cityName = city.name
  21. } else if (city.level === 2) {
  22. // 县级
  23. changeResult.countyCode = city.code
  24. changeResult.countyName = city.name
  25. // 关闭弹窗
  26. toggle()
  27. // 把选中的数据交给父组件
  28. changeResult.fullLocation = `${changeResult.provinceName} ${changeResult.cityName} ${changeResult.countyName}`
  29. emit('change-result', changeResult)
  30. }
  31. }
  • 计算出需要展示列表
  1. // 动态计算当前显示的是省级还是市级还是县级
  2. const cityList = computed(() => {
  3. // 省级列表
  4. let result = list.value
  5. // 计算市级列表
  6. if (changeResult.provinceCode && changeResult.provinceName) {
  7. // 点击了省,计算它的市级数据
  8. result = result.find(item => item.code === changeResult.provinceCode).areaList
  9. }
  10. // 计算县级列表
  11. if (changeResult.cityCode && changeResult.cityName) {
  12. // 点击了省,计算它的市级数据
  13. return result.find(item => item.code === changeResult.cityCode).areaList
  14. }
  15. return result
  16. })
  • 打开弹层清空之前的选择
  1. // 控制选择城市弹窗的显示和隐藏
  2. const toggle = () => {
  3. isShow.value = !isShow.value
  4. // 打开弹窗是调用接口获取城市列表数据
  5. if (isShow.value) {
  6. loading.value = true
  7. getCityList().then(data => {
  8. list.value = data
  9. loading.value = false
  10. })
  11. // 打开弹窗时,请求数据
  12. + for (const key in changeResult) {
  13. + changeResult[key] = ''
  14. + }
  15. }
  16. }
  • 第三步:点击地区的时候,将数据通知给父组件使用,关闭对话框

src/components/xtx-city.vue

  1. // 控制城市的切换
  2. const changeCity = (city) => {
  3. if (city.level === 0) {
  4. // 省级
  5. changeResult.provinceCode = city.code
  6. changeResult.provinceName = city.name
  7. } else if (city.level === 1) {
  8. // 市级
  9. changeResult.cityCode = city.code
  10. changeResult.cityName = city.name
  11. } else if (city.level === 2) {
  12. // 县级
  13. changeResult.countyCode = city.code
  14. changeResult.countyName = city.name
  15. // 关闭弹窗
  16. toggle()
  17. // 把选中的数据交给父组件
  18. changeResult.fullLocation = `${changeResult.provinceName} ${changeResult.cityName} ${changeResult.countyName}`
  19. emit('change-result', changeResult)
  20. }
  21. }

src/views/goods/components/goods-name.vue

  1. // 更新选中的省市区信息
  2. const changeResult = (result) => {
  3. provinceCode.value = result.provinceCode
  4. cityCode.value = result.cityCode
  5. countyCode.value = result.countyCode
  6. fullLocation.value = result.fullLocation
  7. }
  1. <XtxCity @change-result='changeResult' :fullLocation='fullLocation' />

总结:

  1. 控制选中省市区的切换操作
  2. 通过计算属性获取当前的省市区数据
  3. 控制结果的选中
  • 第四步,点击弹窗之外关闭弹窗
  1. // 弹窗引用对象
  2. const target = ref(null)
  3. onClickOutside(target, () => {
  4. // 点击弹窗之外的区域自动触发
  5. toggle()
  6. })

总结:基于vueuse提供onClickOutside方法控制弹窗的关闭

★规格组件-SKU&SPU概念

官方话术:

  • SPU(Standard Product Unit):标准化产品单元。是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以称为一个SPU。
  • SKU(Stock Keeping Unit)库存量单位,即库存进出计量的单位, 可以是以件、盒、托盘等为单位。SKU是物理上不可分割的最小存货单元。在使用时要根据不同业态,不同管理模式来处理。

画图理解:

image.png

总结一下:

  • spu代表一种商品,拥有很多相同的属性。
  • sku代表该商品可选规格的任意组合,他是库存单位的唯一标识。

  • 如何判断组合选择的规格参数是否可以选中?
  1. 从后端可以得到所有的SKU数据
  2. 我们需要过滤出有库存的SKU数据
  3. 为了方便进行组合判断,需要计算每个SKU规格的集合数据的【笛卡尔集】
  4. 此时当点击规格标签时,把选中的规格进行组合,然后去笛卡尔集中判断,只要有一个存在,就证明这种组合是有效的(点击组合点击)

★规格组件-基础结构和样式

目标,完成规格组件的基础布局。

1614068663196.png

大致步骤:

  • 准备组件
  • 使用组件

落地代码:

  • 组件结构 src/views/goods/components/goods-sku.vue
  1. <template>
  2. <div class="goods-sku">
  3. <dl>
  4. <dt>颜色</dt>
  5. <dd>
  6. <img class="selected" src="https://yanxuan-item.nosdn.127.net/d77c1f9347d06565a05e606bd4f949e0.png" alt="">
  7. <img class="disabled" src="https://yanxuan-item.nosdn.127.net/d77c1f9347d06565a05e606bd4f949e0.png" alt="">
  8. </dd>
  9. </dl>
  10. <dl>
  11. <dt>尺寸</dt>
  12. <dd>
  13. <span class="disabled">10英寸</span>
  14. <span class="selected">20英寸</span>
  15. <span>30英寸</span>
  16. </dd>
  17. </dl>
  18. <dl>
  19. <dt>版本</dt>
  20. <dd>
  21. <span>美版</span>
  22. <span>港版</span>
  23. </dd>
  24. </dl>
  25. </div>
  26. </template>
  27. <script>
  28. export default {
  29. name: 'GoodsSku'
  30. }
  31. </script>
  32. <style scoped lang="less">
  33. .sku-state-mixin () {
  34. border: 1px solid #e4e4e4;
  35. margin-right: 10px;
  36. cursor: pointer;
  37. &.selected {
  38. border-color: @xtxColor;
  39. }
  40. &.disabled {
  41. opacity: 0.6;
  42. border-style: dashed;
  43. cursor: not-allowed;
  44. }
  45. }
  46. .goods-sku {
  47. padding-left: 10px;
  48. padding-top: 20px;
  49. dl {
  50. display: flex;
  51. padding-bottom: 20px;
  52. align-items: center;
  53. dt {
  54. width: 50px;
  55. color: #999;
  56. }
  57. dd {
  58. flex: 1;
  59. color: #666;
  60. > img {
  61. width: 50px;
  62. height: 50px;
  63. .sku-state-mixin ();
  64. }
  65. > span {
  66. display: inline-block;
  67. height: 30px;
  68. line-height: 28px;
  69. padding: 0 20px;
  70. .sku-state-mixin ();
  71. }
  72. }
  73. }
  74. }
  75. </style>
  • 使用组件 src/views/goods/index.vue
  1. +import GoodsSku from './components/goods-sku'
  2. name: 'XtxGoodsPage',
  3. + components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku },
  4. setup () {
  1. <div class="spec">
  2. <!-- 名字区组件 -->
  3. <GoodsName :goods="goods" />
  4. <!-- 规格组件 -->
  5. + <GoodsSku />
  6. </div>

总结: 每一个按钮拥有selected disabled 类名,做 选中 和 禁用 要用。

★规格组件-渲染与选中效果

目的:根据商品信息渲染规格,完成选中,取消选中效果。

大致步骤:

  • 依赖 goods.specs 渲染规格
  • 绑定按钮点击事件,完成选中和取消选中

    • 当前点的是选中,取消即可
    • 当前点的未选中,先当前规格按钮全部取消,当前按钮选中。


    落的代码:src/views/goods/components/goods-sku.vue

  1. <template>
  2. <div class="goods-sku">
  3. <dl v-for='(item, i) in specs' :key='i'>
  4. <dt>{{item.name}}</dt>
  5. <dd>
  6. <template v-for='(tag, n) in item.values' :key='n'>
  7. <img :class='{selected: tag.selected}' v-if='tag.picture' :src="tag.picture" alt="" @click='toggle(tag, item.values)'>
  8. <span :class='{selected: tag.selected}' v-else @click='toggle(tag, item.values)'>{{tag.name}}</span>
  9. </template>
  10. </dd>
  11. </dl>
  12. </div>
  13. </template>
  14. <script>
  15. export default {
  16. name: 'GoodsSku',
  17. props: {
  18. // 商品的规格参数
  19. specs: {
  20. type: Array,
  21. default: () => []
  22. }
  23. },
  24. setup () {
  25. // 控制当前标签的选中和反选
  26. const toggle = (tag, list) => {
  27. if (tag.selected) {
  28. // 如果有selected属性并且值为true,证明已经选中
  29. tag.selected = false
  30. } else {
  31. // 没有selected属性或者值为false,没有选中
  32. // 先把同类标签所有的selected状态设置Wiefalse(取消选中),当前的标签状态设置为选中
  33. list.forEach(item => {
  34. item.selected = false
  35. })
  36. tag.selected = true
  37. }
  38. }
  39. return { toggle }
  40. }
  41. }
  42. </script>

总结:

  1. 动态渲染所有的规格参数:两层遍历
  2. 控制标签的选中和反选

★规格组件-禁用效果-思路分析

目标:大致了解禁用效果的整体思路,注意只是了解。

image.png

大致步骤:

  1. 根据后台返回的skus数据得到有效sku组合
  2. 根据有效的sku组合得到所有的子集集合
  3. 根据子集集合组合成一个路径字典,也就是对象。
  4. 在组件初始化的时候去判断每个规格是否可以点击
  5. 在点击规格的时候去判断其他规格是否可点击
  6. 判断的依据是,拿着所有规格和现在已经选中的规则取搭配,得到可走路径。
    1. 如果可走路径在字典中,可点击
    2. 如果可走路径不在字典中,禁用

★规格组件-禁用效果-路径字典

目的:根据后台skus数据得到可走路径字典对象

src/vender/power-set.js

  1. /**
  2. * Find power-set of a set using BITWISE approach.
  3. *
  4. * @param {*[]} originalSet
  5. * @return {*[][]}
  6. */
  7. export default function bwPowerSet(originalSet) {
  8. const subSets = [];
  9. // We will have 2^n possible combinations (where n is a length of original set).
  10. // It is because for every element of original set we will decide whether to include
  11. // it or not (2 options for each set element).
  12. const numberOfCombinations = 2 ** originalSet.length;
  13. // Each number in binary representation in a range from 0 to 2^n does exactly what we need:
  14. // it shows by its bits (0 or 1) whether to include related element from the set or not.
  15. // For example, for the set {1, 2, 3} the binary number of 0b010 would mean that we need to
  16. // include only "2" to the current set.
  17. for (let combinationIndex = 0; combinationIndex < numberOfCombinations; combinationIndex += 1) {
  18. const subSet = [];
  19. for (let setElementIndex = 0; setElementIndex < originalSet.length; setElementIndex += 1) {
  20. // Decide whether we need to include current element into the subset or not.
  21. if (combinationIndex & (1 << setElementIndex)) {
  22. subSet.push(originalSet[setElementIndex]);
  23. }
  24. }
  25. // Add current subset to the list of all subsets.
  26. subSets.push(subSet);
  27. }
  28. return subSets;
  29. }

src/views/goods/components/goods-sku.vue

  1. import getPowerSet from '@/vender/power-set.js'
  2. const spliter = '★'
  3. const getPathMap = (originSet) => {
  4. // 最终形成的路径字典
  5. const result = {}
  6. // 遍历所有的sku信息
  7. originSet.forEach(sku => {
  8. if (sku.inventory === 0) return
  9. // 后续处理表示有效SKU
  10. // 获取有效的SKU值:[蓝色, 中国, 20cm]
  11. const validSku = sku.specs.map(item => item.valueName)
  12. // 计算SKU的子集(笛卡尔集)
  13. const subset = getPowerSet(validSku)
  14. // 遍历subset,生成路径字典
  15. subset.forEach(path => {
  16. // 排除空集
  17. if (path.length === 0) return
  18. // 基于子集项目拼接字符串
  19. const pathKey = path.join(spliter)
  20. if (result[pathKey]) {
  21. // 字典已经存在该属性
  22. result[pathKey].push(sku.id)
  23. } else {
  24. // 字典中尚不存在该属性
  25. result[pathKey] = [sku.id]
  26. }
  27. })
  28. })
  29. return result
  30. }
  1. + setup (props) {
  2. + const pathMap = getPathMap(props.goods.skus)
  3. + console.log(pathMap)
  • 参照示例

image.png

★规格组件-禁用效果-设置状态

image.png

目的:在组件初始化的时候,点击规格的时候,去更新其他按钮的禁用状态。

大致的步骤:

  • 再需要更新状态的时候获取当前选中的规格数组
  • 遍历所有的规格按钮,拿出按钮的值设置给规格数组,然后得到key
  • 拿着key去路径字典中查找,有就可点击,没有禁用即可。

image.png

src/views/goods/components/goods-sku.vue

  1. // 获取当前选中的规格数据
  2. const getSelectedValue = (specs) => {
  3. const result = []
  4. specs.forEach((item, i) => {
  5. const tagObj = item.values.find(tag => tag.selected)
  6. if (tagObj) {
  7. // 其中一个选项选中了,存取选中的标签的名称
  8. result[i] = tagObj.name
  9. } else {
  10. // 一个标签也没有选中
  11. result[i] = undefined
  12. }
  13. })
  14. return result
  15. }
  1. // 控制规格标签是否被禁用
  2. const updateDisabledStatus = (specs, pathMap) => {
  3. // seletedValues = [undefined, undefined, undefined]
  4. specs.forEach((spec, i) => {
  5. // 每次规格的遍历,选中的值需要重新初始化
  6. const seletedValues = getSelectedValue(specs)
  7. spec.values.forEach(tag => {
  8. if (tag.selected) {
  9. // 标签本身就是选中状态,不需要处理
  10. return
  11. } else {
  12. // 没有选中(初始化时,需要判断当个规格的禁用状态)
  13. seletedValues[i] = tag.name
  14. }
  15. // 此时,需要判断当前的按钮是否应该被禁用
  16. // 基于当前选中的值,组合一个路径
  17. // 过滤掉undefined值,基于剩余的值组合一个路径
  18. let currentPath = seletedValues.filter(item => item)
  19. if (currentPath.length > 0) {
  20. // 拼接路径字符串 currentPath = 黑色★10cm
  21. currentPath = currentPath.join(spliter)
  22. // 判断当前的路径是否在路径字典中(如果在字典中没有找到该路径,证明当前的标签应该禁用)
  23. tag.disabled = !pathMap[currentPath]
  24. }
  25. // 单独判断单个按钮是否应该禁用
  26. // tag.disabled = !pathMap[tag.name]
  27. })
  28. })
  29. }
  1. setup (props) {
  2. const pathMap = getPathMap(props.goods.skus)
  3. // 组件初始化的时候更新禁用状态
  4. + updateDisabledStatus(props.goods.specs, pathMap)
  5. const clickSpecs = (item, val) => {
  6. // 如果是禁用状态不作为
  7. + if (val.disabled) return
  8. // 1. 选中与取消选中逻辑
  9. if (val.selected) {
  10. val.selected = false
  11. } else {
  12. item.values.find(bv => { bv.selected = false })
  13. val.selected = true
  14. }
  15. // 点击的时候更新禁用状态
  16. + updateDisabledStatus(props.goods.specs, pathMap)
  17. }
  18. return { clickSpecs }
  19. }

总结

  1. 判断初始状态按钮的禁用效果
  2. 判断点击按钮后,每一个按钮的禁用状态

回顾

  • 城市选择组件
    • 配置组件的基本结构
    • 控制选择城市弹窗的展示和隐藏
    • 获取城市列表数据:抽取通用地址,方便维护;添加数据缓存,提高性能。
    • 添加加载数据的状态:提升用户体验;配置webpack转换图片base64格式(提高性能)
    • 控制城市默认数据的显示:父组件向子组件传递数据
    • 控制省市区列表数据的变化:计算属性的使用
    • 获取省市区的数据:子组件向父组件传递数据
    • 点击弹窗之外关闭弹窗:基于vueuse提供的方法实现
  • 商品规格的操作组件
    • SPU和SKU的概念的理解
    • 商品规格参数选择和禁用状态的验证流程分析
    • 准备组件的基本结构
    • 控制规格的选中和反选
    • 熟悉子集的转换算法:基于第三方封装好的方法计算数组的子集
    • 计算规格路径字典
    • 封装获取规格值的方法:获取选择的所有规格数据
    • 基于选中的规格值,去路径字典中判断是否存在,如果不存在就禁用对应的规格

★规格组件-数据通讯

目的:根据传入的skuId进行默认选中,选择规格后触发change事件传出选择的sku数据。

大致步骤:

  • 根据传入的SKUID选中对应规格按钮
  • 选择规格后传递sku信息给父组件

    • 完整规格,传 skuId 价格 原价 库存 规格文字
    • 不完整的,传 空对象


    落的代码:

  • 根据传人的sku设置默认选中的规格 src/views/goods/components/goods-sku.vue

  1. skuId: {
  2. type: String,
  3. default: ''
  4. }
  1. // 初始化规格的选中状态(根据skuId)
  2. const initSkuSeletedStatus = (skuId, specs, skus) => {
  3. // 1、根据SKUId获取对应的sku详细信息
  4. const currentSku = skus.find(item => item.id === skuId)
  5. // 2、控制currentSku.specs中的规格进行选中
  6. specs.forEach(item => {
  7. // 3、得到需要选中的规格的值
  8. const selectedValue = currentSku.specs.find(skuItem => skuItem.name === item.name).valueName
  9. // 4、根据selectedValue控制规格的选中
  10. item.values.find(tag => tag.name === selectedValue).selected = true
  11. })
  12. }
  1. setup (props, { emit }) {
  2. const pathMap = getPathMap(props.goods.skus)
  3. // 根据传入的skuId默认选中规格按钮
  4. + // 根据SKUId初始化规格的选中状态
  5. + if (props.skuId) {
  6. + initSkuSeletedStatus(props.skuId, props.specs, props.skus)
  7. + }
  8. // 组件初始化的时候更新禁用状态
  9. updateDisabledStatus(props.goods.specs, pathMap)

总结:根据SKUId中的规格数据控制规格的选中

  • 根据选择的完整sku规格传出sku信息

    • 其中传出的specsText是提供给购物车存储使用的。


    src/views/goods/components/goods-sku.vue

  1. + setup (props, { emit }) {
  1. const clickSpecs = (item, val) => {
  2. // 如果是禁用状态不作为
  3. if (val.disabled) return false
  4. // 1. 选中与取消选中逻辑
  5. if (val.selected) {
  6. val.selected = false
  7. } else {
  8. item.values.find(bv => { bv.selected = false })
  9. val.selected = true
  10. }
  11. // 点击的时候更新禁用状态
  12. updateDisabledStatus(props.goods.specs, pathMap)
  13. + // 获取此时选中的规格的所有的值,传递给父组件
  14. + // 1、如果所有的规格都选择了才是合理的
  15. + // 2、如果有未选的的规格,就不应该得到数据
  16. + const result = getSelectedValue(props.specs)
  17. + if (result.filter(item => item).length === props.specs.length) {
  18. + // 所有的规格都进行了选择
  19. + // 有效数据:skuId,price,oldPrice,inventory,specsText (来源于SKU记录)
  20. + // 根据当前的选中的规格结果,拼接路径字典key
  21. + const pathKey = result.join(spliter)
  22. + // 根据路径获取路径字典中存储的skuId
  23. + const skuId = pathMap[pathKey][0]
  24. + // 根据SKUId获取详细数据
  25. + const sku = props.skus.find(item => item.id === skuId)
  26. + // 拼接specsText数据
  27. + let specsText = ''
  28. + sku.specs.forEach(item => {
  29. + specsText += item.name + ':' + item.valueName + ','
  30. + })
  31. + if (specsText.length > 0) {
  32. + specsText = specsText.substring(0, specsText.length - 1)
  33. + }
  34. + // 组合有效数据
  35. + const specInfo = {
  36. + skuId: skuId,
  37. + price: sku.price,
  38. + oldPrice: sku.oldPrice,
  39. + inventory: sku.inventory,
  40. + specsText: specsText
  41. + }
  42. + emit('sku-info', specInfo)
  43. + } else {
  44. + // 还有规格没有选
  45. + emit('sku-info', {})
  46. + }

src/views/goods/index.vue

  1. <GoodsSku @sku-info='skuInfo' :specs='goodsDetail.specs' :skus='goodsDetail.skus' />
  1. setup () {
  2. const goods = useGoods()
  3. // sku改变时候触发
  4. + const skuInfo = (sku) => {
  5. + if (sku.skuId) {
  6. + goods.value.price = sku.price
  7. + goods.value.oldPrice = sku.oldPrice
  8. + goods.value.inventory = sku.inventory
  9. + }
  10. + }
  11. + return { goods, changeSku }
  12. }
  • 基于数组的reduce方法重构拼接字符串的逻辑
  1. let specsText = ['', ...sku.specs].reduce((result, item) => result + item.name + ':' + item.valueName + ',')
  2. specsText = specsText.length > 0 && specsText.substring(0, specsText.length - 1)
  3. // 组合有效数据
  4. const specInfo = {
  5. skuId: skuId,
  6. price: sku.price,
  7. oldPrice: sku.oldPrice,
  8. inventory: sku.inventory,
  9. specsText: specsText
  10. }

总结:数组的reduce方法的基本使用

  1. 参数一表示什么意思?上一次计算的结果,t的初始值值是数组的第一项数据,后续的值是上一次计算的结果
  2. 参数二表示arr数组的其中一项数据,从第二项开始

商品详情-数量选择组件

目的:封装一个通用的数量选中组件。

image.png

大致功能分析:

  • 默认值为1
  • 可限制最大最小值
  • 点击-就是减1 点击+就是加1
  • 需要完成v-model得实现
  • 存在无label情况

基础布局代码:src/components/library/xtx-numbox.vue

  1. <template>
  2. <div class="xtx-numbox">
  3. <div class="label">数量</div>
  4. <div class="numbox">
  5. <a href="javascript:;">-</a>
  6. <input type="text" readonly value="1">
  7. <a href="javascript:;">+</a>
  8. </div>
  9. </div>
  10. </template>
  11. <script>
  12. export default {
  13. name: 'XtxNumbox'
  14. }
  15. </script>
  16. <style scoped lang="less">
  17. .xtx-numbox {
  18. display: flex;
  19. align-items: center;
  20. .label {
  21. width: 60px;
  22. color: #999;
  23. padding-left: 10px;
  24. }
  25. .numbox {
  26. width: 120px;
  27. height: 30px;
  28. border: 1px solid #e4e4e4;
  29. display: flex;
  30. > a {
  31. width: 29px;
  32. line-height: 28px;
  33. text-align: center;
  34. background: #f8f8f8;
  35. font-size: 16px;
  36. color: #666;
  37. &:first-of-type {
  38. border-right:1px solid #e4e4e4;
  39. }
  40. &:last-of-type {
  41. border-left:1px solid #e4e4e4;
  42. }
  43. }
  44. > input {
  45. width: 60px;
  46. padding: 0 5px;
  47. text-align: center;
  48. color: #666;
  49. }
  50. }
  51. }
  52. </style>

逻辑功能实现:

src/components/library/xtx-numbox.vue

  1. <script>
  2. import { useVModel } from '@vueuse/core'
  3. export default {
  4. name: 'XtxNumbox',
  5. props: {
  6. label: {
  7. type: String,
  8. default: ''
  9. },
  10. modelValue: {
  11. type: Number,
  12. default: 1
  13. },
  14. min: {
  15. type: Number,
  16. default: 1
  17. },
  18. max: {
  19. type: Number,
  20. required: true
  21. }
  22. },
  23. setup (props, { emit }) {
  24. const n = useVModel(props, 'modelValue', emit)
  25. // 控制商品数量变更
  26. const toggle = (step) => {
  27. let num = props.modelValue + step
  28. if (num <= 1) {
  29. // 控制最小值
  30. num = 1
  31. } else if (num >= props.max) {
  32. // 控制最大值
  33. num = props.max
  34. }
  35. // emit('update:modelValue', num)
  36. n.value = num
  37. }
  38. return { toggle, n }
  39. }
  40. }
  41. </script>

src/views/goods/index.vue

  1. <XtxNumbox v-model='num' label='数量' :max='goodsDetail.inventory' />
  1. // 选择的数量
  2. + const num = ref(1)
  3. + return { toggle, n, num }

总结:

  1. 父向子传递数据
  2. 子向父传递数据
  3. 基于第三方vueuse提供的方法useVModel优化父子之间的数据传递

商品详情-按钮组件

目的:封装一个通用按钮组件,有大、中、小、超小四种尺寸,有默认、主要、次要、灰色四种类型。

大致步骤:

  • 完成组件基本结构
  • 介绍各个参数的使用
  • 测试按钮组件

落地代码:

  • 封装组件:src/components/library/xtx-button.vue
  1. <template>
  2. <button class="xtx-button ellipsis" :class="[size,type]">
  3. <slot />
  4. </button>
  5. </template>
  6. <script>
  7. export default {
  8. name: 'XtxButton',
  9. props: {
  10. size: {
  11. type: String,
  12. default: 'middle'
  13. },
  14. type: {
  15. type: String,
  16. default: 'default'
  17. }
  18. }
  19. }
  20. </script>
  21. <style scoped lang="less">
  22. .xtx-button {
  23. appearance: none;
  24. border: none;
  25. outline: none;
  26. background: #fff;
  27. text-align: center;
  28. border: 1px solid transparent;
  29. border-radius: 4px;
  30. cursor: pointer;
  31. }
  32. .large {
  33. width: 240px;
  34. height: 50px;
  35. font-size: 16px;
  36. }
  37. .middle {
  38. width: 180px;
  39. height: 50px;
  40. font-size: 16px;
  41. }
  42. .small {
  43. width: 100px;
  44. height: 32px;
  45. font-size: 14px;
  46. }
  47. .mini {
  48. width: 60px;
  49. height: 32px;
  50. font-size: 14px;
  51. }
  52. .default {
  53. border-color: #e4e4e4;
  54. color: #666;
  55. }
  56. .primary {
  57. border-color: @xtxColor;
  58. background: @xtxColor;
  59. color: #fff;
  60. }
  61. .plain {
  62. border-color: @xtxColor;
  63. color: @xtxColor;
  64. background: lighten(@xtxColor,50%);
  65. }
  66. .gray {
  67. border-color: #ccc;
  68. background: #ccc;;
  69. color: #fff;
  70. }
  71. </style>
  • 使用组件:src/views/goods/index.vue
  1. <div class="spec">
  2. <GoodsName :goods="goods"/>
  3. <GoodsSku :goods="goods" @change="changeSku"/>
  4. <XtxNumbox label="数量" v-model="num" :max="goods.inventory"/>
  5. + <XtxButton type="primary" style="margin-top:20px;">加入购物车</XtxButton>
  6. </div>

总结:封装通用的按钮组件,抽取尺寸和样式属性;基于默认插槽定制按钮文字。

商品详情-同类推荐组件

目的:实现商品的同类推荐与猜你喜欢展示功能。

image.png

大致功能需求:

  • 完成基础布局(头部),后期改造xtx-carousel.vue组件来展示商品效果。
  • 然后可以通过是否传入商品ID来区别同类推荐和猜你喜欢。

落的代码开始:

  • 基础布局 src/views/goods/components/goods-relevant.vue
  1. <template>
  2. <div class="goods-relevant">
  3. <div class="header">
  4. <i class="icon" />
  5. <span class="title">同类商品推荐</span>
  6. </div>
  7. <!-- 此处使用改造后的xtx-carousel.vue -->
  8. </div>
  9. </template>
  10. <script>
  11. export default {
  12. // 同类推荐,猜你喜欢
  13. name: 'GoodsRelevant'
  14. }
  15. </script>
  16. <style scoped lang='less'>
  17. .goods-relevant {
  18. background: #fff;
  19. min-height: 460px;
  20. margin-top: 20px;
  21. .header {
  22. height: 80px;
  23. line-height: 80px;
  24. padding: 0 20px;
  25. .title {
  26. font-size: 20px;
  27. padding-left: 10px;
  28. }
  29. .icon {
  30. width: 16px;
  31. height: 16px;
  32. display: inline-block;
  33. border-top: 4px solid @xtxColor;
  34. border-right: 4px solid @xtxColor;
  35. box-sizing: border-box;
  36. position: relative;
  37. transform: rotate(45deg);
  38. &::before {
  39. content: "";
  40. width: 10px;
  41. height: 10px;
  42. position: absolute;
  43. left: 0;
  44. top: 2px;
  45. background: lighten(@xtxColor, 40%);
  46. }
  47. }
  48. }
  49. }
  50. </style>
  • 获取数据传入xtx-carousel.vue组件 src/views/goods/index.vue 传ID
  1. <!-- 商品推荐 -->
  2. <GoodsRelevant :goodsId="goodsDetail.id"/>
  • 定义获取数据的APIsrc/api/goods.js
  1. /**
  2. * 获取商品同类推荐-未传入ID为猜喜欢
  3. * @param {String} id - 商品ID
  4. * @param {Number} limit - 获取条数
  5. */
  6. export const findRelGoods = (id, limit = 16) => {
  7. return request({
  8. method: 'get',
  9. url: '/goods/relevant',
  10. data: { id, limit }
  11. })
  12. }
  • 获取数据 src/views/goods/components/goods-relevant.vue
  1. <div class="header">
  2. <i class="icon" />
  3. + <span class="title">{{goodsId?'同类商品推荐':'猜你喜欢'}}</span>
  4. </div>
  1. <script>
  2. import { findRelGoods } from '@/api/product.js'
  3. import { ref } from 'vue'
  4. const useGoodsList = (goodsId) => {
  5. const list = ref([])
  6. findRelGoods(goodsId).then(data => {
  7. // list.value = data.result
  8. // 原始的数据data.result一共16条数据
  9. // 现在需要每页显示4条
  10. const pageSize = 4
  11. // 计算出总页数
  12. const perPageNum = Math.ceil(data.result.length / pageSize)
  13. // 对原始数据数据进行分页处理
  14. for (let i = 0; i < perPageNum; i++) {
  15. // 每一页的数据
  16. const pageArr = data.result.slice(i * pageSize, (i + 1) * pageSize)
  17. list.value.push(pageArr)
  18. }
  19. })
  20. return list
  21. }
  22. export default {
  23. // 同类推荐,猜你喜欢
  24. name: 'GoodsRelevant',
  25. props: {
  26. goodsId: {
  27. type: String,
  28. required: true
  29. }
  30. },
  31. setup (props) {
  32. // list = [[], [], [], []]
  33. const list = useGoodsList(props.goodsId)
  34. return { list }
  35. }
  36. }
  37. </script>
  1. <!-- 此处使用改造后的xtx-carousel.vue -->
  2. <XtxCarousel :slides="list" style="height:380px" auto-play />
  • 改造xtx-carousel.vue组件 src/components/library/xtx-carousel.vue
  1. + <RouterLink v-if="item.hrefUrl" :to="item.hrefUrl">
  2. <img :src="item.imgUrl" alt="">
  3. </RouterLink>
  4. + <div v-else class="slider">
  5. + <RouterLink v-for="goods in item" :key="goods.id" :to="`/product/${goods.id}`">
  6. + <img :src="goods.picture" alt="">
  7. + <p class="name ellipsis">{{goods.name}}</p>
  8. + <p class="price">&yen;{{goods.price}}</p>
  9. + </RouterLink>
  1. // 轮播商品
  2. .slider {
  3. display: flex;
  4. justify-content: space-around;
  5. padding: 0 40px;
  6. > a {
  7. width: 240px;
  8. text-align: center;
  9. img {
  10. padding: 20px;
  11. width: 230px!important;
  12. height: 230px!important;
  13. }
  14. .name {
  15. font-size: 16px;
  16. color: #666;
  17. padding: 0 40px;
  18. }
  19. .price {
  20. font-size: 16px;
  21. color: @priceColor;
  22. margin-top: 15px;
  23. }
  24. }
  25. }
  • 覆盖xtx-carousel.vue的样式在 src/views/goods/components/goods-relevant.vue
  1. :deep(.xtx-carousel) {
  2. height: 380px;
  3. .carousel {
  4. &-indicator {
  5. bottom: 30px;
  6. span {
  7. &.active {
  8. background: @xtxColor;
  9. }
  10. }
  11. }
  12. &-btn {
  13. top: 110px;
  14. opacity: 1;
  15. background: rgba(0,0,0,0);
  16. color: #ddd;
  17. i {
  18. font-size: 30px;
  19. }
  20. }
  21. }
  22. }

注意:vue3.0使用深度作用选择器写法 :deep(选择器)

商品详情-标签页组件

目的:实现商品详情组件和商品评价组件的切换

image.png

大致步骤:

  • 完成基础的tab的导航布局
  • 完成tab标签页的切换样式效果
  • 使用动态组件完成可切换 详情 和 评论 组件

落的代码:

  • 标签页基础布局 src/vies/goods/components/goods-tabs.vue
  1. <div class="goods-tabs">
  2. <nav>
  3. <a class="active" href="javascript:;">商品详情</a>
  4. <a href="javascript:;">商品评价<span>(500+)</span></a>
  5. </nav>
  6. <!-- 切换内容的地方 -->
  7. </div>
  1. .goods-tabs {
  2. min-height: 600px;
  3. background: #fff;
  4. nav {
  5. height: 70px;
  6. line-height: 70px;
  7. display: flex;
  8. border-bottom: 1px solid #f5f5f5;
  9. a {
  10. padding: 0 40px;
  11. font-size: 18px;
  12. position: relative;
  13. > span {
  14. color: @priceColor;
  15. font-size: 16px;
  16. margin-left: 10px;
  17. }
  18. &:first-child {
  19. border-right: 1px solid #f5f5f5;
  20. }
  21. &.active {
  22. &::before {
  23. content: "";
  24. position: absolute;
  25. left: 40px;
  26. bottom: -1px;
  27. width: 72px;
  28. height: 2px;
  29. background: @xtxColor;
  30. }
  31. }
  32. }
  33. }
  34. }
  • tabs组件切换 src/vies/goods/components/goods-tabs.vue
  1. <template>
  2. <div class="goods-tabs">
  3. <nav>
  4. <a @click='toggle("GoodsDetail")' :class="{active: componentName === 'GoodsDetail'}" href="javascript:;">商品详情</a>
  5. <a @click='toggle("GoodsComment")' :class="{active: componentName === 'GoodsComment'}" href="javascript:;">商品评价<span>(500+)</span></a>
  6. </nav>
  7. <!-- 切换内容的地方 -->
  8. <!-- <GoodsDetail v-if='currentIndex === 0'/> -->
  9. <!-- <GoodsComment v-if='currentIndex === 1'/> -->
  10. <!-- 基于动态组件控制组件的切换 -->
  11. <component :is='componentName'></component>
  12. </div>
  13. </template>
  14. <script>
  15. import GoodsDetail from './goods-detail.vue'
  16. import GoodsComment from './goods-comment.vue'
  17. import { ref } from 'vue'
  18. export default {
  19. name: 'GoodsTabs',
  20. components: { GoodsDetail, GoodsComment },
  21. setup () {
  22. // 当前组件的名称
  23. const componentName = ref('GoodsDetail')
  24. const toggle = (name) => {
  25. componentName.value = name
  26. }
  27. return { toggle, componentName }
  28. }
  29. }
  30. </script>
  • 使用tabs组件 src/views/goods/index.vue
  1. +import GoodsTabs from './components/goods-tabs'
  2. // ... 省略
  3. export default {
  4. name: 'XtxGoodsPage',
  5. + components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku, GoodsTabs },
  6. setup () {
  1. <div class="goods-article">
  2. <!-- 商品+评价 -->
  3. + <GoodsTabs :goods="goods" />
  4. <!-- 注意事项 -->
  5. <div class="goods-warn"></div>
  6. </div>
  1. -.goods-tabs {
  2. - min-height: 600px;
  3. - background: #fff;
  4. -}
  • 定义详情组件, src/vies/goods/components/goods-detail.vue
  1. <template>
  2. <div class="goods-detail">详情</div>
  3. </template>
  4. <script>
  5. export default {
  6. name: 'GoodsDetail'
  7. }
  8. </script>
  9. <style scoped lang="less"></style>
  • 定义评价组件。src/vies/goods/components/goods-comment.vue
  1. <template>
  2. <div class="goods-comment">评价</div>
  3. </template>
  4. <script>
  5. export default {
  6. name: 'GoodsComment'
  7. }
  8. </script>
  9. <style scoped lang="less"></style>

总结:

  1. 封装Tab选项卡组件并实现切换功能
  2. 基于动态组件实现组件的切换

商品详情-热榜组件

目的:展示24小时热榜商品,和周热榜商品。

image.png

大致步骤:

  • 定义一个组件,完成多个组件展现型态,根据传入组件的类型决定。
    • 1代表24小时热销榜 2代表周热销榜 3代表总热销榜
  • 获取数据,完成商品展示和标题样式的设置。

落的代码:

  • 定义组件 src/views/goods/components/goods-hot.vue
  1. <template>
  2. <div class="goods-hot">
  3. <h3>{{title}}</h3>
  4. </div>
  5. </template>
  6. <script>
  7. import { computed } from 'vue'
  8. export default {
  9. name: 'GoodsHot',
  10. props: {
  11. type: {
  12. type: Number,
  13. default: 1
  14. }
  15. },
  16. setup (props) {
  17. const titleObj = { 1: '24小时热销榜', 2: '周热销榜', 3: '总热销榜' }
  18. const title = computed(() => {
  19. return titleObj[props.type]
  20. })
  21. return { title }
  22. }
  23. }
  24. </script>
  25. <style scoped lang="less"></style>
  • 使用组件 src/views/goods/index.vue
  1. +import GoodsHot from './components/goods-hot'
  2. // ... 省略
  3. name: 'XtxGoodsPage',
  4. + components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku, GoodsTabs, GoodsHot },
  5. setup () {
  1. <!-- 24热榜+专题推荐 -->
  2. <div class="goods-aside">
  3. <GoodsHot :goodsId="goods.id" :type="1" />
  4. <GoodsHot :goodsId="goods.id" :type="2" />
  5. </div>
  • 获取数据,设置组件样式src/api/goods.js
  1. /**
  2. * 获取热榜商品
  3. * @param {Number} type - 1代表24小时热销榜 2代表周热销榜 3代表总热销榜
  4. * @param {Number} limit - 获取个数
  5. */
  6. export const findHotGoods = ({id,type, limit = 3}) => {
  7. return request({
  8. method: 'get',
  9. url: '/goods/hot',
  10. data: {id, type, limit }
  11. })
  12. }

src/views/goods/components/goot-hot.vue

  1. import { computed, ref } from 'vue'
  2. import GoodsItem from '../../category/components/goods-item'
  3. import { findHotGoods } from '@/api/goods'
  4. export default {
  5. name: 'GoodsHot',
  6. props: {
  7. type: {
  8. type: Number,
  9. default: 1
  10. },
  11. goodsId: {
  12. type: String
  13. }
  14. },
  15. components: { GoodsItem },
  16. setup (props) {
  17. // 处理标题
  18. const titleObj = { 1: '24小时热销榜', 2: '周热销榜', 3: '总热销榜' }
  19. const title = computed(() => {
  20. return titleObj[props.type]
  21. })
  22. // 商品列表
  23. const goodsList = ref([])
  24. findHotGoods({ id: props.goodsId, type: props.type }).then(data => {
  25. goodsList.value = data.result.map(item => {
  26. item.tag = item.desc
  27. return item
  28. })
  29. })
  30. return { title, goodsList }
  31. }
  32. }
  1. <template>
  2. <div class="goods-hot">
  3. <h3>{{title}}</h3>
  4. <div v-if="list">
  5. <GoodsItem v-for="item in list" :key="item.id" :info="item"/>
  6. </div>
  7. </div>
  8. </template>
  1. .goods-hot {
  2. h3 {
  3. height: 70px;
  4. background: @helpColor;
  5. color: #fff;
  6. font-size: 18px;
  7. line-height: 70px;
  8. padding-left: 25px;
  9. margin-bottom: 10px;
  10. font-weight: normal;
  11. }
  12. ::v-deep .goods-item {
  13. background: #fff;
  14. width: 100%;
  15. margin-bottom: 10px;
  16. img {
  17. width: 200px;
  18. height: 200px;
  19. }
  20. p {
  21. margin: 0 10px;
  22. }
  23. &:hover {
  24. transform: none;
  25. box-shadow: none;
  26. }
  27. }
  28. }

总结:抽取组件时,需要定制变化的数据作为属性,计算属性的使用。

商品详情-详情组件

目的:展示商品属性和商品详情。

image.png

大致步骤:

  • 完成基础布局,主要是属性,详情是图片。
  • goods/index.vue 提供goods数据,子孙组件注入goods数据,渲染展示即可。

落的代码:

  • 传递goods数据src/views/goods/index.vue setup中提供数据
  1. provide('goods', goodsDetail)
  • 使用goods数据,展示评价数量src/views/goods/components/goods-tabs.vue
  1. setup () {
  2. const goods = inject('goods')
  3. return { goods }
  4. },
  1. + >商品评价<span>({{goods.commentCount}})</span></a
  • 使用goods数据,展示商品详情src/views/goods/components/goods-detail.vue
  1. <template>
  2. <div class="goods-detail">
  3. <!-- 属性 -->
  4. <ul class="attrs">
  5. <li v-for="item in goods.details.properties" :key="item.value">
  6. <span class="dt">{{item.name}}</span>
  7. <span class="dd">{{item.value}}</span>
  8. </li>
  9. </ul>
  10. <!-- 图片 -->
  11. <img v-for="item in goods.details.pictures" :key="item" :src="item" alt="">
  12. </div>
  13. </template>
  14. <script>
  15. import { inject } from 'vue'
  16. export default {
  17. name: 'GoodsDetail',
  18. setup () {
  19. const goods = inject('goods')
  20. return { goods }
  21. }
  22. }
  23. </script>
  24. <style scoped lang="less">
  25. .goods-detail {
  26. padding: 40px;
  27. .attrs {
  28. display: flex;
  29. flex-wrap: wrap;
  30. margin-bottom: 30px;
  31. li {
  32. display: flex;
  33. margin-bottom: 10px;
  34. width: 50%;
  35. .dt {
  36. width: 100px;
  37. color: #999;
  38. }
  39. .dd {
  40. flex: 1;
  41. color: #666;
  42. }
  43. }
  44. }
  45. > img {
  46. width: 100%;
  47. }
  48. }
  49. </style>

总结:

  1. 父组件向孙子组件传递数据:provide提供数据,inject接收数据

商品详情-注意事项组件

目的:展示购买商品的注意事项。

  • 商品详情首页 src/views/goods/index.vue
  1. +import GoodsWarn from './components/goods-warn'
  1. name: 'XtxGoodsPage',
  2. + components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku, GoodsTabs, GoodsHot, GoodsWarn },
  3. setup () {
  1. <!-- 注意事项 -->
  2. + <GoodsWarn />
  • 注意事项组件src/views/goods/components/goods-warn.vue
  1. <template>
  2. <!-- 注意事项 -->
  3. <div class="goods-warn">
  4. <h3>注意事项</h3>
  5. <p class="tit">• 购买运费如何收取?</p>
  6. <p>
  7. 单笔订单金额(不含运费)满88元免邮费;不满88元,每单收取10元运费。(港澳台地区需满500元免邮费;不满500元,每单收取30元运费)
  8. </p>
  9. <br />
  10. <p class="tit">• 使用什么快递发货?</p>
  11. <p>默认使用顺丰快递发货(个别商品使用其他快递)</p>
  12. <p>配送范围覆盖全国大部分地区(港澳台地区除外)</p>
  13. <br />
  14. <p class="tit">• 如何申请退货?</p>
  15. <p>
  16. 1.自收到商品之日起30日内,顾客可申请无忧退货,退款将原路返还,不同的银行处理时间不同,预计1-5个工作日到账;
  17. </p>
  18. <p>2.内裤和食品等特殊商品无质量问题不支持退货;</p>
  19. <p>
  20. 3.退货流程:
  21. 确认收货-申请退货-客服审核通过-用户寄回商品-仓库签收验货-退款审核-退款完成;
  22. </p>
  23. <p>
  24. 4.因小兔鲜儿产生的退货,如质量问题,退货邮费由小兔鲜儿承担,退款完成后会以现金券的形式报销。因客户个人原因产生的退货,购买和寄回运费由客户个人承担。
  25. </p>
  26. </div>
  27. </template>
  28. <style lang="less" scoped>
  29. .goods-warn {
  30. margin-top: 20px;
  31. background: #fff;
  32. padding-bottom: 40px;
  33. h3 {
  34. height: 70px;
  35. line-height: 70px;
  36. border-bottom: 1px solid #f5f5f5;
  37. padding-left: 50px;
  38. font-size: 18px;
  39. font-weight: normal;
  40. margin-bottom: 10px;
  41. }
  42. p {
  43. line-height: 40px;
  44. padding: 0 25px;
  45. color: #666;
  46. &.tit {
  47. color: #333;
  48. }
  49. }
  50. }
  51. </style>

回顾

  • 规格组件
    • 根据SKUID控制标签规格的选中状态
    • 获取选中的规格数据
    • 熟悉数组的reduce方法
  • 控制商品数量组件
    • 控制数量的加和减:父子组件之间的数据交互
    • 基于Vueuse提供的方法简化父子组件的交互
  • 按钮组件
    • 实现通用的按钮组件:父组件向子组件传值
  • 相关推荐组件
    • 基于之前封装的轮播图效果进行定制
    • 不要修改通用的组件样式,而是根据需求定制组件样式
  • 标签页组件
    • 控制Tab的切换:基于动态组件方式实现
  • 商品详情组件
    • 动态渲染动态的熟悉和相关的图片
  • 注意事项组件
    • 添加一个布局组件
  • 商品评论组件
    • 准备基本布局,获取接口数据
    • 基于mock的接口获取数据
    • 基于axios调用接口,如果请求地址以http等标准的协议开头,不会拼接基准路径

商品详情-评价组件-头部渲染

image.png

目的:根据后台返回的评价信息渲染评价头部内容。

大致步骤:

  • 完成静态布局
  • 定义API接口
  • 获取数据,处理完毕,提供给模版
  • 渲染模版

落的代码:

  • 布局 src/views/goods/components/goods-comment.vue
  1. <template>
  2. <div class="goods-comment">
  3. <div class="head">
  4. <div class="data">
  5. <p><span>100</span><span>人购买</span></p>
  6. <p><span>99.99%</span><span>好评率</span></p>
  7. </div>
  8. <div class="tags">
  9. <div class="dt">大家都在说:</div>
  10. <div class="dd">
  11. <a href="javascript:;" class="active">全部评价(1000)</a>
  12. <a href="javascript:;">好吃(1000)</a>
  13. <a href="javascript:;">便宜(1000)</a>
  14. <a href="javascript:;">很好(1000)</a>
  15. <a href="javascript:;">再来一次(1000)</a>
  16. <a href="javascript:;">快递棒(1000)</a>
  17. </div>
  18. </div>
  19. </div>
  20. <div class="sort">
  21. <span>排序:</span>
  22. <a href="javascript:;" class="active">默认</a>
  23. <a href="javascript:;">最新</a>
  24. <a href="javascript:;">最热</a>
  25. </div>
  26. <div class="list"></div>
  27. </div>
  28. </template>
  29. <script>
  30. export default {
  31. name: 'GoodsComment'
  32. }
  33. </script>
  34. <style scoped lang="less">
  35. .goods-comment {
  36. .head {
  37. display: flex;
  38. padding: 30px 0;
  39. .data {
  40. width: 340px;
  41. display: flex;
  42. padding: 20px;
  43. p {
  44. flex: 1;
  45. text-align: center;
  46. span {
  47. display: block;
  48. &:first-child {
  49. font-size: 32px;
  50. color: @priceColor;
  51. }
  52. &:last-child {
  53. color: #999;
  54. }
  55. }
  56. }
  57. }
  58. .tags {
  59. flex: 1;
  60. display: flex;
  61. border-left: 1px solid #f5f5f5;
  62. .dt {
  63. font-weight: bold;
  64. width: 100px;
  65. text-align: right;
  66. line-height: 42px;
  67. }
  68. .dd {
  69. flex: 1;
  70. display: flex;
  71. flex-wrap: wrap;
  72. > a {
  73. width: 132px;
  74. height: 42px;
  75. margin-left: 20px;
  76. margin-bottom: 20px;
  77. border-radius: 4px;
  78. border: 1px solid #e4e4e4;
  79. background: #f5f5f5;
  80. color: #999;
  81. text-align: center;
  82. line-height: 40px;
  83. &:hover {
  84. border-color: @xtxColor;
  85. background: lighten(@xtxColor,50%);
  86. color: @xtxColor;
  87. }
  88. &.active {
  89. border-color: @xtxColor;
  90. background: @xtxColor;
  91. color: #fff;
  92. }
  93. }
  94. }
  95. }
  96. }
  97. .sort {
  98. height: 60px;
  99. line-height: 60px;
  100. border-top: 1px solid #f5f5f5;
  101. border-bottom: 1px solid #f5f5f5;
  102. margin: 0 20px;
  103. color: #666;
  104. > span {
  105. margin-left: 20px;
  106. }
  107. > a {
  108. margin-left: 30px;
  109. &.active,&:hover {
  110. color: @xtxColor;
  111. }
  112. }
  113. }
  114. }
  115. </style>
  • 接口 src/api/goods.js
  1. /**
  2. * 获取商品的评价统计信息
  3. * @param {String} id - 商品ID
  4. */
  5. // 获取商品的评论的统计数据
  6. export const findCommentInfoByGoods = (id) => {
  7. // return request({method: 'get',url: '`/goods/${id}/evaluate`'})
  8. return request({
  9. method: 'get',
  10. // 当请求地址是http或者是https等标准协议时,那么axios基准路径不会再次拼接
  11. // 评论数据没有正式的接口,如下的地址是模拟的假数据
  12. url: `https://mock.boxuegu.com/mock/1175/goods/${id}/evaluate`
  13. })
  14. }
  15. // https://mock.boxuegu.com/mock/1175/goods/${id}/evaluate
  • 获取数据,处理数据 src/views/goods/components/goods-comment.vue
  1. import { findCommentInfoByGoods } from '@/api/goods'
  2. import { ref } from 'vue'
  3. const getCommentInfo = (props) => {
  4. const commentInfo = ref(null)
  5. findCommentInfoByGoods(props.goods.id).then(data => {
  6. // type 的目的是将来点击可以区分点的是不是标签
  7. data.result.tags.unshift({ type: 'img', title: '有图', tagCount: data.result.hasPictureCount })
  8. data.result.tags.unshift({ type: 'all', title: '全部评价', tagCount: data.result.evaluateCount })
  9. commentInfo.value = data.result
  10. })
  11. return commentInfo
  12. }
  13. export default {
  14. name: 'GoodsComment',
  15. props: {
  16. goods: {
  17. type: Object,
  18. default: () => {}
  19. }
  20. },
  21. setup (props) {
  22. const commentInfo = getCommentInfo(props)
  23. return { commentInfo }
  24. }
  25. }
  • 渲染模版 + tag选中效果 src/views/goods/components/goods-comment.vue
  1. <template>
  2. <div class="goods-comment">
  3. <div class="head" v-if='commentInfo'>
  4. <div class="data">
  5. <p><span>{{commentInfo.salesCount}}</span><span>人购买</span></p>
  6. <p><span>{{commentInfo.praisePercent}}</span><span>好评率</span></p>
  7. </div>
  8. <div class="tags">
  9. <div class="dt">大家都在说:</div>
  10. <div class="dd">
  11. <a href="javascript:;" :class='{active: currentIndex === i}' v-for='(item, i) in commentInfo.tags' :key='i' @click='currentIndex = i'>{{item.title}}({{item.tagCount}})</a>
  12. </div>
  13. </div>
  14. </div>
  15. <div class="sort">
  16. <span>排序:</span>
  17. <a href="javascript:;" class="active">默认</a>
  18. <a href="javascript:;">最新</a>
  19. <a href="javascript:;">最热</a>
  20. </div>
  21. <div class="list"></div>
  22. </div>
  23. </template>
  24. <script>
  1. <script>
  2. import { ref, inject } from 'vue'
  3. import { findCommentInfoByGoods } from '@/api/product.js'
  4. export default {
  5. name: 'GoodsComment',
  6. setup () {
  7. // 当前选中的标签的索引
  8. const currentIndex = ref(0)
  9. // 商品详情数据
  10. const goods = inject('goods')
  11. // 评论配置信息
  12. const commentInfo = ref(null)
  13. findCommentInfoByGoods(goods.id).then(data => {
  14. // 手动添加两个选项
  15. data.result.tags.unshift({
  16. title: '有图',
  17. tagCount: data.result.hasPictureCount
  18. })
  19. data.result.tags.unshift({
  20. title: '全部评价',
  21. tagCount: data.result.evaluateCount
  22. })
  23. commentInfo.value = data.result
  24. })
  25. return { commentInfo, currentIndex }
  26. }
  27. }
  28. </script>

总结:

  1. 调用接口获取数据,渲染动态标签
  2. 控制标签选中的切换操作

商品详情-评价组件-实现列表

目的:完成列表渲染,筛选和排序。

image.png

大致步骤:

  • 列表基础布局
  • 筛选条件数据准备
  • 何时去获取数据?
    • 组件初始化
    • 点标签
    • 点排序
  • 渲染列表

落地代码:

  • 列表基础布局
  1. <!-- 列表 -->
  2. <div class="list">
  3. <div class="item">
  4. <div class="user">
  5. <img src="http://zhoushugang.gitee.io/erabbit-client-pc-static/uploads/avatar_1.png" alt="">
  6. <span>兔****m</span>
  7. </div>
  8. <div class="body">
  9. <div class="score">
  10. <i class="iconfont icon-wjx01"></i>
  11. <i class="iconfont icon-wjx01"></i>
  12. <i class="iconfont icon-wjx01"></i>
  13. <i class="iconfont icon-wjx01"></i>
  14. <i class="iconfont icon-wjx02"></i>
  15. <span class="attr">颜色:黑色 尺码:M</span>
  16. </div>
  17. <div class="text">网易云app上这款耳机非常不错 新人下载网易云购买这款耳机优惠大 而且耳机🎧确实正品 音质特别好 戴上这款耳机 听音乐看电影效果声音真是太棒了 无线方便 小盒自动充电 最主要是质量好音质棒 想要买耳机的放心拍 音效巴巴滴 老棒了</div>
  18. <div class="time">
  19. <span>2020-10-10 10:11:22</span>
  20. <span class="zan"><i class="iconfont icon-dianzan"></i>100</span>
  21. </div>
  22. </div>
  23. </div>
  24. </div>
  1. .list {
  2. padding: 0 20px;
  3. .item {
  4. display: flex;
  5. padding: 25px 10px;
  6. border-bottom: 1px solid #f5f5f5;
  7. .user {
  8. width: 160px;
  9. img {
  10. width: 40px;
  11. height: 40px;
  12. border-radius: 50%;
  13. overflow: hidden;
  14. }
  15. span {
  16. padding-left: 10px;
  17. color: #666;
  18. }
  19. }
  20. .body {
  21. flex: 1;
  22. .score {
  23. line-height: 40px;
  24. .iconfont {
  25. color: #ff9240;
  26. padding-right: 3px;
  27. }
  28. .attr {
  29. padding-left: 10px;
  30. color: #666;
  31. }
  32. }
  33. }
  34. .text {
  35. color: #666;
  36. line-height: 24px;
  37. }
  38. .time {
  39. color: #999;
  40. display: flex;
  41. justify-content: space-between;
  42. margin-top: 5px;
  43. }
  44. }
  45. }
  • 筛选条件数据准备:定义筛选条件
  1. // 筛选条件准备
  2. const reqParams = reactive({
  3. page: 1,
  4. pageSize: 10,
  5. hasPicture: null,
  6. tag: null,
  7. sortField: null
  8. })
  • 封装获取评论列表数据的接口方法
  1. // 获取评论列表数据
  2. export const findCommentListByGoods = (id, data) => {
  3. return request({
  4. method: 'get',
  5. url: `${mockBaseUrl}/1175/goods/${id}/evaluate/page`,
  6. data
  7. })
  8. }
  • 获取评论列表数据
  1. import { ref, inject, reactive } from 'vue'
  2. import { findCommentInfoByGoods, findCommentListByGoods } from '@/api/product.js'
  3. const useCommentList = (id, params) => {
  4. const list = ref([])
  5. findCommentListByGoods(id, params).then(data => {
  6. list.value = data.result
  7. })
  8. return list
  9. }
  1. setup () {
  2. // 获取评论的列表数据
  3. const list = useCommentList(goods.value.id, reqParams)
  4. }
  • 评论列表模板渲染
  1. <!-- 列表 -->
  2. <div class="list">
  3. <div class="item" v-for='item in list.items' :key='item.id'>
  4. <div class="user">
  5. <img :src="item.member.avatar" alt="">
  6. <span>{{item.member.nickname}}</span>
  7. </div>
  8. <div class="body">
  9. <div class="score">
  10. <i class="iconfont icon-wjx01" v-for='star in item.score' :key='star'></i>
  11. <i class="iconfont icon-wjx02" v-for='star in 5 - item.score' :key='star'></i>
  12. <span class="attr">{{item.orderInfo.specs.reduce((ret, item) => `${ret} ${item.name}:${item.nameValue}`, '')}}</span>
  13. </div>
  14. <div class="text">{{item.content}}</div>
  15. <div class="time">
  16. <span>{{item.createTime}}</span>
  17. <span class="zan"><i class="iconfont icon-dianzan"></i>{{item.praiseCount}}</span>
  18. </div>
  19. </div>
  20. </div>
  21. </div>
  • 收集排序条件
  1. <!-- 排序 -->
  2. <div class="sort">
  3. <span>排序:</span>
  4. <a
  5. @click="changeSort(null)"
  6. href="javascript:;"
  7. :class="{active:reqParams.sortField===null}"
  8. >默认</a>
  9. <a
  10. @click="changeSort('praiseCount')"
  11. href="javascript:;"
  12. :class="{active:reqParams.sortField==='praiseCount'}"
  13. >最热</a>
  14. <a
  15. @click="changeSort('createTime')"
  16. href="javascript:;"
  17. :class="{active:reqParams.sortField==='createTime'}"
  18. >最新</a>
  19. </div>
  1. // 改变排序
  2. const changeSort = (type) => {
  3. reqParams.sortField = type
  4. reqParams.page = 1
  5. }
  • 收集标签和是否有图条件
  1. const changeTag = (i) => {
  2. // 选中索引
  3. currentIndex.value = i
  4. // 获取标签数据对象
  5. const tag = commentInfo.value.tags[i]
  6. if (tag.type === 'all') {
  7. // 全部评价
  8. reqParams.hasPicture = null
  9. reqParams.tag = null
  10. reqParams.sortField = null
  11. } else if (tag.type === 'img') {
  12. // 是否有图
  13. reqParams.hasPicture = true
  14. reqParams.tag = null
  15. } else {
  16. // 后续的四个标签
  17. reqParams.tag = tag.title
  18. reqParams.hasPicture = false
  19. }
  20. // 重置页码
  21. reqParams.page = 1
  22. }
  • 监听条件的变化(当组件初始化的时候,筛选条件改变的时候)
  1. // 侦听排序和标签的参数变化
  2. const list = ref([])
  3. watch(reqParams, () => {
  4. // useCommentList(goods.value.id, reqParams, list)
  5. findCommentListByGoods(goods.id, reqParams).then(data => {
  6. list.value = data.result
  7. })
  8. }, {
  9. immediate: true
  10. })
  • 渲染模版:处理数据,昵称加*号,规格拼接字符串。
  1. // 定义转换数据的函数(对应vue2.0的过滤器)
  2. const formatSpecs = (specs) => {
  3. return specs.reduce((p, c) => `${p} ${c.name}:${c.nameValue}`, '').trim()
  4. }
  5. const formatNickname = (nickname) => {
  6. return nickname.substr(0, 1) + '****' + nickname.substr(-1)
  7. }

总结:

  1. 调用接口获取评论基本配置信息和类别数据
  2. 支持条件筛选

商品详情-评价组件-图片预览

目的:封装一个组件展示 图片列表 和 预览图片 功能。

image.png

大致步骤:

  • 准备一个组件导入goods-comment.vue使用起来,传入图片数据
  • 展示图片列表,和选中图片功能。
  • 提供图片预览功能和关闭图片预览。

落的代码:

  • 展示图片列表和选中效果实现src/views/goods/goods-comment-image.vue
  1. <template>
  2. <div class="goods-comment-image">
  3. <div class="list">
  4. <a
  5. href="javascript:;"
  6. :class="{active:currImage===url}"
  7. @click="currImage=url"
  8. v-for="url in pictures"
  9. :key="url"
  10. >
  11. <img :src="url" alt="">
  12. </a>
  13. </div>
  14. <div class="preview"></div>
  15. </div>
  16. </template>
  17. <script>
  18. import { ref } from 'vue'
  19. export default {
  20. name: 'GoodsCommentImage',
  21. props: {
  22. pictures: {
  23. type: Array,
  24. default: () => []
  25. }
  26. },
  27. setup () {
  28. const currImage = ref(null)
  29. return { currImage }
  30. }
  31. }
  32. </script>
  33. <style scoped lang="less">
  34. .goods-comment-image {
  35. .list {
  36. display: flex;
  37. flex-wrap: wrap;
  38. margin-top: 10px;
  39. a {
  40. width: 120px;
  41. height: 120px;
  42. border:1px solid #e4e4e4;
  43. margin-right: 20px;
  44. margin-bottom: 10px;
  45. img {
  46. width: 100%;
  47. height: 100%;
  48. object-fit: contain;
  49. }
  50. &.active {
  51. border-color: @xtxColor;
  52. }
  53. }
  54. }
  55. }
  56. </style>

src/views/goods/goods-comment.vue

  1. +import GoodsCommentImage from './goods-comment-image'
  2. // ...
  3. export default {
  4. name: 'GoodsComment',
  5. + components: { GoodsCommentImage },
  6. props: {
  1. <div class="text">{{item.content}}</div>
  2. <!-- 使用图片预览组件 -->
  3. + <GoodsCommentImage v-if="item.pictures.length" :pictures="item.pictures" />
  4. <div class="time">
  • 实现预览图片和关闭预览
  1. <div class="preview" v-if="currImage">
  2. <img :src="currImage" alt="">
  3. <i @click="currImage=null" class="iconfont icon-close-new"></i>
  4. </div>
  1. .preview {
  2. width: 480px;
  3. height: 480px;
  4. border: 1px solid #e4e4e4;
  5. background: #f8f8f8;
  6. margin-bottom: 20px;
  7. position: relative;
  8. img {
  9. width: 100%;
  10. height: 100%;
  11. object-fit: contain;
  12. }
  13. i {
  14. position: absolute;
  15. right: 0;
  16. top: 0;
  17. width: 30px;
  18. height: 30px;
  19. background: rgba(0,0,0,0.2);
  20. color: #fff;
  21. text-align: center;
  22. line-height: 30px;
  23. }
  24. }

商品详情-评价组件-★分页组件

目的:封装一个统一的分页组件。

image.png

大致步骤:

  • 分页基础布局,依赖数据分析。
  • 分页内部逻辑,完成切换效果。
  • 接收外部数据,提供分页事件。

落的代码:

  • 分页基础布局,依赖数据分析 src/components/library/xtx-pagination.vue
  1. <template>
  2. <div class="xtx-pagination">
  3. <a href="javascript:;" class="disabled">上一页</a>
  4. <span>...</span>
  5. <a href="javascript:;" class="active">3</a>
  6. <a href="javascript:;">4</a>
  7. <a href="javascript:;">5</a>
  8. <a href="javascript:;">6</a>
  9. <a href="javascript:;">7</a>
  10. <span>...</span>
  11. <a href="javascript:;">下一页</a>
  12. </div>
  13. </template>
  14. <script>
  15. export default {
  16. name: 'XtxPagination'
  17. }
  18. </script>
  19. <style scoped lang="less">
  20. .xtx-pagination {
  21. display: flex;
  22. justify-content: center;
  23. padding: 30px;
  24. > a {
  25. display: inline-block;
  26. padding: 5px 10px;
  27. border: 1px solid #e4e4e4;
  28. border-radius: 4px;
  29. margin-right: 10px;
  30. &:hover {
  31. color: @xtxColor;
  32. }
  33. &.active {
  34. background: @xtxColor;
  35. color: #fff;
  36. border-color: @xtxColor;
  37. }
  38. &.disabled {
  39. cursor: not-allowed;
  40. opacity: 0.4;
  41. &:hover {
  42. color: #333
  43. }
  44. }
  45. }
  46. > span {
  47. margin-right: 10px;
  48. }
  49. }
  50. </style>

image.png

  • 分页内部逻辑,完成切换效果 src/components/library/xtx-pagination.vue

1)准备渲染数据

  1. setup () {
  2. // 总条数
  3. const myTotal = ref(100)
  4. // 每页条数
  5. const myPageSize = ref(10)
  6. // 当前第几页
  7. const myCurrentPage = ref(1)
  8. // 按钮个数
  9. const btnCount = 5
  10. // 重点:根据上述数据得到(总页数,起始页码,结束页码,按钮数组)
  11. const pager = computed(() => {
  12. // 计算总页数
  13. const pageCount = Math.ceil(myTotal.value / myPageSize.value)
  14. // 计算起始页码和结束页码
  15. // 1. 理想情况根据当前页码,和按钮个数可得到
  16. let start = myCurrentPage.value - Math.floor(btnCount / 2)
  17. let end = start + btnCount - 1
  18. // 2.1 如果起始页码小于1了,需要重新计算
  19. if (start < 1) {
  20. start = 1
  21. end = (start + btnCount - 1) > pageCount ? pageCount : (start + btnCount - 1)
  22. }
  23. // 2.2 如果结束页码大于总页数,需要重新计算
  24. if (end > pageCount) {
  25. end = pageCount
  26. start = (end - btnCount + 1) < 1 ? 1 : (end - btnCount + 1)
  27. }
  28. // 处理完毕start和end得到按钮数组
  29. const btnArr = []
  30. for (let i = start; i <= end; i++) {
  31. btnArr.push(i)
  32. }
  33. return { pageCount, start, end, btnArr }
  34. })
  35. return { pager, myCurrentPage}
  36. }

2)进行渲染

  1. <a v-if="myCurrentPage<=1" href="javascript:;" class="disabled">上一页</a>
  2. <a v-else href="javascript:;">上一页</a>
  3. <span v-if="pager.start>1">...</span>
  4. <a href="javascript:;" :class="{active:i===myCurrentPage}" v-for="i in pager.btnArr" :key="i">{{i}}</a>
  5. <span v-if="pager.end<pager.pageCount">...</span>
  6. <a v-if="myCurrentPage>=pager.pageCount" href="javascript:;" class="disabled">下一页</a>
  7. <a v-else href="javascript:;">下一页</a>

3)切换效果

  1. <div class="xtx-pagination">
  2. <a v-if="myCurrentPage<=1" href="javascript:;" class="disabled">上一页</a>
  3. + <a @click="changePage(myCurrentPage-1)" v-else href="javascript:;">上一页</a>
  4. <span v-if="pager.start>1">...</span>
  5. + <a @click="changePage(i)" href="javascript:;" :class="{active:i===myCurrentPage}" v-for="i in pager.btnArr" :key="i">{{i}}</a>
  6. <span v-if="pager.end<pager.pageCount">...</span>
  7. <a v-if="myCurrentPage>=pager.pageCount" href="javascript:;" class="disabled">下一页</a>
  8. + <a @click="changePage(myCurrentPage+1)" v-else href="javascript:;">下一页</a>
  9. </div>
  1. // 改变页码
  2. const changePage = (newPage) => {
  3. myCurrentPage.value = newPage
  4. }
  5. return { pager, myCurrentPage, changePage }
  • 接收外部数据,提供分页事件。
  1. props: {
  2. total: {
  3. type: Number,
  4. default: 100
  5. },
  6. currentPage: {
  7. type: Number,
  8. default: 1
  9. },
  10. pageSize: {
  11. type: Number,
  12. default: 10
  13. }
  14. },
  1. // 监听传人的值改变
  2. watch(props, () => {
  3. myTotal.value = props.total
  4. myPageSize.value = props.pageSize
  5. myCurrentPage.value = props.currentPage
  6. }, { immediate: true })
  1. // 改变页码
  2. const changePage = (newPage) => {
  3. if (myCurrentPage.value !== newPage) {
  4. myCurrentPage.value = newPage
  5. // 通知父组件最新页码
  6. emit('current-change', newPage)
  7. }
  8. }

最后使用组件:

  1. + // 记录总条数
  2. const commentList = ref([])
  3. + const total = ref(0)
  4. watch(reqParams, async () => {
  5. const data = await findCommentListByGoods(props.goods.id, reqParams)
  6. commentList.value = data.result
  7. + total.value = data.result.counts
  8. }, { immediate: true })
  1. // 改变分页函数
  2. const changePager = (np) => {
  3. reqParams.page = np
  4. }
  5. return { commentInfo, currTagIndex, changeTag, reqParams, changeSort, commentList, total, changePager }
  1. <!-- 分页 -->
  2. <XtxPagination @current-change="changePager" :total="total" :current-page="reqParams.page" />

筛选和排序改变后页码回到第一页:

  1. // 改变排序
  2. const changeSort = (type) => {
  3. reqParams.sortField = type
  4. + reqParams.page = 1
  5. }
  1. const changeTag = (i) => {
  2. currTagIndex.value = i
  3. // 设置有图和标签条件
  4. const currTag = commentInfo.value.tags[i]
  5. if (currTag.type === 'all') {
  6. reqParams.hasPicture = false
  7. reqParams.tag = null
  8. } else if (currTag.type === 'img') {
  9. reqParams.hasPicture = true
  10. reqParams.tag = null
  11. } else {
  12. reqParams.hasPicture = false
  13. reqParams.tag = currTag.title
  14. }
  15. + reqParams.page = 1
  16. }

优化:有条数才显示分页

  1. <div class="xtx-pagination" v-if="total>0">