配置路由

搭建基础页面

面包屑渲染

接口

  1. import request from '@/utils/request'
  2. /**
  3. * 根据 id 获取商品详情数据
  4. * @param {*} id
  5. */
  6. export const reqFindGoods = (id) => request('/goods', 'get', { id })

js逻辑

useRoute 是vue3中的this.route

  1. <script>
  2. import { reqFindGoods } from '@/api/goods'
  3. import { useRoute } from 'vue-router'
  4. import { ref } from 'vue'
  5. export default {
  6. name: 'XtxGoodsPage',
  7. setup () {
  8. // 商品数据
  9. const goods = ref({})
  10. const route = useRoute()
  11. console.log('route', route)
  12. const findGoods = async () => {
  13. const { result } = await reqFindGoods(route.params.id)
  14. console.log('res', result)
  15. goods.value = result
  16. }
  17. findGoods()
  18. return {
  19. goods
  20. }
  21. }
  22. }
  23. </script>

面包屑

渲染数据,配置跳转路径

  1. <!-- 面包屑 -->
  2. <XtxBread>
  3. <XtxBreadItem to="/">首页</XtxBreadItem>
  4. <XtxBreadItem :to="'/category/'+goods.categories[1].id">{{goods.categories[1].name}}</XtxBreadItem>
  5. <XtxBreadItem :to="`/category/sub/${goods.categories[0].id}`">{{goods.categories[0].name}}</XtxBreadItem>
  6. <XtxBreadItem to="/">{{goods.name}}</XtxBreadItem>
  7. </XtxBread>

渲染页面后这里报错如下 image.png 数组下标查找到的是undefined,因为数据获取是异步的这时组件已经渲染 解决:需要加一个健全判断一下

  1. <div class="container" v-if="goods.categories">
  2. <!-- 面包屑 -->
  3. <XtxBread>
  4. <XtxBreadItem to="/">首页</XtxBreadItem>
  5. <XtxBreadItem :to="'/category/'+goods.categories[1].id">{{goods.categories[1].name}}</XtxBreadItem>
  6. <XtxBreadItem :to="`/category/sub/${goods.categories[0].id}`">{{goods.categories[0].name}}</XtxBreadItem>
  7. <XtxBreadItem to="/">{{goods.name}}</XtxBreadItem>
  8. </XtxBread>
  9. </div>

结构拆分

  1. <!-- 商品信息 -->
  2. <div class="goods-info">
  3. <!-- 图片预览区 -->
  4. <div class="media">左边</div>
  5. <!-- 商品信息区 -->
  6. <div class="spec">右边</div>
  7. </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. }

图片放大预览组件

