01-商品详情-基础布局

目的:完成商品详情基础布局,路由配置,搭好页面架子。
image.png
大致步骤:

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

落地代码:

  • 页面组件:src/views/goods/index.vue ```vue

  1. - 商品推荐组件:src/views/goods/components/goods-relevant.vue
  2. ```vue
  3. <template>
  4. <div class='goods-relevant'></div>
  5. </template>
  6. <script>
  7. export default {
  8. name: 'GoodsRelevant'
  9. }
  10. </script>
  11. <style scoped lang='less'>
  12. .goods-relevant {
  13. background: #fff;
  14. min-height: 460px;
  15. margin-top: 20px;
  16. }
  17. </style>
  • 路由配置:src/router/index.js ```javascript const Goods = () => import(‘@/views/goods/index’) children: [ { path: ‘/‘, component: Home }, { path: ‘/category/:id’, component: TopCategory }, { path: ‘/category/sub/:id’, component: SubCategory },
  • { path: ‘/product/:id’, component: Goods } ] ```

    02-商品详情-渲染面包屑

    目的:获取数据,渲染面包屑。
    大致步骤:
  • 定义获取商品详情API函数
  • 在组件setup中获取商品详情数据
  • 定义一个useXxx函数处理数据

落地代码:

  • API函数 src/api/product.js ```javascript import request from ‘@/utils/request’

/**

  • 获取商品详情
  • @param {String} id - 商品ID */ export const findGoods = (id) => { return request(‘/goods’, ‘get’, { id }) } ```
  • 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/'+goods.categories[0].id">{{goods.categories[0].name}}</XtxBreadItem>
    5. <XtxBreadItem :to="'/category/sub/'+goods.categories[1].id">{{goods.categories[1].name}}</XtxBreadItem>
    6. <XtxBreadItem>{{goods.name}}</XtxBreadItem>
    7. </XtxBread>

    03-商品详情-图片预览组件

    目的:完成商品图片预览功能和切换
    image.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. }
  • 商品图片组件,渲染和切换

    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>

    04-商品详情-图片放大镜

    目的:实现图片放大镜功能
    image.png
    大致步骤:

  • 首先准备大图容器和遮罩容器

  • 然后使用@vueuse/core的useMouseInElement方法获取基于元素的偏移量
  • 计算出 遮罩容器定位与大容器北京定位 暴露出数据给模板使用

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

  • 准备大图容器 ```vue
  • less .goods-image { width: 480px; height: 400px; position: relative; display: flex;
  • z-index: 500;
  • .large {
  • position: absolute;
  • top: 0;
  • left: 412px;
  • width: 400px;
  • height: 400px;
  • box-shadow: 0 0 10px rgba(0,0,0,0.1);
  • background-repeat: no-repeat;
  • background-size: 800px 800px;
  • background-color: #f8f8f8;
  • } ```
  • 准备待移动的遮罩容器 ```vue
    1. <img :src="images[currIndex]" alt="">
  • less .middle { width: 400px; height: 400px;
  • position: relative;
  • cursor: move;
  • .layer {
  • width: 200px;
  • height: 200px;
  • background: rgba(0,0,0,.2);
  • left: 0;
  • top: 0;
  • position: absolute;
  • } } ```
  • 使用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中返回模板需要数据,并使用它 ```javascript setup () { const { currIndex, toggleImg } = useToggleImg()

  • const { position, bgPosition, show, target } = usePreviewImg()
  • return { currIndex, toggleImg, position, bgPosition, show, target } }
    1. ```vue
    2. <div class="large" v-show="show" :style="[{backgroundImage:`url(${images[currIndex]})`},bgPosition]"></div>
    3. <div class="middle" ref="target">
    4. <img :src="images[currIndex]" alt="">
    5. <div class="layer" v-show="show" :style="position"></div>
    6. </div>

    05-商品详情-基本信息展示

    目的:展示商品基本信息
    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 },
    ```vue
    1. <!-- 商品信息 -->
    2. <div class="goods-info">
    3. <div class="media">
    4. <GoodsImage :images="goods.mainPictures" />
  • ```
  • ⑶渲染数据 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>

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

    目的:完成城市组件的基础布局和基本显示隐藏切换效果。
    image.png
    大致步骤:

  • 准备基本组件结构

  • 完成切换显示隐藏
  • 完成点击外部隐藏

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

  • 结构

    1. <template>
    2. <div class="xtx-city" ref="target">
    3. <div class="select" @click="toggleDialog" :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-if="active">
    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. // 控制展开收起,默认收起
    8. const active = ref(false)
    9. const openDialog = () => {
    10. active.value = true
    11. }
    12. const closeDialog = () => {
    13. active.value = false
    14. }
    15. // 切换展开收起
    16. const toggleDialog = () => {
    17. if (active.value) closeDialog()
    18. else openDialog()
    19. }
    20. // 点击其他位置隐藏
    21. const target = ref(null)
    22. onClickOutside(target, () => {
    23. closeDialog()
    24. })
    25. return { active, toggleDialog, target }
    26. }
    27. }
    28. </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>

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

    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. const getCityData = () => {
    6. // 这个位置可能有异常操作,封装成promise
    7. return new Promise((resolve, reject) => {
    8. if (window.cityData) {
    9. // 有缓存
    10. resolve(window.cityData)
    11. } else {
    12. // 无缓存
    13. const url = 'https://yjy-oss-files.oss-cn-zhangjiakou.aliyuncs.com/tuxian/area.json'
    14. axios.get(url).then(res => {
    15. window.cityData = res.data
    16. resolve(window.cityData)
    17. })
    18. }
    19. })
    20. }
  • open使用函数 ```javascript // 获取城市数据,显示当前地方列表 // 2. 显示和隐藏函数(为什么是函数,做更多事情) const loading = ref(false) const cityData = ref([]) const open = () => { visible.value = true loading.value = true // 获取数据 getCityData().then(data => { cityData.value = data loading.value = false }) }

// 定义计算属性 const currList = computed(() => { const currList = cityData.value // TODO 根据点击的省份城市获取对应的列表 return currList })

  1. return { active, toggleDialog, target, currList, loading }
  1. - 加载中样式
  2. ```less
  3. .option {
  4. // 省略...
  5. .loading {
  6. height: 290px;
  7. width: 100%;
  8. background: url(../../assets/images/loading.gif) no-repeat center;
  9. }
  10. }
  • 模板中使用 ```vue
    1. **注意事项:** 需要配置10kb下的图片打包成base64的格式 vue.config.js
    2. ```javascript
    3. chainWebpack: config => {
    4. config.module
    5. .rule('images')
    6. .use('url-loader')
    7. .loader('url-loader')
    8. .tap(options => Object.assign(options, { limit: 10000 }))
    9. }

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

    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="changeItem(item)" class="ellipsis"
  1. const changeResult = reactive({
  2. provinceCode: '',
  3. provinceName: '',
  4. cityCode: '',
  5. cityName: '',
  6. countyCode: '',
  7. countyName: '',
  8. fullLocation: ''
  9. })
  10. const changeItem = (item) => {
  11. // 省份
  12. if (item.level === 0) {
  13. changeResult.provinceCode = item.code
  14. changeResult.provinceName = item.name
  15. }
  16. // 市
  17. if (item.level === 1) {
  18. changeResult.cityCode = item.code
  19. changeResult.cityName = item.name
  20. }
  21. }

计算出需要展示列表

  1. // 定义计算属性
  2. const currList = computed(() => {
  3. // 省份
  4. let currList = cityData.value
  5. // 城市
  6. if (changeResult.provinceCode) {
  7. currList = currList.find(p => p.code === changeResult.provinceCode).areaList
  8. }
  9. // 地区
  10. if (changeResult.cityCode) {
  11. currList = currList.find(c => c.code === changeResult.cityCode).areaList
  12. }
  13. return currList
  14. })

打开弹层清空之前的选择

  1. const open = () => {
  2. visible.value = true
  3. loading.value = true
  4. // 获取数据
  5. getCityData().then(data => {
  6. cityData.value = data
  7. loading.value = false
  8. })
  9. // 清空选择结果
  10. + for (const key in changeResult) {
  11. + changeResult[key] = ''
  12. + }
  13. }
  • 第三步:点击地区的时候,将数据通知给父组件使用,关闭对话框

src/components/xtx-city.vue

  1. const changeItem = (item) => {
  2. // 省份
  3. if (item.level === 0) {
  4. changeResult.provinceCode = item.code
  5. changeResult.provinceName = item.name
  6. }
  7. // 市
  8. if (item.level === 1) {
  9. changeResult.cityCode = item.code
  10. changeResult.cityName = item.name
  11. }
  12. + // 地区
  13. + if (item.level === 2) {
  14. + changeResult.countyCode = item.code
  15. + changeResult.countyName = item.name
  16. + changeResult.fullLocation = `${changeResult.provinceName} ${changeResult.cityName} ${changeResult.countyName}`
  17. + close()
  18. + emit('change', changeResult)
  19. + }
  20. }

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

  1. // 选择城市
  2. const changeCity = (result) => {
  3. provinceCode.value = result.provinceCode
  4. cityCode.value = result.cityCode
  5. countyCode.value = result.countyCode
  6. fullLocation.value = result.fullLocation
  7. }
  8. return { fullLocation, changeCity }
  1. <XtxCity @change="changeCity" :fullLocation="fullLocation" />

07-★规格组件-SKU&SPU概念

官方话术:

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

画图理解:
image.png
总结一下:

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

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

    目标,完成规格组件的基础布局。
    image.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 ```javascript +import GoodsSku from ‘./components/goods-sku’

    name: ‘XtxGoodsPage’,

  • components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku }, setup () { vue
    1. <div class="spec">
    2. <!-- 名字区组件 -->
    3. <GoodsName :goods="goods" />
    4. <!-- 规格组件 -->
  • ``` 总结: 每一个按钮拥有selected disabled 类名,做 选中 和 禁用要用。

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

    目的:根据商品信息渲染规格,完成选中,取消选中效果。
    大致步骤:
  • 依赖 goods.specs 渲染规格
  • 绑定按钮点击事件,完成选中和取消选中
    • 当前点的是选中,取消即可
    • 当前点的未选中,先当前规格按钮全部取消,当前按钮选中。

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

  1. <template>
  2. <div class="goods-sku">
  3. <dl v-for="item in goods.specs" :key="item.id">
  4. <dt>{{item.name}}</dt>
  5. <dd>
  6. <template v-for="val in item.values" :key="val.name">
  7. <img :class="{selected:val.selected}" @click="clickSpecs(item,val)" v-if="val.picture" :src="val.picture" :title="val.name">
  8. <span :class="{selected:val.selected}" @click="clickSpecs(item,val)" v-else>{{val.name}}</span>
  9. </template>
  10. </dd>
  11. </dl>
  12. </div>
  13. </template>
  14. <script>
  15. export default {
  16. name: 'GoodsSku',
  17. props: {
  18. goods: {
  19. type: Object,
  20. default: () => ({ specs: [], skus: [] })
  21. }
  22. },
  23. setup (props) {
  24. const clickSpecs = (item, val) => {
  25. // 1. 选中与取消选中逻辑
  26. if (val.selected) {
  27. val.selected = false
  28. } else {
  29. item.values.forEach(bv => { bv.selected = false })
  30. val.selected = true
  31. }
  32. }
  33. return { clickSpecs }
  34. }
  35. }
  36. </script>