组件需要传入 image.png

  1. <template>
  2. <div class="goods-image">
  3. <!-- 大图 -->
  4. <div
  5. class="large"
  6. :style="[
  7. {
  8. backgroundImage: `url(${imageList[curIndex]})`,
  9. backgroundPositionX: positionX + 'px',
  10. backgroundPositionY: positionY + 'px',
  11. },
  12. ]"
  13. v-show="showFlag"
  14. ></div>
  15. <div class="middle" ref="target">
  16. <img :src="imageList[curIndex]" alt="" />
  17. <!-- 蒙层容器 -->
  18. <div
  19. class="layer"
  20. :style="{ left: left + 'px', top: top + 'px' }"
  21. v-show="showFlag"
  22. ></div>
  23. </div>
  24. <!-- 小图 -->
  25. <ul class="small">
  26. <li
  27. v-for="(img, i) in imageList"
  28. :key="i"
  29. @mouseenter="mouseEnterFn(i)"
  30. :class="{ active: i === curIndex }"
  31. >
  32. <img :src="img" alt="" />
  33. </li>
  34. </ul>
  35. </div>
  36. </template>
  37. <script>
  38. /**
  39. * 交互思路分析:
  40. * 1. 基于鼠标移入事件 mouseenter
  41. * 2. 鼠标移入哪个就把哪个的下标值记录一下 然后通过下标值去imageList中去取值 把取到的值放到src渲染即可
  42. */
  43. import { ref, watch } from 'vue'
  44. import { useMouseInElement } from '@vueuse/core'
  45. export default {
  46. name: 'XtxImageView',
  47. props: {
  48. imageList: {
  49. type: Array,
  50. default: () => {
  51. return []
  52. }
  53. }
  54. },
  55. setup () {
  56. // 实现鼠标移入交互
  57. const curIndex = ref(0)
  58. function mouseEnterFn (i) {
  59. curIndex.value = i
  60. }
  61. // 实现放大镜效果
  62. const target = ref(null)
  63. // 控制是否显示 false代表不显示 (直接使用isOutside 会有闪动bug)
  64. const showFlag = ref(false)
  65. // elementX:相较于我们盒子左侧的距离 refObj
  66. // elementY:相较于盒子顶部的距离 refObj
  67. // isOutSide: 鼠标是否在盒子外部 true代表在外部 refObj
  68. const { elementX, elementY, isOutside } = useMouseInElement(target)
  69. // 实现我们滑块跟随鼠标移动的交互效果
  70. const left = ref(0)
  71. const top = ref(0)
  72. const positionX = ref(0)
  73. const positionY = ref(0)
  74. watch([elementX, elementY, isOutside], () => {
  75. showFlag.value = !isOutside.value
  76. // 只有进入到容器中才开始做移动判断
  77. if (isOutside.value) {
  78. return false
  79. }
  80. // 根据鼠标的坐标变化控制我们滑块的位移 left top值
  81. // 1. 控制滑块最大的可移动范围
  82. if (elementX.value > 300) {
  83. left.value = 200
  84. }
  85. if (elementX.value < 100) {
  86. left.value = 0
  87. }
  88. // 2. 横向有效移动范围内的逻辑
  89. if (elementX.value < 300 && elementX.value > 100) {
  90. left.value = elementX.value - 100
  91. }
  92. if (elementY.value > 300) {
  93. top.value = 200
  94. }
  95. if (elementY.value < 100) {
  96. top.value = 0
  97. }
  98. // 2. 横向有效移动范围内的逻辑
  99. if (elementY.value < 300 && elementY.value > 100) {
  100. top.value = elementY.value - 100
  101. }
  102. // 控制背景大图的移动 (背景图的移动 是跟着 滑块的移动走的)
  103. // 1.鼠标的移动的方向和大图的方向是相反的 (正负)
  104. // 2.鼠标每移动一个像素 大图背景移动俩个像素 (x2)
  105. positionX.value = -left.value * 2
  106. positionY.value = -top.value * 2
  107. })
  108. /**
  109. * 1. 换算关系 难点
  110. * 2. 使用工具函数的时候 返回的数据的类型 ref类型 refObj.value
  111. * 3. 在实现一些和样式有关的交互 一定要保证css单位值是有效的
  112. */
  113. return {
  114. mouseEnterFn,
  115. curIndex,
  116. target,
  117. elementX,
  118. elementY,
  119. left,
  120. top,
  121. positionX,
  122. positionY,
  123. showFlag
  124. }
  125. }
  126. }
  127. </script>
  128. <style scoped lang="less">
  129. .goods-image {
  130. width: 480px;
  131. height: 400px;
  132. position: relative;
  133. display: flex;
  134. .middle {
  135. width: 400px;
  136. height: 400px;
  137. background: #f5f5f5;
  138. }
  139. .large {
  140. position: absolute;
  141. top: 0;
  142. left: 412px;
  143. width: 400px;
  144. height: 400px;
  145. z-index: 500;
  146. box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  147. background-repeat: no-repeat;
  148. // 背景图:盒子的大小 = 2:1 将来控制背景图的移动来实现放大的效果查看 background-position
  149. background-size: 800px 800px;
  150. background-color: #f8f8f8;
  151. }
  152. .layer {
  153. width: 200px;
  154. height: 200px;
  155. background: rgba(0, 0, 0, 0.2);
  156. // 绝对定位 然后跟随咱们鼠标控制left和top属性就可以让滑块移动起来
  157. left: 0;
  158. top: 0;
  159. position: absolute;
  160. }
  161. .small {
  162. width: 80px;
  163. li {
  164. width: 68px;
  165. height: 68px;
  166. margin-left: 12px;
  167. margin-bottom: 15px;
  168. cursor: pointer;
  169. &:hover,
  170. &.active {
  171. border: 2px solid @xtxColor;
  172. }
  173. }
  174. }
  175. }
  176. </style>