注意:处理后台数据不规范,规格属性顺序和sku属性顺序不一致问题 src/views/goods/index.vue 现在无需处理。

  1. findGoods('1369155859933827074').then(({ result }) => {
  2. // const sortSpecs = []
  3. // result.skus[0].specs.forEach(spec => {
  4. // sortSpecs.push(result.specs.find(item => item.name === spec.name))
  5. // })
  6. // result.specs = sortSpecs
  7. result.skus.forEach(sku => {
  8. const sortSpecs = []
  9. result.specs.forEach(spec => {
  10. sortSpecs.push(sku.specs.find(item => item.name === spec.name))
  11. })
  12. sku.specs = sortSpecs
  13. })
  14. goods.value = result
  15. })

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

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

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

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

      目的:根据后台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'
  2. const spliter = '★'
  3. // 根据skus数据得到路径字典对象
  4. const getPathMap = (skus) => {
  5. const pathMap = {}
  6. skus.forEach(sku => {
  7. // 1. 过滤出有库存有效的sku
  8. if (sku.inventory) {
  9. // 2. 得到sku属性值数组
  10. const specs = sku.specs.map(spec => spec.valueName)
  11. // 3. 得到sku属性值数组的子集
  12. const powerSet = getPowerSet(specs)
  13. // 4. 设置给路径字典对象
  14. powerSet.forEach(set => {
  15. const key = set.join(spliter)
  16. if (pathMap[key]) {
  17. // 已经有key往数组追加
  18. pathMap[key].push(sku.id)
  19. } else {
  20. // 没有key设置一个数组
  21. pathMap[key] = [sku.id]
  22. }
  23. })
  24. }
  25. })
  26. return pathMap
  27. }
  1. + setup (props) {
  2. + const pathMap = getPathMap(props.goods.skus)
  3. + console.log(pathMap)
  • 参照示例

image.png

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

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

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

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

  1. // 得到当前选中规格集合
  2. const getSelectedArr = (specs) => {
  3. const selectedArr = []
  4. specs.forEach(spec => {
  5. const selectedVal = spec.values.find(val => val.selected)
  6. selectedArr.push(selectedVal ? selectedVal.name : undefined)
  7. })
  8. return selectedArr
  9. }
  10. // 更新按钮的禁用状态
  11. const updateDisabledStatus = (specs, pathMap) => {
  12. specs.forEach((spec, i) => {
  13. const selectedArr = getSelectedArr(specs)
  14. spec.values.forEach(val => {
  15. // 已经选中的按钮不用判断
  16. if (val.name === selectedArr[i]) return false
  17. // 未选中的替换对应的值
  18. selectedArr[i] = val.name
  19. // 过滤无效值得到key
  20. const key = selectedArr.filter(v => v).join(spliter)
  21. // 设置禁用状态
  22. val.disabled = !pathMap[key]
  23. })
  24. })
  25. }
  26. setup (props) {
  27. const pathMap = getPathMap(props.goods.skus)
  28. // 组件初始化的时候更新禁用状态
  29. + updateDisabledStatus(props.goods.specs, pathMap)
  30. const clickSpecs = (item, val) => {
  31. // 如果是禁用状态不作为
  32. + if (val.disabled) return false
  33. // 1. 选中与取消选中逻辑
  34. if (val.selected) {
  35. val.selected = false
  36. } else {
  37. item.values.find(bv => { bv.selected = false })
  38. val.selected = true
  39. }
  40. // 点击的时候更新禁用状态
  41. + updateDisabledStatus(props.goods.specs, pathMap)
  42. }
  43. return { clickSpecs }
  44. }

13-★规格组件-数据通讯

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

  • 根据传入的SKUID选中对应规格按钮
  • 选择规格后传递sku信息给父组件
    • 完整规格,传 skuId 价格 原价 库存 规格文字
    • 不完整的,传 空对象

落的代码:

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

    1. skuId: {
    2. type: String,
    3. default: ''
    4. }
    1. // 初始化选中状态
    2. const initSelectedStatus = (goods, skuId) => {
    3. const sku = goods.skus.find(sku => sku.id === skuId)
    4. if (sku) {
    5. goods.specs.forEach((spec, i) => {
    6. const value = sku.specs[i].valueName
    7. spec.values.forEach(val => {
    8. val.selected = val.name === value
    9. })
    10. })
    11. }
    12. }
    1. setup (props, { emit }) {
    2. const pathMap = getPathMap(props.goods.skus)
    3. // 根据传入的skuId默认选中规格按钮
    4. + initSelectedStatus(props.goods, props.skuId)
    5. // 组件初始化的时候更新禁用状态
    6. updateDisabledStatus(props.goods.specs, pathMap)
  • 根据选择的完整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. + // 触发change事件将sku数据传递出去
  14. + const selectedArr = getSelectedArr(props.goods.specs).filter(v => v)
  15. + if (selectedArr.length === props.goods.specs.length) {
  16. + const skuIds = pathMap[selectedArr.join(spliter)]
  17. + const sku = props.goods.skus.find(sku => sku.id === skuIds[0])
  18. + // 传递
  19. + emit('change', {
  20. + skuId: sku.id,
  21. + price: sku.price,
  22. + oldPrice: sku.oldPrice,
  23. + inventory: sku.inventory,
  24. + specsText: sku.specs.reduce((p, n) => `${p} ${n.name}:${n.valueName}`, '').replace(' ', '')
  25. + })
  26. + } else {
  27. + emit('change', {})
  28. + }
  29. + }

src/views/goods/index.vue

  1. <GoodsSku :goods="goods" @change="changeSku"/>
  1. setup () {
  2. const goods = useGoods()
  3. // sku改变时候触发
  4. + const changeSku = (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. }

14-商品详情-数量选择组件

目的:封装一个通用的数量选中组件。
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. <template>
  2. <div class="xtx-numbox">
  3. <div class="label" v-if="label">{{label}}</div>
  4. <div class="numbox">
  5. <a @click="changeNum(-1)" href="javascript:;">-</a>
  6. <input type="text" readonly :value="modelValue">
  7. <a @click="changeNum(1)" href="javascript:;">+</a>
  8. </div>
  9. </div>
  10. </template>
  11. <script>
  12. import { useVModel } from '@vueuse/core'
  13. export default {
  14. name: 'XtxNumbox',
  15. props: {
  16. label: {
  17. type: String,
  18. default: ''
  19. },
  20. modelValue: {
  21. type: Number,
  22. default: 1
  23. },
  24. // 最小值,最大值
  25. min: {
  26. type: Number,
  27. default: 1
  28. },
  29. max: {
  30. type: Number,
  31. default: 10
  32. }
  33. },
  34. setup (props, { emit }) {
  35. // 1. 绑定按钮点击事件 -按钮 +按钮 触发同一个事件,同一个函数
  36. // 2. 使用vueuse的useVModel做数据绑定,修改 count 通知父组件更新
  37. const count = useVModel(props, 'modelValue', emit)
  38. const changeNum = (step) => {
  39. // 3. 得到将要改变的值,如果值不合法终止程序
  40. const newValue = count.value + step
  41. if (newValue < props.min || newValue > props.max) return
  42. // 4. 正常改值即可
  43. count.value = newValue
  44. // 5. 提供change事件
  45. emit('change', newValue)
  46. }
  47. return { changeNum }
  48. }
  49. }

src/views/goods/index.vue

<XtxNumbox label="数量" v-model="count" :max="goods.inventory"/>
// 选择的数量
+    const count = ref(1)
+    return { goods, changeSku, count }

15-商品详情-按钮组件

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

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

落地代码:

  • 封装组件:src/components/library/xtx-numbox.vue

    <template>
    <button class="xtx-button ellipsis" :class="[size,type]">
      <slot />
    </button>
    </template>
    <script>
    export default {
    name: 'XtxButton',
    props: {
      size: {
        type: String,
        default: 'middle'
      },
      type: {
        type: String,
        default: 'default'
      }
    }
    }
    </script>
    <style scoped lang="less">
    .xtx-button {
    appearance: none;
    border: none;
    outline: none;
    background: #fff;
    text-align: center;
    border: 1px solid transparent;
    border-radius: 4px;
    cursor: pointer;
    }
    .large {
    width: 240px;
    height: 50px;
    font-size: 16px;
    }
    .middle {
    width: 180px;
    height: 50px;
    font-size: 16px;
    }
    .small {
    width: 100px;
    height: 32px;
    font-size: 14px;  
    }
    .mini {
    width: 60px;
    height: 32px;
    font-size: 14px;  
    }
    .default {
    border-color: #e4e4e4;
    color: #666;
    }
    .primary {
    border-color: @xtxColor;
    background: @xtxColor;
    color: #fff;
    }
    .plain {
    border-color: @xtxColor;
    color: @xtxColor;
    background: lighten(@xtxColor,50%);
    }
    .gray {
    border-color: #ccc;
    background: #ccc;;
    color: #fff;
    }
    </style>
    
  • 使用组件:src/views/goods/index.vue ```vue

  • 加入购物车 ```

    16-商品详情-同类推荐组件

    目的:实现商品的同类推荐与猜你喜欢展示功能。
    image.png
    大致功能需求:
  • 完成基础布局(头部),后期改造xtx-carousel.vue组件来展示商品效果。
  • 然后可以通过是否传入商品ID来区别同类推荐和猜你喜欢。

落的代码开始:

  • 基础布局 src/views/goods/components/goods-relevant.vue ```vue


- 获取数据传入xtx-carousel.vue组件

src/views/goods/index.vue 传ID
```vue
   <!-- 商品推荐 -->
      <GoodsRelevant :goodsId="goods.id"/>

src/api/goods.js 定义获取数据的API

/**
 * 获取商品同类推荐-未传入ID为猜喜欢
 * @param {String} id - 商品ID
 * @param {Number} limit - 获取条数
 */
export const findRelGoods = (id, limit = 16) => {
  return request('/goods/relevant', 'get', { id, limit })
}

src/views/goods/components/goods-relevant.vue 获取数据

    <div class="header">
      <i class="icon" />
+      <span class="title">{{goodsId?'同类商品推荐':'猜你喜欢'}}</span>
    </div>
<script>
import { findRelGoods } from '@/api/goods'
import { ref } from 'vue'
// 得到需要的数据
const useRelGoodsData = (id) => {
  const sliders = ref([])
  findRelGoods(id).then(data => {
    // 每页4条
    const size = 4
    const total = Math.ceil(data.result.length / size)
    for (let i = 0; i < total; i++) {
      sliders.value.push(data.result.slice(i * size, (i + 1) * size))
    }
  })
  return sliders
}
export default {
  // 同类推荐,猜你喜欢
  name: 'GoodsRelevant',
  props: {
    goodsId: {
      type: String,
      default: undefined
    }
  },
  setup (props) {
    const sliders = useRelGoodsData(props.goodsId)
    return { sliders }
  }
}
</script>
   <!-- 此处使用改造后的xtx-carousel.vue -->
    <XtxCarousel :sliders="sliders" style="height:380px" auto-play />
  • 改造xtx-carousel.vue组件 src/components/library/xtx-carousel.vue ```vue
  • 商品详情 - 图15
  • 商品详情 - 图16
  • {{goods.name}}

  • ¥{{goods.price}}

  • ```less
    // 轮播商品
    .slider {
    display: flex;
    justify-content: space-around;
    padding: 0 40px;
    > a {
    width: 240px;
    text-align: center;
    img {
    padding: 20px;
    width: 230px!important;
    height: 230px!important;
    }
    .name {
    font-size: 16px;
    color: #666;
    padding: 0 40px;
    }
    .price {
    font-size: 16px;
    color: @priceColor;
    margin-top: 15px;
    }
    }
    }
    
  • 覆盖xtx-carousel.vue的样式在 src/views/goods/components/goods-relevant.vue

    :deep(.xtx-carousel) {
    height: 380px;
    .carousel {
      &-indicator {
        bottom: 30px;
        span {
          &.active {
            background: @xtxColor;
          }
        }
      }
      &-btn {
        top: 110px;
        opacity: 1;
        background: rgba(0,0,0,0);
        color: #ddd;
        i {
          font-size: 30px;
        }
      }
    }
    }
    

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

    17-商品详情-标签页组件

    目的:实现商品详情组件和商品评价组件的切换
    image.png
    大致步骤:

  • 完成基础的tab的导航布局

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

落的代码:

  • 标签页基础布局 src/vies/goods/components/goods-tabs.vue

    <div class="goods-tabs">
      <nav>
        <a class="active" href="javascript:;">商品详情</a>
        <a href="javascript:;">商品评价<span>(500+)</span></a>
      </nav>
      <!-- 切换内容的地方 -->  
    </div>
    
    .goods-tabs {
    min-height: 600px;
    background: #fff;
    nav {
      height: 70px;
      line-height: 70px;
      display: flex;
      border-bottom: 1px solid #f5f5f5;
      a {
        padding: 0 40px;
        font-size: 18px;
        position: relative;
        > span {
          color: @priceColor;
          font-size: 16px;
          margin-left: 10px;
        }
        &:first-child {
          border-right: 1px solid #f5f5f5;
        }
        &.active {
          &::before {
            content: "";
            position: absolute;
            left: 40px;
            bottom: -1px;
            width: 72px;
            height: 2px;
            background: @xtxColor;
          }
        }
      }
    }
    }
    
  • tabs组件切换 src/vies/goods/components/goods-tabs.vue

    <template>
    <div class="goods-tabs">
      <nav>
        <a
          :class="{ active: activeName === 'detail' }"
          href="javascript:;"
          @click="clickTab('detail')"
          >商品详情</a
        >
        <a
          :class="{ active: activeName === 'comment' }"
          href="javascript:;"
          @click="clickTab('comment')"
          >商品评价<span>(500+)</span></a
        >
      </nav>
      <!-- 这个位置显示对应的组件 GoodsDetail 或者 GoodsComment -->
      <component :is="'goods-'+activeName" />
    </div>
    </template>
    <script>
    import { ref } from 'vue'
    import GoodsDetail from './goods-detail'
    import GoodsComment from './goods-comment'
    export default {
    name: 'GoodsTabs',
    components: { GoodsDetail, GoodsComment },
    setup () {
      // detail-->详情   comment-->评价
      const activeName = ref('detail')
      const clickTab = (name) => {
        activeName.value = name
      }
      return { activeName, clickTab }
    }
    }
    </script>
    
  • 使用tabs组件 src/views/goods/index.vue

    +import GoodsTabs from './components/goods-tabs'
    // ... 省略
    export default {
    name: 'XtxGoodsPage',
    +  components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku, GoodsTabs },
    setup () {
    

    ```vue

      <div class="goods-article">
        <!-- 商品+评价 -->
    
  • less -.goods-tabs {
  • min-height: 600px;
  • background: #fff; -} ```

  • 定义详情组件,评价组件。

src/vies/goods/components/goods-detail.vue

<template>
  <div class="goods-detail">详情</div>
</template>
<script>
export default {
  name: 'GoodsDetail'
}
</script>
<style scoped lang="less"></style>

src/vies/goods/components/goods-comment.vue

<template>
  <div class="goods-comment">评价</div>
</template>
<script>
export default {
  name: 'GoodsComment'
}
</script>
<style scoped lang="less"></style>

18-商品详情-热榜组件

目的:展示24小时热榜商品,和周热榜商品。
image.png
大致步骤:

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

落的代码:

  • 定义组件 src/views/goods/components/goods-hot.vue

    <template>
    <div class="goods-hot">
      <h3>{{title}}</h3>
    </div>
    </template>
    <script>
    import { computed } from 'vue'
    export default {
    name: 'GoodsHot',
    props: {
      type: {
        type: Number,
        default: 1
      }
    },
    setup (props) {
      const titleObj = { 1: '24小时热销榜', 2: '周热销榜', 3: '总热销榜' }
      const title = computed(() => {
        return titleObj[props.type]
      })
      return { title }
    }
    }
    </script>
    <style scoped lang="less"></style>
    
  • 使用组件 src/views/goods/index.vue

    +import GoodsHot from './components/goods-hot'
    // ... 省略
    name: 'XtxGoodsPage',
    +  components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku, GoodsTabs, GoodsHot },
    setup () {
    
    <!-- 24热榜+专题推荐 -->
    <div class="goods-aside">
    <GoodsHot :goodsId="goods.id" :type="1" />
    <GoodsHot :goodsId="goods.id" :type="2" />
    </div>
    
  • 获取数据,设置组件样式

src/api/goods.js

/**
 * 获取热榜商品
 * @param {Number} type - 1代表24小时热销榜 2代表周热销榜 3代表总热销榜
 * @param {Number} limit - 获取个数
 */
export const findHotGoods = ({id,type, limit = 3}) => {
  return request('/goods/hot', 'get', {id, type, limit })
}

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

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

19-商品详情-详情组件

目的:展示商品属性和商品详情。
image.png
大致步骤:

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

落的代码:

  • 传递goods数据

src/views/goods/index.vue setup中提供数据

provide('goods', goods)
  • 使用goods数据,展示评价数量
    setup () {
    const goods = inject('goods')
    return { goods }
    },
    
    ```vue
  • 商品评价({{goods.commentCount}}) ```

  • 使用goods数据,展示商品详情

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

<template>
  <div class="goods-detail">
    <!-- 属性 -->
    <ul class="attrs">
      <li v-for="item in goods.details.properties" :key="item.value">
        <span class="dt">{{item.name}}</span>
        <span class="dd">{{item.value}}</span>
      </li>
    </ul>
    <!-- 图片 -->
    <img v-for="item in goods.details.pictures" :key="item" :src="item" alt="">
  </div>
</template>
<script>
export default {
  name: 'GoodsDetail',
  setup () {
      const goods = inject('goods')
      return { goods }
  }
}
</script>
<style scoped lang="less">
.goods-detail {
  padding: 40px;
  .attrs {
    display: flex;
    flex-wrap: wrap;
    margin-bottom: 30px;
    li {
      display: flex;
      margin-bottom: 10px;
      width: 50%;
      .dt {
        width: 100px;
        color: #999;
      }
      .dd {
        flex: 1;
        color: #666;
      }
    }
  }
  > img {
    width: 100%;
  }
}
</style>

20-商品详情-注意事项组件

目的:展示购买商品的注意事项。
src/views/goods/index.vue

<script>
  +import GoodsWarn from './components/goods-warn'
  ...
   name: 'XtxGoodsPage',
+  components: { GoodsRelevant, GoodsImage, GoodsSales, GoodsName, GoodsSku, GoodsTabs, GoodsHot, GoodsWarn },
  setup () {
</script>
<!-- 注意事项 -->
+ <GoodsWarn />

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

<template>
  <!-- 注意事项 -->
  <div class="goods-warn">
    <h3>注意事项</h3>
    <p class="tit">• 购买运费如何收取?</p>
    <p>
      单笔订单金额(不含运费)满88元免邮费;不满88元,每单收取10元运费。(港澳台地区需满500元免邮费;不满500元,每单收取30元运费)
    </p>
    <br />
    <p class="tit">• 使用什么快递发货?</p>
    <p>默认使用顺丰快递发货(个别商品使用其他快递)</p>
    <p>配送范围覆盖全国大部分地区(港澳台地区除外)</p>
    <br />
    <p class="tit">• 如何申请退货?</p>
    <p>
      1.自收到商品之日起30日内,顾客可申请无忧退货,退款将原路返还,不同的银行处理时间不同,预计1-5个工作日到账;
    </p>
    <p>2.内裤和食品等特殊商品无质量问题不支持退货;</p>
    <p>
      3.退货流程:
      确认收货-申请退货-客服审核通过-用户寄回商品-仓库签收验货-退款审核-退款完成;
    </p>
    <p>
      4.因小兔鲜儿产生的退货,如质量问题,退货邮费由小兔鲜儿承担,退款完成后会以现金券的形式报销。因客户个人原因产生的退货,购买和寄回运费由客户个人承担。
    </p>
  </div>
</template>
<style lang="less" scoped>
.goods-warn {
  margin-top: 20px;
  background: #fff;
  padding-bottom: 40px;
  h3 {
    height: 70px;
    line-height: 70px;
    border-bottom: 1px solid #f5f5f5;
    padding-left: 50px;
    font-size: 18px;
    font-weight: normal;
    margin-bottom: 10px;
  }
  p {
    line-height: 40px;
    padding: 0 25px;
    color: #666;
    &.tit {
      color: #333;
    }
  }
}
</style>

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

image.png
目的:根据后台返回的评价信息渲染评价头部内容。
yapi 平台可提供模拟接口,当后台接口未开发完毕或者没有数据的情况下,可以支持前端的开发。
大致步骤:

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

落的代码:

  • 布局 src/views/goods/components/goods-comment.vue

    <template>
    <div class="goods-comment">
      <div class="head">
        <div class="data">
          <p><span>100</span><span>人购买</span></p>
          <p><span>99.99%</span><span>好评率</span></p>
        </div>
        <div class="tags">
          <div class="dt">大家都在说:</div>
          <div class="dd">
            <a href="javascript:;" class="active">全部评价(1000)</a>
            <a href="javascript:;">好吃(1000)</a>
            <a href="javascript:;">便宜(1000)</a>
            <a href="javascript:;">很好(1000)</a>
            <a href="javascript:;">再来一次(1000)</a>
            <a href="javascript:;">快递棒(1000)</a>
          </div>
        </div>
      </div>
      <div class="sort">
        <span>排序:</span>
        <a href="javascript:;" class="active">默认</a>
        <a href="javascript:;">最新</a>
        <a href="javascript:;">最热</a>
      </div>
      <div class="list"></div>
    </div>
    </template>
    <script>
    export default {
    name: 'GoodsComment'
    }
    </script>
    <style scoped lang="less">
    .goods-comment {
    .head {
      display: flex;
      padding: 30px 0;
      .data {
        width: 340px;
        display: flex;
        padding: 20px;
        p {
          flex: 1;
          text-align: center;
          span {
            display: block;
            &:first-child {
              font-size: 32px;
              color: @priceColor;
            }
            &:last-child {
              color: #999;
            }
          }
        }
      }
      .tags {
        flex: 1;
        display: flex;
        border-left: 1px solid #f5f5f5;
        .dt {
          font-weight: bold;
          width: 100px;
          text-align: right;
          line-height: 42px;
        }
        .dd {
          flex: 1;
          display: flex;
          flex-wrap: wrap;
          > a {
            width: 132px;
            height: 42px;
            margin-left: 20px;
            margin-bottom: 20px;
            border-radius: 4px;
            border: 1px solid #e4e4e4;
            background: #f5f5f5;
            color: #999;
            text-align: center;
            line-height: 40px;
            &:hover {
              border-color: @xtxColor;
              background: lighten(@xtxColor,50%);
              color: @xtxColor;
            }
            &.active {
              border-color: @xtxColor;
              background: @xtxColor;
              color: #fff;
            }
          }
        }
      }
    }
    .sort {
      height: 60px;
      line-height: 60px;
      border-top: 1px solid #f5f5f5;
      border-bottom: 1px solid #f5f5f5;
      margin: 0 20px;
      color: #666;
      > span {
        margin-left: 20px;
      }
      > a {
        margin-left: 30px;
        &.active,&:hover {
          color: @xtxColor;
        }
      }
    }
    }
    </style>
    
  • 接口 src/api/goods.js

    /**
    * 获取商品的评价统计信息
    * @param {String} id - 商品ID
    */
    export const findCommentInfoByGoods = (id) => {
    return request(`/goods/${id}/evaluate`)
    }
    // https://mock.boxuegu.com/mock/1175/goods/${id}/evaluate
    
  • 获取数据,处理数据 src/views/goods/components/goods-comment.vue

    import { findCommentInfoByGoods } from '@/api/goods'
    import { ref } from 'vue'
    const getCommentInfo = (props) => {
    const commentInfo = ref(null)
    findCommentInfoByGoods(props.goods.id).then(data => {
      // type 的目的是将来点击可以区分点的是不是标签
      data.result.tags.unshift({ type: 'img', title: '有图', tagCount: data.result.hasPictureCount })
      data.result.tags.unshift({ type: 'all', title: '全部评价', tagCount: data.result.evaluateCount })
      commentInfo.value = data.result
    })
    return commentInfo
    }
    export default {
    name: 'GoodsComment',
    props: {
      goods: {
        type: Object,
        default: () => {}
      }
    },
    setup (props) {
      const commentInfo = getCommentInfo(props)
      return { commentInfo }
    }
    }
    
  • 渲染模版 + tag选中效果 src/views/goods/components/goods-comment.vue

      <!-- 头部 -->
      <div class="head" v-if="commentInfo">
        <div class="data">
          <p><span>{{commentInfo.salesCount}}</span><span>人购买</span></p>
          <p><span>{{commentInfo.praisePercent}}</span><span>好评率</span></p>
        </div>
        <div class="tags">
          <div class="dt">大家都在说:</div>
          <div class="dd">
            <a
              v-for="(item,i) in commentInfo.tags"
              :key="item.title"
              href="javascript:;"
              :class="{active:currTagIndex===i}"
              @click="changeTag(i)"
            >
              {{item.title}}({{item.tagCount}})
            </a>
          </div>
        </div>
      </div>
      <!-- 排序 -->
      <div class="sort" v-if="commentInfo">
    

    ```javascript setup (props) { const commentInfo = getCommentInfo(props) // 记录当前激活的索引

  • const currTagIndex = ref(0)
  • const changeTag = (i) => {
  • currTagIndex.value = i
  • }
  • return { commentInfo, currTagIndex, changeTag } } ```

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

    目的:完成列表渲染,筛选和排序。
    image.png
    大致步骤:
  • 列表基础布局
  • 筛选条件数据准备
  • 何时去获取数据?
    • 组件初始化
    • 点标签
    • 点排序
  • 渲染列表

落地代码:

  • 列表基础布局

    <!-- 列表 -->
      <div class="list">
        <div class="item">
          <div class="user">
            <img src="http://zhoushugang.gitee.io/erabbit-client-pc-static/uploads/avatar_1.png" alt="">
            <span>兔****m</span>
          </div>
          <div class="body">
            <div class="score">
              <i class="iconfont icon-wjx01"></i>
              <i class="iconfont icon-wjx01"></i>
              <i class="iconfont icon-wjx01"></i>
              <i class="iconfont icon-wjx01"></i>
              <i class="iconfont icon-wjx02"></i>
              <span class="attr">颜色:黑色 尺码:M</span>
            </div>
            <div class="text">网易云app上这款耳机非常不错 新人下载网易云购买这款耳机优惠大 而且耳机🎧确实正品 音质特别好 戴上这款耳机 听音乐看电影效果声音真是太棒了 无线方便 小盒自动充电 最主要是质量好音质棒 想要买耳机的放心拍 音效巴巴滴 老棒了</div>
            <div class="time">
              <span>2020-10-10 10:11:22</span>
              <span class="zan"><i class="iconfont icon-dianzan"></i>100</span>
            </div>
          </div>
        </div>
      </div>
    
    .list {
      padding: 0 20px;
      .item {
        display: flex;
        padding: 25px 10px;
        border-bottom: 1px solid #f5f5f5;
        .user {
          width: 160px;
          img {
            width: 40px;
            height: 40px;
            border-radius: 50%;
            overflow: hidden;
          }
          span {
            padding-left: 10px;
            color: #666;
          }
        }
        .body {
          flex: 1;
          .score {
            line-height: 40px;
            .iconfont {
              color: #ff9240;
              padding-right: 3px;
            }
            .attr {
              padding-left: 10px;
              color: #666;
            }
          }
        }
        .text {
          color: #666;
          line-height: 24px;
        }
        .time {
          color: #999;
          display: flex;
          justify-content: space-between;
          margin-top: 5px;
        }
      }
    }
    

    筛选条件数据准备

  • 定义筛选条件

      // 筛选条件准备
      const reqParams = reactive({
        page: 1,
        pageSize: 10,
        hasPicture: null,
        tag: null,
        sortField: null
      })
    
  • 收集排序条件

    <!-- 排序 -->
        <div class="sort">
          <span>排序:</span>
          <a
            @click="changeSort(null)"
            href="javascript:;"
            :class="{active:reqParams.sortField===null}"
          >默认</a>
          <a
            @click="changeSort('praiseCount')"
            href="javascript:;"
            :class="{active:reqParams.sortField==='praiseCount'}"
          >最热</a>
          <a
            @click="changeSort('createTime')"
            href="javascript:;"
            :class="{active:reqParams.sortField==='createTime'}"
          >最新</a>
        </div>
    
        // 改变排序
        const changeSort = (type) => {
          reqParams.sortField = type
           reqParams.page = 1
        }
    
  • 收集标签和是否有图条件 ```javascript const changeTag = (i) => {

    currTagIndex.value = i
    
  • // 设置有图和标签条件
  • const currTag = commentInfo.value.tags[i]
  • if (currTag.type === ‘all’) {
  • reqParams.hasPicture = false
  • reqParams.tag = null
  • } else if (currTag.type === ‘img’) {
  • reqParams.hasPicture = true
  • reqParams.tag = null
  • } else {
  • reqParams.hasPicture = false
  • reqParams.tag = currTag.title
  • }
  • reqParams.page = 1 } ```
  • 获取数据(当组件初始化的时候,筛选条件改变的时候)

      // 初始化或者筛选条件改变后,获取列表数据。
      const commentList = ref([])
      watch(reqParams, async () => {
        const data = await findCommentListByGoods(props.goods.id, reqParams)
        commentList.value = data.result.items
      }, { immediate: true })
    

    渲染模版

  • 处理数据,昵称加*号,规格拼接字符串。

     // 定义转换数据的函数(对应vue2.0的过滤器)
      const formatSpecs = (specs) => {
        return specs.reduce((p, c) => `${p} ${c.name}:${c.nameValue}`, '').trim()
      }
      const formatNickname = (nickname) => {
        return nickname.substr(0, 1) + '****' + nickname.substr(-1)
      }
    

    所有数据和函数

    return { commentInfo, currentTagIndex, changeTag, reqParams, commentList, changeSort, formatSpecs, formatNickname }
    
  • 渲染html

    <!-- 列表 -->
      <div class="list">
        <div class="item" v-for="item in commentList" :key="item.id">
          <div class="user">
            <img :src="item.member.avatar" alt="">
            <span>{{formatNickname(item.member.nickname)}}</span>
          </div>
          <div class="body">
            <div class="score">
              <i v-for="i in item.score" :key="i+'1'" class="iconfont icon-wjx01"></i>
              <i v-for="i in 5-item.score" :key="i+'2'" class="iconfont icon-wjx02"></i>
              <span class="attr">{{formatSpecs(item.orderInfo.specs)}}</span>
            </div>
            <div class="text">{{item.content}}</div>
            <div class="time">
              <span>{{item.createTime}}</span>
              <span class="zan"><i class="iconfont icon-dianzan"></i> {{item.praiseCount}}</span>
            </div>
          </div>
        </div>
      </div>
    

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

    目的:封装一个组件展示 图片列表 和 预览图片 功能。
    image.png
    大致步骤:

  • 准备一个组件导入goods-comment.vue使用起来,传入图片数据

  • 展示图片列表,和选中图片功能。
  • 提供图片预览功能和关闭图片预览。

落的代码:

  • 展示图片列表和选中效果实现

src/views/goods/goods-comment-image.vue

<template>
  <div class="goods-comment-image">
    <div class="list">
      <a
        href="javascript:;"
        :class="{active:currImage===url}"
        @click="currImage=url"
        v-for="url in pictures"
        :key="url"
      >
        <img :src="url" alt="">
      </a>
    </div>
    <div class="preview"></div>
  </div>
</template>
<script>
import { ref } from 'vue'
export default {
  name: 'GoodsCommentImage',
  props: {
    pictures: {
      type: Array,
      default: () => []
    }
  },
  setup () {
    const currImage = ref(null)
    return { currImage }
  }
}
</script>
<style scoped lang="less">
.goods-comment-image {
  .list {
    display: flex;
    flex-wrap: wrap;
    margin-top: 10px;
    a {
      width: 120px;
      height: 120px;
      border:1px solid #e4e4e4;
      margin-right: 20px;
      margin-bottom: 10px;
      img {
        width: 100%;
        height: 100%;
        object-fit: contain;
      }
      &.active {
        border-color: @xtxColor;
      }
    }
  }
}
</style>

src/views/goods/goods-comment.vue

+import GoodsCommentImage from './goods-comment-image'
// ...
export default {
  name: 'GoodsComment',
+  components: { GoodsCommentImage },
  props: {
<div class="text">{{item.content}}</div>
<!-- 使用图片预览组件 -->
+ <GoodsCommentImage v-if="item.pictures.length" :pictures="item.pictures" />
<div class="time">
  • 实现预览图片和关闭预览

    <div class="preview" v-if="currImage">
    <img :src="currImage" alt="">
    <i @click="currImage=null" class="iconfont icon-close-new"></i>
    </div>
    
    .preview {
      width: 480px;
      height: 480px;
      border: 1px solid #e4e4e4;
      background: #f8f8f8;
      margin-bottom: 20px;
      position: relative;
      img {
          width: 100%;
          height: 100%;
          object-fit: contain;
      }
      i {
        position: absolute;
        right: 0;
        top: 0;
        width: 30px;
        height: 30px;
        background: rgba(0,0,0,0.2);
        color: #fff;
        text-align: center;
        line-height: 30px;
      }
    }
    

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

    目的:封装一个统一的分页组件。
    image.png
    大致步骤:

  • 分页基础布局,依赖数据分析。

  • 分页内部逻辑,完成切换效果。
  • 接收外部数据,提供分页事件。

落的代码:

  • 分页基础布局,依赖数据分析 src/components/library/xtx-pagination.vue

    <template>
    <div class="xtx-pagination">
      <a href="javascript:;" class="disabled">上一页</a>
      <span>...</span>
      <a href="javascript:;" class="active">3</a>
      <a href="javascript:;">4</a>
      <a href="javascript:;">5</a>
      <a href="javascript:;">6</a>
      <a href="javascript:;">7</a>
      <span>...</span>
      <a href="javascript:;">下一页</a>
    </div>
    </template>
    <script>
    export default {
    name: 'XtxPagination'
    }
    </script>
    <style scoped lang="less">
    .xtx-pagination {
    display: flex;
    justify-content: center;
    padding: 30px;
    > a {
      display: inline-block;
      padding: 5px 10px;
      border: 1px solid #e4e4e4;
      border-radius: 4px;
      margin-right: 10px;
      &:hover {
        color: @xtxColor;
      }
      &.active {
        background: @xtxColor;
        color: #fff;
        border-color: @xtxColor;
      }
      &.disabled {
        cursor: not-allowed;
        opacity: 0.4;
        &:hover {
          color: #333
        }
      }
    }
    > span {
      margin-right: 10px;
    }
    }
    </style>
    

    image.png

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

1)准备渲染数据

  setup () {
    // 总条数
    const myTotal = ref(100)
    // 每页条数
    const myPageSize = ref(10)
    // 当前第几页
    const myCurrentPage = ref(1)
    // 按钮个数
    const btnCount = 5

    // 重点:根据上述数据得到(总页数,起始页码,结束页码,按钮数组)
    const pager = computed(() => {
      // 计算总页数
      const pageCount = Math.ceil(myTotal.value / myPageSize.value)
      // 计算起始页码和结束页码
      // 1. 理想情况根据当前页码,和按钮个数可得到
      let start = myCurrentPage.value - Math.floor(btnCount / 2)
      let end = start + btnCount - 1
      // 2.1 如果起始页码小于1了,需要重新计算
      if (start < 1) {
        start = 1
        end = (start + btnCount - 1) > pageCount ? pageCount : (start + btnCount - 1)
      }
      // 2.2 如果结束页码大于总页数,需要重新计算
      if (end > pageCount) {
        end = pageCount
        start = (end - btnCount + 1) < 1 ? 1 : (end - btnCount + 1)
      }
      // 处理完毕start和end得到按钮数组
      const btnArr = []
      for (let i = start; i <= end; i++) {
        btnArr.push(i)
      }
      return { pageCount, start, end, btnArr }
    })

    return { pager, myCurrentPage}
  }

2)进行渲染

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

3)切换效果

  <div class="xtx-pagination">
    <a v-if="myCurrentPage<=1" href="javascript:;" class="disabled">上一页</a>
+    <a @click="changePage(myCurrentPage-1)" v-else href="javascript:;">上一页</a>
    <span v-if="pager.start>1">...</span>
+    <a @click="changePage(i)" href="javascript:;" :class="{active:i===myCurrentPage}" v-for="i in pager.btnArr" :key="i">{{i}}</a>
    <span v-if="pager.end<pager.pageCount">...</span>
    <a v-if="myCurrentPage>=pager.pageCount" href="javascript:;" class="disabled">下一页</a>
+    <a @click="changePage(myCurrentPage+1)" v-else href="javascript:;">下一页</a>
  </div>
    // 改变页码
    const changePage = (newPage) => {
      myCurrentPage.value = newPage
    }

    return { pager, myCurrentPage, changePage }
  • 接收外部数据,提供分页事件。
    props: {
      total: {
        type: Number,
        default: 100
      },
      currentPage: {
        type: Number,
        default: 1
      },
      pageSize: {
        type: Number,
        default: 10
      }
    },
    
      // 监听传人的值改变
      watch(props, () => {
        myTotal.value = props.total
        myPageSize.value = props.pageSize
        myCurrentPage.value = props.currentPage
      }, { immediate: true })
    
      // 改变页码
      const changePage = (newPage) => {
        if (myCurrentPage.value !== newPage) {
          myCurrentPage.value = newPage
          // 通知父组件最新页码
          emit('current-change', newPage)
        }
      }
    
    最后使用组件: ```javascript
  • // 记录总条数 const commentList = ref([])
  • const total = ref(0) watch(reqParams, async () => { const data = await findCommentListByGoods(props.goods.id, reqParams) commentList.value = data.result
  • total.value = data.result.counts }, { immediate: true })
    ```javascript
    // 改变分页函数
    const changePager = (np) => {
    reqParams.page = np
    }
    return { commentInfo, currTagIndex, changeTag, reqParams, changeSort, commentList, total, changePager }
    
    <!-- 分页 -->
    <XtxPagination @current-change="changePager" :total="total" :current-page="reqParams.page"  />
    
    筛选和排序改变后页码回到第一页: ```javascript // 改变排序 const changeSort = (type) => { reqParams.sortField = type
  • reqParams.page = 1 } javascript const changeTag = (i) => { currTagIndex.value = i // 设置有图和标签条件 const currTag = commentInfo.value.tags[i] if (currTag.type === ‘all’) { reqParams.hasPicture = false reqParams.tag = null } else if (currTag.type === ‘img’) { reqParams.hasPicture = true reqParams.tag = null } else { reqParams.hasPicture = false reqParams.tag = currTag.title }
  • reqParams.page = 1 }
    优化:有条数才显示分页
    ```vue
    <div class="xtx-pagination" v-if="total>0">