注册后使用

规格组件

测试商品id: 1379052170040578049

字典筛选(重难点)

将得到的sku组合整理成字典基于字典查询 在字典中的可以点击,不在的按钮需要禁用 库存为0不会出现在字典集合中

  1. <script>
  2. import bwPowerSet from '@/vendor/power-set'
  3. // console.log(bwPowerSet(['']));
  4. // 根据skus 整理路径字典
  5. const getPathMap = (skus) => {
  6. const pathMap = {}
  7. // 1. 过滤出inventory(库存)不为0 有效sku
  8. skus
  9. .filter(sku => sku.inventory > 0)
  10. // filter 返回的还是数组 链式调用循环
  11. .forEach(sku => {
  12. // 2. 找到了符合条件的sku
  13. })
  14. return pathMap
  15. }
  16. export default {
  17. name: 'GoodsSku',
  18. props: {
  19. goods: {
  20. type: Object,
  21. default: () => ({
  22. specs: [],
  23. skus: []
  24. })
  25. }
  26. },
  27. setup (props) {
  28. const pathMap = getPathMap(props.goods.skus)
  29. return {
  30. changeSku
  31. }
  32. }
  33. }
  34. </script>

幂集算法 https://raw.githubusercontent.com/trekhleb/javascript-algorithms/master/src/algorithms/sets/power-set/bwPowerSet.js js算法库 https://github.com/trekhleb/javascript-algorithms

  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. }

根据算法处理数据,使用算法求子级、整理子级

  1. // 根据skus 整理路径字典
  2. const getPathMap = (skus) => {
  3. const pathMap = {}
  4. // 1. 过滤出inventory(库存)不为0 有效sku
  5. skus
  6. .filter(sku => sku.inventory > 0)
  7. // filter 返回的还是数组 链式调用循环
  8. .forEach(sku => {
  9. // 2. 找到了符合条件的sku
  10. // console.log(sku)
  11. const arr = sku.specs.map(item => item.valueName)
  12. // console.log(arr)
  13. // 3.使用算法求子集
  14. const sets = bwPowerSet(arr)
  15. // console.log(sets)
  16. // 4.整理子集,组合字典
  17. sets.forEach(set => {
  18. // 把子级数组转换成字符串
  19. const key = set.join('')
  20. // console.log(key)
  21. /**
  22. * 当没有pathMap[key] 的时候新建一个 数组 key:[id]
  23. * 如果有就push到这个数组中去
  24. * */
  25. if (pathMap[key]) {
  26. pathMap[key].push(sku.id)
  27. } else {
  28. pathMap[key] = [sku.id]
  29. }
  30. })
  31. })
  32. return pathMap
  33. }

按钮

禁用(重难点)

模板中添加禁用类 disabled 这里会有一个bug 就算禁用之后点击还是会添加选中状态类 在changesku中做一个健全有这个禁用属性值为true时return

  1. // 禁用
  2. const updateDisabledStatus = (specs, pathMap) => {
  3. // 深拷贝
  4. const selectedJSON = JSON.stringify(getSelectedSpac(specs))
  5. // 遍历之后获取到每一行
  6. specs.forEach((item, i) => {
  7. // 遍历之后获取到每一个按钮
  8. item.values.forEach(btn => {
  9. // console.log(btn.name)
  10. const selectedArr = JSON.parse(selectedJSON)
  11. // console.log('selectedArr', selectedArr)
  12. // selectedArr[i]是当前行 btn.name所有按钮的名字
  13. selectedArr[i] = btn.name
  14. // console.log('selectedArr[i]', selectedArr[i])
  15. // 筛选出 undefined 然后拼接
  16. // const key = selectedArr.filter(v => v).join('')
  17. // console.log(selectedArr.filter(v => v))
  18. // console.log('key', key)
  19. if (pathMap[btn.name]) {
  20. btn.disabled = false
  21. } else {
  22. btn.disabled = true
  23. }
  24. })
  25. })
  26. }
  27. // 初始化调用 点击按钮也需要更新禁用状态
  28. setup (props, context) {
  29. if (props.skuId) {
  30. initDefaultStatus(props.goods, props.skuId)
  31. }
  32. const pathMap = getPathMap(props.goods.skus)
  33. console.log('path', pathMap)
  34. // 初始化的时候禁用
  35. updateDisabledStatus(props.goods.specs, pathMap)
  36. const changeSku = (item, spec) => {
  37. // 健全 如果不可点击直接返回,避免添加类
  38. if (item.disabled) {
  39. return
  40. }
  41. // 取反
  42. if (item.active) {
  43. item.active = false
  44. } else {
  45. // 排他 先全部取消,在选中当前项
  46. spec.values.forEach(element => {
  47. element.active = false
  48. })
  49. item.active = true
  50. }
  51. // 在点击按钮之后也需要更新禁用状态
  52. updateDisabledStatus(props.goods.specs, pathMap)
  53. // active 改变时会重新执行这里,对应上了用户的点击顺序
  54. const selectedArr = getSelectedSpac(props.goods.specs).filter(v => v)
  55. console.log(selectedArr)
  56. if (selectedArr.length === props.goods.specs.length) {
  57. const skuId = (pathMap[selectedArr.join('')][0])
  58. const sku = props.goods.skus.find(sku => sku.id === skuId)
  59. console.log('组件中的sku', sku)
  60. // 传递
  61. context.emit('change', {
  62. skuId: sku.id,
  63. price: sku.price,
  64. oldPrice: sku.oldPrice,
  65. inventory: sku.inventory,
  66. specsText: sku.specs.reduce((str, item) => `${str} ${item.name}:${item.valueName}`, '').trim('')
  67. })
  68. } else {
  69. // 没选中的
  70. context.emit('change', {})
  71. }
  72. }
  73. return {
  74. changeSku
  75. }
  76. }
  77. }

获取点击数据

按钮点击之后需要拼接成与字典中相同的数据来匹配 用find方法找到active为true 的一项 这一项就是点击选中的项 将没有点击选中的设为undefinde,然后添加到数组中

  1. // 获得按钮点击之后的数据
  2. const getSelectedSpac = (specs) => {
  3. const arr = []
  4. specs.forEach((spec, index) => {
  5. // 获取当前点击的按钮
  6. const btn = spec.values.find(obj => obj.active === true)
  7. if (btn) {
  8. arr[index] = btn.name
  9. } else {
  10. arr[index] = undefined
  11. }
  12. })
  13. return arr
  14. }

处理数据传出组件

  1. const changeSku = (item, spec) => {
  2. // 健全 如果不可点击直接返回,避免添加类
  3. if (item.disabled) {
  4. return
  5. }
  6. // 取反
  7. if (item.active) {
  8. item.active = false
  9. } else {
  10. // 排他 先全部取消,在选中当前项
  11. spec.values.forEach(element => {
  12. element.active = false
  13. })
  14. item.active = true
  15. }
  16. // 在点击按钮之后也需要更新禁用状态
  17. updateDisabledStatus(props.goods.specs, pathMap)
  18. // active 改变时会重新执行这里,对应上了用户的点击顺序
  19. const selectedArr = getSelectedSpac(props.goods.specs).filter(v => v)
  20. // console.log(selectedArr)
  21. if (selectedArr.length === props.goods.specs.length) {
  22. const skuId = (pathMap[selectedArr.join('')][0])
  23. const sku = props.goods.skus.find(sku => sku.id === skuId)
  24. console.log('组件中的sku', sku)
  25. // 传递
  26. context.emit('change', {
  27. skuId: sku.id,
  28. price: sku.price,
  29. oldPrice: sku.oldPrice,
  30. inventory: sku.inventory,
  31. specsText: sku.specs.reduce((str, item) => `${str} ${item.name}:${item.valueName}`, '').trim('')
  32. })
  33. } else {
  34. // 没选中的
  35. context.emit('change', {})
  36. }
  37. }