学习笔记 | 微信小程序项目day08

今日学习内容

  • 地址管理页
  • sku模块
  • 购物车模块

地址管理页

类型声明

/** 添加收货地址: 请求参数 */
export type AddressParams = {
  /** 收货人姓名 */
  receiver: string
  /** 联系方式 */
  contact: string
  /** 省份编码 */
  provinceCode: string
  /** 城市编码 */
  cityCode: string
  /** 区/县编码 */
  countyCode: string
  /** 详细地址 */
  address: string
  /** 默认地址,1为是,0为否 */
  isDefault: number
}

/** 收货地址项 */
export type AddressItem = {
  /** 收货人姓名 */
  receiver: string
  /** 联系方式 */
  contact: string
  /** 省份编码 */
  provinceCode: string
  /** 城市编码 */
  cityCode: string
  /** 区/县编码 */
  countyCode: string
  /** 详细地址 */
  address: string
  /** 默认地址,1为是,0为否 */
  isDefault: number
  /** 收货地址 id */
  id: string
  /** 省市区 */
  fullLocation: string
}

接口声明

import type { AddressItem, AddressParams } from '@/types/address'
import { http } from '@/utils/http'

/**
 * 添加收货地址
 * @param data 请求参数
 */
export const postMemberAddressAPI = (data: AddressParams) => {
  return http({
    method: 'POST',
    url: '/member/address',
    data,
  })
}

/**
 * 获取收货地址列表
 */
export const getMemberAddressAPI = () => {
  return http<AddressItem[]>({
    method: 'GET',
    url: '/member/address',
  })
}

/**
 * 获取收货地址详情
 * @param id 地址id(路径参数)
 */
export const getMemberAddressByIdAPI = (id: string) => {
  return http<AddressItem>({
    method: 'GET',
    url: `/member/address/${id}`,
  })
}

/**
 * 修改收货地址
 * @param id 地址id(路径参数)
 * @param data 表单数据(请求体参数)
 */
export const putMemberAddressByIdAPI = (id: string, data: AddressParams) => {
  return http({
    method: 'PUT',
    url: `/member/address/${id}`,
    data,
  })
}

/**
 * 删除收货地址
 * @param id 地址id(路径参数)
 */
export const deleteMemberAddressByIdAPI = (id: string) => {
  return http({
    method: 'DELETE',
    url: `/member/address/${id}`,
  })
}

组件代码

<script setup lang="ts">
import { getMemberAddressAPI, deleteMemberAddressByIdAPI } from '@/services/address'
import type { AddressItem } from '@/types/address'
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'

// 获取收货地址列表数据
const addressList = ref<AddressItem[]>([])
const getMemberAddressData = async () => {
  const res = await getMemberAddressAPI()
  addressList.value = res.result
}

// 初始化调用(页面显示)
onShow(() => {
  getMemberAddressData()
})

const onDeleteAddress = (id: string) => {
  deleteMemberAddressByIdAPI(id)
  uni.showToast({ icon: 'success', title: '操作成功~' })
  getMemberAddressData()
}
</script>

<template>
  <view class="viewport">
    <!-- 地址列表 -->
    <scroll-view class="scroll-view" scroll-y>
      <view v-if="true" class="address">
        <uni-swipe-action class="address-list">
          <!-- 收获地址项 -->
          <uni-swipe-action-item class="item" v-for="item in addressList" :key="item.id">
            <view class="item-content">
              <view class="user">
                {{ item.receiver }}
                <text class="contact">{{ item.contact }}</text>
                <text v-if="item.isDefault" class="badge">默认</text>
              </view>
              <view class="locate">{{ item.fullLocation }} {{ item.address }}</view>
              <navigator class="edit" hover-class="none" :url="`/pagesMember/address-form/address-form?id=${item.id}`">
                修改
              </navigator>
            </view>
            <!-- 右侧插槽 -->
            <template #right>
              <button @tap="onDeleteAddress(item.id)" class="delete-button">删除</button>
            </template>
          </uni-swipe-action-item>
        </uni-swipe-action>
      </view>
      <view v-else class="blank">暂无收货地址</view>
    </scroll-view>
    <!-- 添加按钮 -->
    <view class="add-btn">
      <navigator hover-class="none" url="/pagesMember/address-form/address-form">
        新建地址
      </navigator>
    </view>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
  overflow: hidden;
}

/* 删除按钮 */
.delete-button {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 50px;
  height: 100%;
  font-size: 28rpx;
  color: #fff;
  border-radius: 0;
  padding: 0;
  background-color: #cf4444;
}

.viewport {
  display: flex;
  flex-direction: column;
  height: 100%;
  background-color: #f4f4f4;

  .scroll-view {
    padding-top: 20rpx;
  }
}

.address {
  padding: 0 20rpx;
  margin: 0 20rpx;
  border-radius: 10rpx;
  background-color: #fff;

  .item-content {
    line-height: 1;
    padding: 40rpx 10rpx 38rpx;
    border-bottom: 1rpx solid #ddd;
    position: relative;

    .edit {
      position: absolute;
      top: 36rpx;
      right: 30rpx;
      padding: 2rpx 0 2rpx 20rpx;
      border-left: 1rpx solid #666;
      font-size: 26rpx;
      color: #666;
      line-height: 1;
    }
  }

  .item:last-child .item-content {
    border: none;
  }

  .user {
    font-size: 28rpx;
    margin-bottom: 20rpx;
    color: #333;

    .contact {
      color: #666;
    }

    .badge {
      display: inline-block;
      padding: 4rpx 10rpx 2rpx 14rpx;
      margin: 2rpx 0 0 10rpx;
      font-size: 26rpx;
      color: #27ba9b;
      border-radius: 6rpx;
      border: 1rpx solid #27ba9b;
    }
  }

  .locate {
    line-height: 1.6;
    font-size: 26rpx;
    color: #333;
  }
}

.blank {
  margin-top: 300rpx;
  text-align: center;
  font-size: 32rpx;
  color: #888;
}

.add-btn {
  height: 80rpx;
  text-align: center;
  line-height: 80rpx;
  margin: 30rpx 20rpx;
  color: #fff;
  border-radius: 80rpx;
  font-size: 30rpx;
  background-color: #27ba9b;
}
</style>
<script setup lang="ts">
import {
  getMemberAddressByIdAPI,
  postMemberAddressAPI,
  putMemberAddressByIdAPI,
} from '@/services/address'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'

// 表单数据
const form = ref({
  receiver: '', // 收货人
  contact: '', // 联系方式
  fullLocation: '', // 省市区(前端展示)
  provinceCode: '', // 省份编码(后端参数)
  cityCode: '', // 城市编码(后端参数)
  countyCode: '', // 区/县编码(后端参数)
  address: '', // 详细地址
  isDefault: 0, // 默认地址,1为是,0为否
})

// 收集所在地区
const onRegionChange: UniHelper.RegionPickerOnChange = (ev) => {
  // 省市区(前端展示)
  form.value.fullLocation = ev.detail.value.join(' ')
  // 省市区(后端参数)
  const [provinceCode, cityCode, countyCode] = ev.detail.code!
  // 合并数据
  Object.assign(form.value, { provinceCode, cityCode, countyCode })
}

// 收集是否默认收货地址
const onSwitchChange: UniHelper.SwitchOnChange = (ev) => {
  form.value.isDefault = ev.detail.value ? 1 : 0
}

// 提交表单
// 提交表单
const onSubmit = async () => {
  try {
    await formRef.value?.validate?.()
    // 判断当前页面是否有地址 id
    if (query.id) {
      // 修改地址请求
      await putMemberAddressByIdAPI(query.id, form.value)
    } else {
      // 新建地址请求
      await postMemberAddressAPI(form.value)
    }
    // 成功提示
    uni.showToast({ icon: 'success', title: query.id ? '修改成功' : '添加成功' })
    // 返回上一页
    setTimeout(() => {
      uni.navigateBack()
    }, 400)
  } catch (error) {
    uni.showToast({ icon: 'error', title: '请填写完整信息' })
  }
}

// 获取页面参数
const query = defineProps<{
  id?: string
}>()

// 动态设置标题
uni.setNavigationBarTitle({ title: query.id ? '修改地址' : '新建地址' })

// 获取收货地址详情数据
const getMemberAddressByIdData = async () => {
  // 有 id 才调用接口
  if (query.id) {
    // 发送请求
    const res = await getMemberAddressByIdAPI(query.id)
    // 把数据合并到表单中
    Object.assign(form.value, res.result)
  }
}

// 页面加载
onLoad(() => {
  getMemberAddressByIdData()
})

// 定义校验规则
const rules: UniHelper.UniFormsRules = {
  receiver: {
    rules: [{ required: true, errorMessage: '请输入收货人姓名' }],
  },
  contact: {
    rules: [
      { required: true, errorMessage: '请输入联系方式' },
      { pattern: /^1[3-9]\d{9}$/, errorMessage: '手机号格式不正确' },
    ],
  },
  fullLocation: {
    rules: [{ required: true, errorMessage: '请选择所在地区' }],
  },
  address: {
    rules: [{ required: true, errorMessage: '请选择详细地址' }],
  },
}

// 获取表单组件实例,用于调用表单方法
const formRef = ref<UniHelper.UniFormsInstance>()
</script>
<template>
  <view class="content">
    <uni-forms :rules="rules" :model="form" ref="formRef">
      <!-- 表单内容 -->
      <uni-forms-item name="receiver" class="form-item">
        <text class="label">收货人</text>
        <input class="input" placeholder="请填写收货人姓名" v-model="form.receiver" />
      </uni-forms-item>
      <uni-forms-item name="contact" class="form-item">
        <text class="label">手机号码</text>
        <input
          class="input"
          placeholder="请填写收货人手机号码"
          :maxlength="11"
          v-model="form.contact"
        />
      </uni-forms-item>
      <uni-forms-item name="fullLocation" class="form-item">
        <text class="label">所在地区</text>
        <picker
          class="picker"
          @change="onRegionChange"
          mode="region"
          :value="form.fullLocation.split(' ')"
        >
          <view v-if="form.fullLocation">{{ form.fullLocation }}</view>
          <view v-else class="placeholder">请选择省/市/区(县)</view>
        </picker>
      </uni-forms-item>
      <uni-forms-item name="address" class="form-item">
        <text class="label">详细地址</text>
        <input class="input" placeholder="街道、楼牌号等信息" v-model="form.address" />
      </uni-forms-item>
      <view class="form-item">
        <label class="label">设为默认地址</label>
        <switch
          class="switch"
          color="#27ba9b"
          @change="onSwitchChange"
          :checked="form.isDefault === 1"
        />
      </view>
    </uni-forms>
  </view>
  <!-- 提交按钮 -->
  <button @tap="onSubmit" class="button">保存并使用</button>
</template>
<style lang="scss">
page {
  background-color: #f4f4f4;
}

.content {
  margin: 20rpx 20rpx 0;
  padding: 0 20rpx;
  border-radius: 10rpx;
  background-color: #fff;

  .form-item,
  .uni-forms-item {
    display: flex;
    align-items: center;
    min-height: 96rpx;
    padding: 25rpx 10rpx 40rpx;
    background-color: #fff;
    font-size: 28rpx;
    border-bottom: 1rpx solid #ddd;
    position: relative;
    margin-bottom: 0;

    // 调整 uni-forms 样式
    .uni-forms-item__content {
      display: flex;
    }

    .uni-forms-item__error {
      margin-left: 200rpx;
    }

    &:last-child {
      border: none;
    }

    .label {
      width: 200rpx;
      color: #333;
    }

    .input {
      flex: 1;
      display: block;
      height: 46rpx;
    }

    .switch {
      position: absolute;
      right: -20rpx;
      transform: scale(0.8);
    }

    .picker {
      flex: 1;
    }

    .placeholder {
      color: #808080;
    }
  }
}

.button {
  height: 80rpx;
  margin: 30rpx 20rpx;
  color: #fff;
  border-radius: 80rpx;
  font-size: 30rpx;
  background-color: #27ba9b;
}
</style>

Sku模块

导入组件

<template>
  <view class="vk-data-goods-sku-popup" catchtouchmove="true" :class="valueCom && complete ? 'show' : 'none'"
    @touchmove.stop.prevent="moveHandle" @click.stop="stop">
    <!-- 页面内容开始 -->
    <view class="mask" @click="close('mask')"></view>
    <view class="layer attr-content" :class="{ 'safe-area-inset-bottom': safeAreaInsetBottom }"
      :style="{ borderRadius: borderRadius + 'rpx ' + borderRadius + 'rpx 0 0' }">
      <view class="specification-wrapper">
        <scroll-view class="specification-wrapper-content" scroll-y="true">
          <view class="specification-header">
            <view class="specification-left">
              <image class="product-img" :src="selectShop.image ? selectShop.image : goodsInfo[goodsThumbName]"
                :style="{ backgroundColor: goodsThumbBackgroundColor }" mode="aspectFill" @click="previewImage"></image>
            </view>
            <view class="specification-right">
              <view class="price-content" :style="{ color: themeColorFn('priceColor') }">
                <text class="sign">¥</text>
                <text class="price" :class="priceCom.length > 16 ? 'price2' : ''">{{ priceCom }}</text>
              </view>
              <view class="inventory" v-if="!hideStock">{{ stockText }}:{{ stockCom }}</view>
              <view class="inventory" v-else></view>
              <view class="choose" v-show="isManyCom">已选:{{ selectArr.join(' ') }}</view>
            </view>
          </view>

          <view class="specification-content">
            <view v-show="isManyCom" class="specification-item" v-for="(item, index1) in goodsInfo[specListName]"
              :key="index1">
              <view class="item-title">{{ item.name }}</view>
              <view class="item-wrapper">
                <view class="item-content" v-for="(item_value, index2) in item.list" :key="index2"
                  :class="[item_value.ishow ? '' : 'noactived', subIndex[index1] == index2 ? 'actived' : '']" :style="[
    item_value.ishow ? '' : themeColorFn('disableStyle'),
    item_value.ishow ? themeColorFn('btnStyle') : '',
    subIndex[index1] == index2 ? themeColorFn('activedStyle') : ''
  ]" @click="skuClick(item_value, index1, index2)">
                  {{ item_value.name }}
                </view>
              </view>
            </view>
            <view class="number-box-view">
              <view style="flex: 1;">数量</view>
              <view style="flex: 4;text-align: right;">
                <vk-data-input-number-box v-model="selectNum" :min="minBuyNum || 1" :max="maxBuyNumCom"
                  :step="stepBuyNum || 1" :step-strictly="stepStrictly" :positive-integer="true"
                  @change="numChange"></vk-data-input-number-box>
              </view>
            </view>
          </view>
        </scroll-view>
        <view class="close" @click="close('close')" v-if="showClose != false">
          <image class="close-item" :src="closeImage"></image>
        </view>
      </view>

      <view class="btn-wrapper" v-if="outFoStock || mode == 4">
        <view class="sure" style="color:#ffffff;background-color:#cccccc">{{ noStockText }}</view>
      </view>
      <view class="btn-wrapper" v-else-if="mode == 1">
        <view class="sure add-cart" style="border-radius:38rpx 0rpx 0rpx 38rpx;" :style="{
    color: themeColorFn('addCartColor'),
    backgroundColor: themeColorFn('addCartBackgroundColor')
  }" @click="addCart">
          {{ addCartText }}
        </view>

        <view class="sure" style="border-radius:0rpx 38rpx 38rpx 0rpx;" :style="{
    color: themeColorFn('buyNowColor'),
    backgroundColor: themeColorFn('buyNowBackgroundColor')
  }" @click="buyNow">
          {{ buyNowText }}
        </view>
      </view>
      <view class="btn-wrapper" v-else-if="mode == 2">
        <view class="sure add-cart" :style="{
    color: themeColorFn('addCartColor'),
    backgroundColor: themeColorFn('addCartBackgroundColor')
  }" @click="addCart">
          {{ addCartText }}
        </view>
      </view>
      <view class="btn-wrapper" v-else-if="mode == 3">
        <view class="sure" :style="{
    color: themeColorFn('buyNowColor'),
    backgroundColor: themeColorFn('buyNowBackgroundColor')
  }" @click="buyNow">
          {{ buyNowText }}
        </view>
      </view>
    </view>
    <!-- 页面内容结束 -->
  </view>
</template>

<script>
var vk; // vk依赖
var goodsCache = {}; // 本地商品缓存
export default {
  name: 'vk-data-goods-sku-popup',
  emits: ['update:modelValue', 'input', 'update-goods', 'open', 'close', 'add-cart', 'buy-now', 'cart', 'buy', 'num-change'],
  props: {
    // true 组件显示 false 组件隐藏
    value: {
      Type: Boolean,
      default: false
    },
    modelValue: {
      Type: Boolean,
      default: false
    },
    // vk云函数路由模式参数开始-----------------------------------------------------------
    // 商品id
    goodsId: {
      Type: String,
      default: ''
    },
    // vk路由模式框架下的云函数地址
    action: {
      Type: String,
      default: ''
    },
    // vk云函数路由模式参数结束-----------------------------------------------------------
    // 该商品已抢完时的按钮文字
    noStockText: {
      Type: String,
      default: '该商品已抢完'
    },
    // 库存文字
    stockText: {
      Type: String,
      default: '库存'
    },
    // 商品表id的字段名
    goodsIdName: {
      Type: String,
      default: '_id'
    },
    // sku表id的字段名
    skuIdName: {
      Type: String,
      default: '_id'
    },
    // sku_list的字段名
    skuListName: {
      Type: String,
      default: 'sku_list'
    },
    // spec_list的字段名
    specListName: {
      Type: String,
      default: 'spec_list'
    },
    // 库存的字段名 默认 stock
    stockName: {
      Type: String,
      default: 'stock'
    },
    // sku组合路径的字段名
    skuArrName: {
      Type: String,
      default: 'sku_name_arr'
    },
    // 默认单规格时的规格组名称
    defaultSingleSkuName: {
      Type: String,
      default: '默认'
    },
    // 模式 1:都显示  2:只显示购物车 3:只显示立即购买 4:显示缺货按钮 默认 1
    mode: {
      Type: Number,
      default: 1
    },
    // 点击遮罩是否关闭组件 true 关闭 false 不关闭 默认true
    maskCloseAble: {
      Type: Boolean,
      default: true
    },
    // 顶部圆角值
    borderRadius: {
      Type: [String, Number],
      default: 0
    },
    // 商品缩略图字段名(未选择sku时)
    goodsThumbName: {
      Type: [String],
      default: 'goods_thumb'
    },
    // 商品缩略图背景颜色,如#999999
    goodsThumbBackgroundColor: {
      Type: String,
      default: 'transparent'
    },
    // 最小购买数量 默认 1
    minBuyNum: {
      Type: [Number, String],
      default: 1
    },
    // 最大购买数量 默认 100000
    maxBuyNum: {
      Type: [Number, String],
      default: 100000
    },
    // 步进器步长 默认 1
    stepBuyNum: {
      Type: [Number, String],
      default: 1
    },
    // 是否只能输入 step 的倍数
    stepStrictly: {
      Type: Boolean,
      default: false
    },
    // 自定义获取商品信息的函数,支付宝小程序不支持该属性,请使用localdata属性
    customAction: {
      Type: [Function],
      default: null
    },
    // 本地数据源
    localdata: {
      type: Object
    },
    // 价格的字体颜色
    priceColor: {
      Type: String
    },
    // 立即购买按钮的文字
    buyNowText: {
      Type: String,
      default: '立即购买'
    },
    // 立即购买按钮的字体颜色
    buyNowColor: {
      Type: String
    },
    // 立即购买按钮的背景颜色
    buyNowBackgroundColor: {
      Type: String
    },
    // 加入购物车按钮的文字
    addCartText: {
      Type: String,
      default: '加入购物车'
    },
    // 加入购物车按钮的字体颜色
    addCartColor: {
      Type: String
    },
    // 加入购物车按钮的背景颜色
    addCartBackgroundColor: {
      Type: String
    },
    // 不可点击时,按钮的样式
    disableStyle: {
      Type: Object,
      default: null
    },
    // 按钮点击时的样式
    activedStyle: {
      Type: Object,
      default: null
    },
    // 按钮常态的样式
    btnStyle: {
      Type: Object,
      default: null
    },
    // 是否显示右上角关闭按钮
    showClose: {
      Type: Boolean,
      default: true
    },
    // 关闭按钮的图片地址 https://img.alicdn.com/imgextra/i1/121022687/O1CN01ImN0O11VigqwzpLiK_!!121022687.png
    closeImage: {
      Type: String,
      default:
        'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACoAAAAqCAYAAADFw8lbAAAEyUlEQVR42sSZeWwNURTGp4OqtBo7sSXELragdkpQsRRJ1Zr4hyJiJ9YgxNIg1qANiT+E1i5IY0kVVWtQEbuEKLFGUSH27/ANN5PXmTvzupzkl/tm8t6b7517lnvvC0lKSjJ8WmnQAUSDFqABqALKgl8gD7wE90E2SAeXwFf1SxISErQeVtKHwCgwFsSDSIf3hYFKoCkYDBaDdyAViHdueHmoF6FtwDLQ23b/E7gM7oIcejIERIDaoBFoC8qA8mA8SQNz6W1XC9GY+nCQCCYAk/c+gF0gBZwH312+IxR0BCPBUIaH2A+wHsxHCHxx+gLT5QGN6a2JfG8uvVCDws9oiDQYlxkMGfHyQvARlADTwcXk5OT6foV2kS8ATXidymlcyen1a/Jjl9IJh3hPkjELYqO8Cu0KjjNZvtETw5jFBWXPmGSTGQKSeOn5iQ0kVLL0CINfPNcPbDMKyRCbGzEMBJ+ZD8cChYFdqGTqfsWT8otPGoVsEHsMwxDFs3shNsxJ6BrQ0Po8OGUUkVHsNCVml+cntB1jUWwn2GEUsTEMrASbDK+2CCQ0kYX6nfLLisMmKqUr0S60M+jG10vAm+JSCa8+x7CKlzHwaktV6DiObzUzPJIxFO1BQ12wGtTReO9GetVgY/kjNJzZbcWmTjHfxw51AsRqvL8eOAtmsJuFu3g1l+1ZLB5eDTVZ3K0P7tL0TkWOpSg61kVkBtuuNRthGs+wtJST5aQI7cEbkkRXNYVKgX6kIdYuUhYzMQwxN8tiExCLFqHNeSF9/aem0BzGp5PYQCJ7c/Gsk1RfuSD6U1dNpcDf9ZigTmKbMRZ9iVTsHscGJluW2FMf1SSQWGnBmaB6kCJVTVVNJZE++Cx9drEllS1KMCINpURFmEbBWA63Fz9s95cGIdJgp/zXmT4pZcOvSUzuZttTbblmnc3PIjjmidDXvKgdhMh0JdbzuCjWrbNOVovjS5P7bkPJ/mBESkz2BO0166ybNeJ431S2q+01NntuIq3E0amzjiZtk9tssWyTDzO4525bACK9NAUn68TtkNhpEXpOSagRml+S6iLSSeweHv242Qhl13rRyvoDvDlKyTQny/ZQJ+1iH7vVbEx7OR5UiKVIO7VicgvHCtwrudloMIV7/0uadVYW57O4Wvvi8v4pymlKkrpwvsDeLLZAY2pkwbAB3PSQfC+4cH7l4k1ZH8zkZRq8ecO+Z5rN40JJqnXFuGfaxPCTLjcn0OZOpnArXw8HY4paIbw5CcMgXq6HN2/mt6+XGLrN15tBryIUGavMpCTrfKcDCKkAceA9S8nhAOehhSUyhXpkBxxnP4YM1InugP7cBkjBPcqVUWFYCEROxXiQz5JlXV+IfKh7mpfJac+lZ6V87QXVClBkTc7YWsWTPSDyitfzUTlJlj8TbvE6jluDOdwZ+jX57GLO3ADeuyZrDYi86vV81FD2UVGsmT+5Zl0BnkhoseOEaogL46pqO4v/IqUEyalIR4h85BgjHv6+aUWRMbb7EstX6O0cpT1Gco0ry8fWygLDMjmDnQeBt3Qe7uVfkeugDwVLcsVzGsuwLXbV+I63XNAkG5r/hvgRqgqWs6pJPKrsbvz/Q6yyun0w/h6lP+BnzrCpfPMT2L8FGAA7k1GZ/vnaqAAAAABJRU5ErkJggg=='
    },
    // 是否隐藏库存显示
    hideStock: {
      Type: Boolean,
      default: false
    },
    // 颜色主题
    theme: {
      Type: String,
      default: 'default'
    },
    // 请求中的提示
    actionTips: {
      Type: String,
      default: '请求中...'
    },
    // 默认选中的SKU
    defaultSelect: {
      Type: Object
    },
    // 是否使用缓存
    useCache: {
      Type: Boolean,
      default: true
    },
    /**
     * 默认商品,设置该值可快速展示商品
     * 逻辑: 先展示 defaultGoods 信息,再取数据库,再更新页面(通常为更新库存)
     */
    defaultGoods: {
      Type: Object
    },
    /**
     * 金额是否需要除以100
     * 1:金额会除以100
     * 0:金额不会除以100
     */
    amountType: {
      Type: Number,
      default: 1
    },
    // 每次选择完SKU后,购买数量归1,如果有最小购买数量,则设置为最小购买数量
    selectedInit: {
      Type: Boolean,
      default: false
    },
    // 是否开启底部安全区适配,默认true
    safeAreaInsetBottom: {
      Type: Boolean,
      default: true
    },
  },
  data() {
    return {
      complete: false, // 组件是否加载完成
      goodsInfo: {}, // 商品信息
      isShow: false, // true 显示 false 隐藏
      initKey: true, // 是否需要初始化 true 是 false 否
      // #ifndef MP-BAIDU
      shopItemInfo: {}, // 存放要和选中的值进行匹配的数据(因百度小程序setData不支持中文字段,故不编译shopItemInfo变量)
      // #endif
      selectArr: [], // 存放被选中的值
      subIndex: [], // 是否选中 因为不确定是多规格还是单规格,所以这里定义数组来判断
      selectShop: {}, // 存放最后选中的商品
      selectNum: this.minBuyNum || 1, // 选中数量
      outFoStock: false, // 是否全部sku都缺货
      openTime: 0,
      themeColor: {
        // 默认主题
        default: {
          priceColor: 'rgb(254, 86, 10)',
          buyNowColor: '#ffffff',
          buyNowBackgroundColor: 'rgb(254, 86, 10)',
          addCartColor: '#ffffff',
          addCartBackgroundColor: 'rgb(255, 148, 2)',
          btnStyle: {
            color: '#333333',
            borderColor: '#f4f4f4',
            backgroundColor: '#ffffff'
          },
          activedStyle: {
            color: 'rgb(254, 86, 10)',
            borderColor: 'rgb(254, 86, 10)',
            backgroundColor: 'rgba(254,86,10,0.1)'
          },
          disableStyle: {
            color: '#c3c3c3',
            borderColor: '#f6f6f6',
            backgroundColor: '#f6f6f6'
          }
        },
        // 红黑主题
        'red-black': {
          priceColor: 'rgb(255, 68, 68)',
          buyNowColor: '#ffffff',
          buyNowBackgroundColor: 'rgb(255, 68, 68)',
          addCartColor: '#ffffff',
          addCartBackgroundColor: 'rgb(85, 85, 85)',
          activedStyle: {
            color: 'rgb(255, 68, 68)',
            borderColor: 'rgb(255, 68, 68)',
            backgroundColor: 'rgba(255,68,68,0.1)'
          }
        },
        // 黑白主题
        'black-white': {
          priceColor: 'rgb(47, 47, 52)',
          buyNowColor: '#ffffff',
          buyNowBackgroundColor: 'rgb(47, 47, 52)',
          addCartColor: 'rgb(47, 47, 52)',
          addCartBackgroundColor: 'rgb(235, 236, 242)',
          // btnStyle:{
          // 	color:"rgb(47, 47, 52)",
          // 	borderColor:"rgba(235,236,242,0.5)",
          // 	backgroundColor:"rgba(235,236,242,0.5)",
          // },
          activedStyle: {
            color: 'rgb(47, 47, 52)',
            borderColor: 'rgba(47,47,52,0.12)',
            backgroundColor: 'rgba(47,47,52,0.12)'
          }
        },
        // 咖啡色主题
        coffee: {
          priceColor: 'rgb(195, 167, 105)',
          buyNowColor: '#ffffff',
          buyNowBackgroundColor: 'rgb(195, 167, 105)',
          addCartColor: 'rgb(195, 167, 105)',
          addCartBackgroundColor: 'rgb(243, 238, 225)',
          activedStyle: {
            color: 'rgb(195, 167, 105)',
            borderColor: 'rgb(195, 167, 105)',
            backgroundColor: 'rgba(195, 167, 105,0.1)'
          }
        },
        // 浅绿色主题
        green: {
          priceColor: 'rgb(99, 190, 114)',
          buyNowColor: '#ffffff',
          buyNowBackgroundColor: 'rgb(99, 190, 114)',
          addCartColor: 'rgb(99, 190, 114)',
          addCartBackgroundColor: 'rgb(225, 244, 227)',
          activedStyle: {
            color: 'rgb(99, 190, 114)',
            borderColor: 'rgb(99, 190, 114)',
            backgroundColor: 'rgba(99, 190, 114,0.1)'
          }
        }
      }
    };
  },
  created() {
    let that = this;
    vk = that.vk;
    if (that.valueCom) {
      that.open();
    }
  },
  mounted() { },
  methods: {
    // 初始化
    init(notAutoClick) {
      let that = this;
      // 清空之前的数据
      that.selectArr = [];
      that.subIndex = [];
      that.selectShop = {};
      that.selectNum = that.minBuyNum || 1;
      that.outFoStock = false;
      that.shopItemInfo = {};
      let specListName = that.specListName;
      that.goodsInfo[specListName].map(item => {
        that.selectArr.push('');
        that.subIndex.push(-1);
      });
      that.checkItem(); // 计算sku里面规格形成路径
      that.checkInpath(-1); // 传-1是为了不跳过循环
      if (!notAutoClick) that.autoClickSku(); // 自动选择sku策略
    },
    // 使用vk路由模式框架获取商品信息
    findGoodsInfo(obj = {}) {
      let that = this;
      let { useCache } = obj;
      if (typeof vk == 'undefined') {
        that.toast('custom-action必须是function', 'none');
        return false;
      }
      let { actionTips } = that;
      let actionTitle = '';
      let actionAoading = false;
      if (actionTips !== 'custom') {
        actionTitle = useCache ? '' : '请求中...';
      } else {
        actionAoading = useCache ? false : true;
      }
      vk.callFunction({
        url: that.action,
        title: actionTitle,
        loading: actionAoading,
        data: {
          goods_id: that.goodsId
        },
        success(data) {
          that.updateGoodsInfo(data.goodsInfo);
          // 更新缓存
          goodsCache[that.goodsId] = data.goodsInfo;
          that.$emit('update-goods', data.goodsInfo);
        },
        fail() {
          that.updateValue(false);
        }
      });
    },
    updateValue(value) {
      let that = this;
      if (value) {
        that.$emit('open', true);
        that.$emit('input', true);
        that.$emit('update:modelValue', true);
      } else {
        that.$emit('input', false);
        that.$emit('close', 'close');
        that.$emit('update:modelValue', false);
      }
    },
    // 更新商品信息(库存、名称、图片)
    updateGoodsInfo(goodsInfo) {
      let that = this;
      // goodsInfo.sku_list.map((item, index) => {
      // 	item.sku_name_arr = ["20ml/瓶"];
      // });
      let { skuListName } = that;
      if (JSON.stringify(that.goodsInfo) === '{}' || that.goodsInfo[that.goodsIdName] !== goodsInfo[that.goodsIdName]) {
        that.goodsInfo = goodsInfo;
        that.initKey = true;
      } else {
        that.goodsInfo[skuListName] = goodsInfo[skuListName];
      }
      if (that.initKey) {
        that.initKey = false;
        that.init();
      }
      // 更新选中sku的库存信息
      let select_sku_info = that.getListItem(that.goodsInfo[skuListName], that.skuIdName, that.selectShop[that.skuIdName]);
      Object.assign(that.selectShop, select_sku_info);
      that.defaultSelectSku();
      that.complete = true;
    },
    async open() {
      let that = this;
      that.openTime = new Date().getTime();
      let findGoodsInfoRun = true;
      let skuListName = that.skuListName;
      // 先获取缓存中的商品信息
      let useCache = false;
      let goodsInfo = goodsCache[that.goodsId];
      if (goodsInfo && that.useCache) {
        useCache = true;
        that.updateGoodsInfo(goodsInfo);
      } else {
        that.complete = false;
      }
      if (that.customAction && typeof that.customAction === 'function') {
        try {
          goodsInfo = await that
            .customAction({
              useCache,
              goodsId: that.goodsId,
              goodsInfo,
              close: function () {
                setTimeout(function () {
                  that.close();
                }, 500);
              }
            })
            .catch(err => {
              setTimeout(function () {
                that.close();
              }, 500);
            });
        } catch (err) {
          let { message = '' } = err;
          if (message.indexOf('.catch is not a function') > -1) {
            that.toast('custom-action必须返回一个Promise', 'none');
            setTimeout(function () {
              that.close();
            }, 500);
            return false;
          }
        }
        // 更新缓存
        goodsCache[that.goodsId] = goodsInfo;
        if (goodsInfo && typeof goodsInfo == 'object' && JSON.stringify(goodsInfo) != '{}') {
          findGoodsInfoRun = false;
          that.updateGoodsInfo(goodsInfo);
          that.updateValue(true);
        } else {
          that.toast('未获取到商品信息', 'none');
          that.$emit('input', false);
          return false;
        }
      } else if (typeof that.localdata !== 'undefined' && that.localdata !== null) {
        goodsInfo = that.localdata;
        if (goodsInfo && typeof goodsInfo == 'object' && JSON.stringify(goodsInfo) != '{}') {
          findGoodsInfoRun = false;
          that.updateGoodsInfo(goodsInfo);
          that.updateValue(true);
        } else {
          that.toast('未获取到商品信息', 'none');
          that.$emit('input', false);
          return false;
        }
      } else {
        if (findGoodsInfoRun) that.findGoodsInfo({ useCache });
      }
    },
    // 监听 - 弹出层收起
    close(s) {
      let that = this;
      if (new Date().getTime() - that.openTime < 400) {
        return false;
      }
      if (s == 'mask') {
        if (that.maskCloseAble !== false) {
          that.$emit('input', false);
          that.$emit('close', 'mask');
          that.$emit('update:modelValue', false);
        }
      } else {
        that.$emit('input', false);
        that.$emit('close', 'close');
        that.$emit('update:modelValue', false);
      }
    },
    moveHandle() {
      //禁止父元素滑动
    },
    // sku按钮的点击事件
    skuClick(value, index1, index2) {
      let that = this;
      if (value.ishow) {
        if (that.selectArr[index1] != value.name) {
          that.$set(that.selectArr, index1, value.name);
          that.$set(that.subIndex, index1, index2);
        } else {
          that.$set(that.selectArr, index1, '');
          that.$set(that.subIndex, index1, -1);
        }
        that.checkInpath(index1);
        // 如果全部选完
        that.checkSelectShop();
      }
    },
    // 检测是否已经选完sku
    checkSelectShop() {
      let that = this;
      // 如果全部选完
      if (that.selectArr.every(item => item != '')) {
        that.selectShop = that.shopItemInfo[that.getArrayToSting(that.selectArr)];
        let stock = that.selectShop[that.stockName];
        if (typeof stock !== 'undefined' && that.selectNum > stock) {
          that.selectNum = stock;
        }
        if (that.selectNum > that.maxBuyNum) {
          that.selectNum = that.maxBuyNum;
        }
        if (that.selectNum < that.minBuyNum) {
          that.selectNum = that.minBuyNum;
        }
        if (that.selectedInit) {
          that.selectNum = that.minBuyNum || 1;
        }
      } else {
        that.selectShop = {};
      }
    },
    // 检查路径
    checkInpath(clickIndex) {
      let that = this;
      let specListName = that.specListName;
      //console.time('筛选可选路径需要的时间是');
      //循环所有属性判断哪些属性可选
      //当前选中的兄弟节点和已选中属性不需要循环
      let specList = that.goodsInfo[specListName];
      for (let i = 0, len = specList.length; i < len; i++) {
        if (i == clickIndex) {
          continue;
        }
        let len2 = specList[i].list.length;
        for (let j = 0; j < len2; j++) {
          if (that.subIndex[i] != -1 && j == that.subIndex[i]) {
            continue;
          }
          let choosed_copy = [...that.selectArr];
          that.$set(choosed_copy, i, specList[i].list[j].name);
          let choosed_copy2 = choosed_copy.filter(item => item !== '' && typeof item !== 'undefined');
          if (that.shopItemInfo.hasOwnProperty(that.getArrayToSting(choosed_copy2))) {
            specList[i].list[j].ishow = true;
          } else {
            specList[i].list[j].ishow = false;
          }
        }
      }
      that.$set(that.goodsInfo, specListName, specList);
      // console.timeEnd('筛选可选路径需要的时间是');
    },
    // 计算sku里面规格形成路径
    checkItem() {
      let that = this;
      // console.time('计算有多小种可选路径需要的时间是');
      let { stockName } = that;
      let skuListName = that.skuListName;
      // 去除库存小于等于0的商品sku
      let originalSkuList = that.goodsInfo[skuListName];
      let skuList = [];
      let stockNum = 0;
      originalSkuList.map((skuItem, index) => {
        if (skuItem[stockName] > 0) {
          skuList.push(skuItem);
          stockNum += skuItem[stockName];
        }
      });
      if (stockNum <= 0) {
        that.outFoStock = true;
      }
      // 计算有多小种可选路径
      let result = skuList.reduce(
        (arrs, items) => {
          return arrs.concat(
            items[that.skuArrName].reduce(
              (arr, item) => {
                return arr.concat(
                  arr.map(item2 => {
                    // 利用对象属性的唯一性实现二维数组去重
                    //console.log(1,that.shopItemInfo,that.getArrayToSting([...item2, item]),item2,item,items);
                    if (!that.shopItemInfo.hasOwnProperty(that.getArrayToSting([...item2, item]))) {
                      that.shopItemInfo[that.getArrayToSting([...item2, item])] = items;
                    }
                    return [...item2, item];
                  })
                );
              },
              [[]]
            )
          );
        },
        [[]]
      );
      // console.timeEnd('计算有多小种可选路径需要的时间是');
    },
    getArrayToSting(arr) {
      let str = '';
      arr.map((item, index) => {
        item = item.replace(/\./g, '。');
        if (index == 0) {
          str += item;
        } else {
          str += ',' + item;
        }
      });
      return str;
    },
    // 检测sku选项是否已全部选完,且有库存
    checkSelectComplete(obj = {}) {
      let that = this;
      let clickTime = new Date().getTime();
      if (that.clickTime && clickTime - that.clickTime < 400) {
        return false;
      }
      that.clickTime = clickTime;
      let { selectShop, selectNum, stockText, stockName } = that;
      if (!selectShop || !selectShop[that.skuIdName]) {
        that.toast('请先选择对应规格', 'none');
        return false;
      }
      if (selectNum <= 0) {
        that.toast('购买数量必须>0', 'none');
        return false;
      }
      // 判断库存
      if (selectNum > selectShop[stockName]) {
        that.toast(stockText + '不足', 'none');
        return false;
      }
      if (typeof obj.success == 'function') obj.success(selectShop);
    },
    // 加入购物车
    addCart() {
      let that = this;
      that.checkSelectComplete({
        success: function (selectShop) {
          selectShop.buy_num = that.selectNum;
          that.$emit('add-cart', selectShop);
          that.$emit('cart', selectShop);
          // setTimeout(function() {
          // 	that.init();
          // }, 300);
        }
      });
    },
    // 立即购买
    buyNow() {
      let that = this;
      that.checkSelectComplete({
        success: function (selectShop) {
          selectShop.buy_num = that.selectNum;
          that.$emit('buy-now', selectShop);
          that.$emit('buy', selectShop);
        }
      });
    },
    // 弹窗
    toast(title, icon) {
      uni.showToast({
        title: title,
        icon: icon
      });
    },
    // 获取对象数组中的某一个item,根据指定的键值
    getListItem(list, key, value) {
      let that = this;
      let item;
      for (let i in list) {
        if (typeof value == 'object') {
          if (JSON.stringify(list[i][key]) === JSON.stringify(value)) {
            item = list[i];
            break;
          }
        } else {
          if (list[i][key] === value) {
            item = list[i];
            break;
          }
        }
      }
      return item;
    },
    getListIndex(list, key, value) {
      let that = this;
      let index = -1;
      for (let i = 0; i < list.length; i++) {
        if (list[i][key] === value) {
          index = i;
          break;
        }
      }
      return index;
    },
    // 自动选择sku前提是只有一组sku,默认自动选择最前面的有库存的sku
    autoClickSku() {
      let that = this;
      let { stockName } = that;
      let skuList = that.goodsInfo[that.skuListName];
      let specListArr = that.goodsInfo[that.specListName];
      if (specListArr.length == 1) {
        let specList = specListArr[0].list;
        for (let i = 0; i < specList.length; i++) {
          let sku = that.getListItem(skuList, that.skuArrName, [specList[i].name]);
          if (sku && sku[stockName] > 0) {
            that.skuClick(specList[i], 0, i);
            break;
          }
        }
      }
    },
    // 主题颜色
    themeColorFn(name) {
      let that = this;
      let { theme, themeColor } = that;
      let color = that[name] ? that[name] : themeColor[theme][name];
      return color;
    },
    defaultSelectSku() {
      let that = this;
      let { defaultSelect } = that;
      if (defaultSelect && defaultSelect.sku && defaultSelect.sku.length > 0) {
        that.selectSku(defaultSelect);
      }
    },
    /**
       * 主动方法 - 设置sku
      that.$refs.skuPopup.selectSku({
        sku:["红色","256G","公开版"],
        num:5
      });
       */
    selectSku(obj = {}) {
      let that = this;
      let { sku: skuArr, num: selectNum } = obj;
      let specListArr = that.goodsInfo[that.specListName];
      if (skuArr && specListArr.length === skuArr.length) {
        // 先清空
        let skuClickArr = [];
        let clickKey = true;
        for (let index = 0; index < skuArr.length; index++) {
          let skuName = skuArr[index];
          let specList = specListArr[index].list;
          let index1 = index;
          let index2 = that.getListIndex(specList, 'name', skuName);
          if (index2 == -1) {
            clickKey = false;
            break;
          }
          skuClickArr.push({
            spec: specList[index2],
            index1: index1,
            index2: index2
          });
        }
        if (clickKey) {
          that.init(true);
          skuClickArr.map(item => {
            that.skuClick(item.spec, item.index1, item.index2);
          });
        }
      }
      if (selectNum > 0) that.selectNum = selectNum;
    },
    priceFilter(n = 0) {
      let that = this;
      if (typeof n == 'string') {
        n = parseFloat(n);
      }
      if (that.amountType === 0) {
        return n.toFixed(2);
      } else {
        return (n / 100).toFixed(2);
      }
    },
    pushGoodsCache(goodsInfo) {
      let that = this;
      let { goodsIdName } = that;
      goodsCache[goodsInfo[goodsIdName]] = goodsInfo;
    },
    // 用于阻止冒泡
    stop() { },
    // 图片预览
    previewImage() {
      let that = this;
      let { selectShop, goodsInfo, goodsThumbName } = that;
      let src = selectShop.image ? selectShop.image : goodsInfo[goodsThumbName];
      if (src) {
        uni.previewImage({
          urls: [src]
        });
      }
    },
    getMaxStock() {
      let maxStock = 0;
      let that = this;
      let { selectShop = {}, goodsInfo = {}, skuListName, stockName } = that;
      if (selectShop[stockName]) {
        maxStock = selectShop[stockName];
      } else {
        let skuList = goodsInfo[skuListName];
        if (skuList && skuList.length > 0) {
          let valueArr = [];
          skuList.map((skuItem, index) => {
            valueArr.push(skuItem[stockName]);
          });
          let max = Math.max(...valueArr);
          maxStock = max;
        }
      }
      return maxStock;
    },
    numChange(e) {
      this.$emit("num-change", e.value);
    }
  },
  // 计算属性
  computed: {
    valueCom() {
      // #ifndef VUE3
      return this.value;
      // #endif

      // #ifdef VUE3
      return this.modelValue;
      // #endif
    },
    // 最大购买数量
    maxBuyNumCom() {
      let that = this;
      let maxStock = that.getMaxStock();
      let max = that.maxBuyNum || 100000;
      // 最大购买量不能超过当前商品的库存
      if (max > maxStock) {
        max = maxStock;
      }
      return max;
    },
    // 是否是多规格
    isManyCom() {
      let that = this;
      let { goodsInfo, defaultSingleSkuName, specListName } = that;
      let isMany = true;
      if (
        goodsInfo[specListName] &&
        goodsInfo[specListName].length === 1 &&
        goodsInfo[specListName][0].list.length === 1 &&
        goodsInfo[specListName][0].name === defaultSingleSkuName
      ) {
        isMany = false;
      }
      return isMany;
    },
    // 默认价格区间计算
    priceCom() {
      let str = '';
      let that = this;
      let { selectShop = {}, goodsInfo = {}, skuListName, skuIdName } = that;
      if (selectShop[skuIdName]) {
        str = that.priceFilter(selectShop.price);
      } else {
        let skuList = goodsInfo[skuListName];
        if (skuList && skuList.length > 0) {
          let valueArr = [];
          skuList.map((skuItem, index) => {
            valueArr.push(skuItem.price);
          });
          let min = that.priceFilter(Math.min(...valueArr));
          let max = that.priceFilter(Math.max(...valueArr));
          if (min === max) {
            str = min + '';
          } else {
            str = `${min} - ${max}`;
          }
        }
      }
      return str;
    },
    // 库存显示
    stockCom() {
      let str = '';
      let that = this;
      let { selectShop = {}, goodsInfo = {}, skuListName, stockName } = that;
      if (selectShop[stockName]) {
        str = selectShop[stockName];
      } else {
        let skuList = goodsInfo[skuListName];
        if (skuList && skuList.length > 0) {
          let valueArr = [];
          skuList.map((skuItem, index) => {
            valueArr.push(skuItem[stockName]);
          });
          let min = Math.min(...valueArr);
          let max = Math.max(...valueArr);
          if (min === max) {
            str = min;
          } else {
            str = `${min} - ${max}`;
          }
        }
      }
      return str;
    }
  },
  watch: {
    valueCom(newVal, oldValue) {
      let that = this;
      if (newVal) {
        that.open();
      }
    },
    defaultGoods: {
      immediate: true,
      handler: function (newVal, oldValue) {
        let that = this;
        let { goodsIdName } = that;
        if (typeof newVal === 'object' && newVal && newVal[goodsIdName] && !goodsCache[newVal[goodsIdName]]) {
          that.pushGoodsCache(newVal);
        }
      }
    }
  }
};
</script>

<style lang="scss" scoped>
/*  sku弹出层 */
.vk-data-goods-sku-popup {
  position: fixed;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 990;
  overflow: hidden;

  &.show {
    display: block;

    .mask {
      animation: showPopup 0.2s linear both;
    }

    .layer {
      animation: showLayer 0.2s linear both;
      bottom: var(--window-bottom);
    }
  }

  &.hide {
    .mask {
      animation: hidePopup 0.2s linear both;
    }

    .layer {
      animation: hideLayer 0.2s linear both;
    }
  }

  &.none {
    display: none;
  }

  .mask {
    position: fixed;
    top: 0;
    width: 100%;
    height: 100%;
    z-index: 1;
    background-color: rgba(0, 0, 0, 0.3);
  }

  .layer {
    display: flex;
    width: 100%;
    // height: 1014rpx;
    flex-direction: column;
    // min-height: 40vh;
    // max-height: 1014rpx;
    position: fixed;
    z-index: 99;
    bottom: 0;
    border-radius: 10rpx 10rpx 0 0;
    background-color: #fff;

    .specification-wrapper {
      width: 100%;
      padding: 30rpx 25rpx;
      box-sizing: border-box;

      .specification-wrapper-content {
        width: 100%;
        max-height: 900rpx;
        min-height: 300rpx;

        &::-webkit-scrollbar {
          /*隐藏滚轮*/
          display: none;
        }

        .specification-header {
          width: 100%;
          display: flex;
          flex-direction: row;
          position: relative;
          margin-bottom: 40rpx;

          .specification-left {
            width: 180rpx;
            height: 180rpx;
            flex: 0 0 180rpx;

            .product-img {
              width: 180rpx;
              height: 180rpx;
            }
          }

          .specification-right {
            flex: 1;
            padding: 0 35rpx 0 28rpx;
            box-sizing: border-box;
            display: flex;
            flex-direction: column;
            justify-content: flex-end;
            font-weight: 500;

            .price-content {
              color: #fe560a;
              margin-bottom: 20rpx;

              .sign {
                font-size: 28rpx;
              }

              .price {
                margin-left: 4rpx;
                font-size: 48rpx;
              }

              .price2 {
                margin-left: 4rpx;
                font-size: 36rpx;
              }
            }

            .inventory {
              font-size: 24rpx;
              color: #999999;
              margin-bottom: 14rpx;
            }

            .choose {
              font-size: 28rpx;
              color: #333333;
            }
          }
        }

        .specification-content {
          font-weight: 500;

          .specification-item {
            margin-bottom: 40rpx;

            &:last-child {
              margin-bottom: 0;
            }

            .item-title {
              margin-bottom: 20rpx;
              font-size: 28rpx;
              color: #999999;
            }

            .item-wrapper {
              display: flex;
              flex-direction: row;
              flex-flow: wrap;

              .item-content {
                display: inline-block;
                padding: 10rpx 35rpx;
                font-size: 24rpx;
                border-radius: 10rpx;
                background-color: #ffffff;
                color: #333333;
                margin-right: 20rpx;
                margin-bottom: 16rpx;
                border: 1px solid #f4f4f4;
                box-sizing: border-box;

                &.actived {
                  border-color: #fe560a;
                  color: #fe560a;
                }

                &.noactived {
                  background-color: #f6f6f6;
                  border-color: #f6f6f6;
                  color: #c3c3c3;
                }
              }
            }
          }

          .number-box-view {
            display: flex;
            padding-top: 30rpx;
          }
        }
      }

      .close {
        position: absolute;
        top: 30rpx;
        right: 25rpx;
        width: 50rpx;
        height: 50rpx;
        text-align: center;
        line-height: 50rpx;

        .close-item {
          width: 50rpx;
          height: 50rpx;
        }
      }
    }

    .btn-wrapper {
      display: flex;
      width: 100%;
      height: 120rpx;
      flex: 0 0 120rpx;
      align-items: center;
      justify-content: space-between;
      padding: 0 26rpx;
      box-sizing: border-box;

      .layer-btn {
        width: 335rpx;
        height: 76rpx;
        border-radius: 38rpx;
        color: #fff;
        line-height: 76rpx;
        text-align: center;
        font-weight: 500;
        font-size: 28rpx;

        &.add-cart {
          background: #ffbe46;
        }

        &.buy {
          background: #fe560a;
        }
      }

      .sure {
        width: 698rpx;
        height: 68rpx;
        border-radius: 38rpx;
        color: #fff;
        line-height: 68rpx;
        text-align: center;
        font-weight: 500;
        font-size: 28rpx;
        background: #fe560a;
      }

      .sure.add-cart {
        background: #ff9402;
      }
    }

    .btn-wrapper.safe-area-inset-bottom {
      padding-bottom: 0;
      padding-bottom: constant(safe-area-inset-bottom);
      padding-bottom: env(safe-area-inset-bottom);
    }
  }

  @keyframes showPopup {
    0% {
      opacity: 0;
    }

    100% {
      opacity: 1;
    }
  }

  @keyframes hidePopup {
    0% {
      opacity: 1;
    }

    100% {
      opacity: 0;
    }
  }

  @keyframes showLayer {
    0% {
      transform: translateY(120%);
    }

    100% {
      transform: translateY(0%);
    }
  }

  @keyframes hideLayer {
    0% {
      transform: translateY(0);
    }

    100% {
      transform: translateY(120%);
    }
  }
}
</style>
<!-- 步进器 -->
<template>
  <view class="vk-data-input-number-box">
    <view class="u-icon-minus" :class="{ 'u-icon-disabled': disabled || inputVal <= min }" :style="{
      background: bgColor,
      height: inputHeight + 'rpx',
      color: color,
      fontSize: size + 'rpx',
      minHeight: '1.4em'
    }" @click="emptyClick" @touchstart.prevent="btnTouchStart('minus')" @touchend.stop.prevent="clearTimer">
      <view :style="'font-size:' + (Number(size) + 10) + 'rpx'" class="num-btn">-</view>
    </view>
    <input v-model="inputVal" :disabled="disabledInput || disabled" :cursor-spacing="getCursorSpacing"
      :class="{ 'u-input-disabled': disabled }" class="u-number-input" type="number" :style="{
      color: color,
      fontSize: size + 'rpx',
      background: bgColor,
      height: inputHeight + 'rpx',
      width: inputWidth + 'rpx'
    }" @blur="onBlur" @click="showInput = true" />
    <view class="u-icon-plus" :class="{ 'u-icon-disabled': disabled || inputVal >= max }" :style="{
      background: bgColor,
      height: inputHeight + 'rpx',
      color: color,
      fontSize: size + 'rpx',
      minHeight: '1.4em'
    }" @click="emptyClick" @touchstart.prevent="btnTouchStart('plus')" @touchend.stop.prevent="clearTimer">
      <view :style="'font-size:' + (Number(size) + 10) + 'rpx'" class="num-btn">+</view>
    </view>
  </view>
</template>
<script>
/**
 * numberBox 步进器(此为uview组件改造)
 * @description 该组件一般用于商城购物选择物品数量的场景。注意:该输入框只能输入大于或等于0的整数,不支持小数输入
 * @tutorial https://www.uviewui.com/components/numberBox.html
 * @property {Number} value 输入框初始值(默认1)
 * @property {String} bg-color 输入框和按钮的背景颜色(默认#F2F3F5)
 * @property {Number} min 用户可输入的最小值(默认0)
 * @property {Number} max 用户可输入的最大值(默认99999)
 * @property {Number} step 步长,每次加或减的值(默认1)
 * @property {Number} stepFirst 步进值,首次增加或最后减的值(默认step值和一致)
 * @property {Boolean} disabled 是否禁用操作,禁用后无法加减或手动修改输入框的值(默认false)
 * @property {Boolean} disabled-input 是否禁止输入框手动输入值(默认false)
 * @property {Boolean} positive-integer 是否只能输入正整数(默认true)
 * @property {String | Number} size 输入框文字和按钮字体大小,单位rpx(默认26)
 * @property {String} color 输入框文字和加减按钮图标的颜色(默认#323233)
 * @property {String | Number} input-width 输入框宽度,单位rpx(默认80)
 * @property {String | Number} input-height 输入框和按钮的高度,单位rpx(默认50)
 * @property {String | Number} index 事件回调时用以区分当前发生变化的是哪个输入框
 * @property {Boolean} long-press 是否开启长按连续递增或递减(默认true)
 * @property {String | Number} press-time 开启长按触发后,每触发一次需要多久,单位ms(默认250)
 * @property {String | Number} cursor-spacing 指定光标于键盘的距离,避免键盘遮挡输入框,单位rpx(默认200)
 * @event {Function} change 输入框内容发生变化时触发,对象形式
 * @event {Function} blur 输入框失去焦点时触发,对象形式
 * @event {Function} minus 点击减少按钮时触发(按钮可点击情况下),对象形式
 * @event {Function} plus 点击增加按钮时触发(按钮可点击情况下),对象形式
 * @example <vk-data-input-number-box :min="1" :max="100"></vk-data-input-number-box>
 */
export default {
  name: 'vk-data-input-number-box',
  emits: ['update:modelValue', 'input', 'change', 'blur', 'plus', 'minus'],
  props: {
    // 预显示的数字
    value: {
      type: Number,
      default: 1
    },
    modelValue: {
      type: Number,
      default: 1
    },
    // 背景颜色
    bgColor: {
      type: String,
      default: '#FFFFFF'
    },
    // 最小值
    min: {
      type: Number,
      default: 0
    },
    // 最大值
    max: {
      type: Number,
      default: 99999
    },
    // 步进值,每次加或减的值
    step: {
      type: Number,
      default: 1
    },
    // 步进值,首次增加或最后减的值
    stepFirst: {
      type: Number,
      default: 0
    },
    // 是否只能输入 step 的倍数
    stepStrictly: {
      type: Boolean,
      default: false
    },
    // 是否禁用加减操作
    disabled: {
      type: Boolean,
      default: false
    },
    // input的字体大小,单位rpx
    size: {
      type: [Number, String],
      default: 26
    },
    // 加减图标的颜色
    color: {
      type: String,
      default: '#323233'
    },
    // input宽度,单位rpx
    inputWidth: {
      type: [Number, String],
      default: 80
    },
    // input高度,单位rpx
    inputHeight: {
      type: [Number, String],
      default: 50
    },
    // index索引,用于列表中使用,让用户知道是哪个numberbox发生了变化,一般使用for循环出来的index值即可
    index: {
      type: [Number, String],
      default: ''
    },
    // 是否禁用输入框,与disabled作用于输入框时,为OR的关系,即想要禁用输入框,又可以加减的话
    // 设置disabled为false,disabledInput为true即可
    disabledInput: {
      type: Boolean,
      default: false
    },
    // 输入框于键盘之间的距离
    cursorSpacing: {
      type: [Number, String],
      default: 100
    },
    // 是否开启长按连续递增或递减
    longPress: {
      type: Boolean,
      default: true
    },
    // 开启长按触发后,每触发一次需要多久
    pressTime: {
      type: [Number, String],
      default: 250
    },
    // 是否只能输入大于或等于0的整数(正整数)
    positiveInteger: {
      type: Boolean,
      default: true
    }
  },
  watch: {
    valueCom(v1, v2) {
      // 只有value的改变是来自外部的时候,才去同步inputVal的值,否则会造成循环错误
      if (!this.changeFromInner) {
        this.inputVal = v1;
        // 因为inputVal变化后,会触发this.handleChange(),在其中changeFromInner会再次被设置为true,
        // 造成外面修改值,也导致被认为是内部修改的混乱,这里进行this.$nextTick延时,保证在运行周期的最后处
        // 将changeFromInner设置为false
        this.$nextTick(function () {
          this.changeFromInner = false;
        });
      }
    },
    inputVal(v1, v2) {
      // 为了让用户能够删除所有输入值,重新输入内容,删除所有值后,内容为空字符串
      if (v1 == '') return;
      let value = 0;
      // 首先判断是否数值,并且在min和max之间,如果不是,使用原来值
      let tmp = this.isNumber(v1);
      if (tmp && v1 >= this.min && v1 <= this.max) value = v1;
      else value = v2;
      // 判断是否只能输入大于等于0的整数
      if (this.positiveInteger) {
        // 小于0,或者带有小数点,
        if (v1 < 0 || String(v1).indexOf('.') !== -1) {
          value = v2;
          // 双向绑定input的值,必须要使用$nextTick修改显示的值
          this.$nextTick(() => {
            this.inputVal = v2;
          });
        }
      }
      // 发出change事件
      this.handleChange(value, 'change');
    },
    min(v1) {
      if (v1 !== undefined && v1 != '' && this.valueCom < v1) {
        this.$emit('input', v1);
        this.$emit('update:modelValue', v1);
      }
    },
    max(v1) {
      if (v1 !== undefined && v1 != '' && this.valueCom > v1) {
        this.$emit('input', v1);
        this.$emit('update:modelValue', v1);
      }
    }
  },
  data() {
    return {
      inputVal: 1, // 输入框中的值,不能直接使用props中的value,因为应该改变props的状态
      timer: null, // 用作长按的定时器
      changeFromInner: false, // 值发生变化,是来自内部还是外部
      innerChangeTimer: null,// 内部定时器
      showInput: false,
    };
  },
  created() {
    this.inputVal = Number(this.valueCom);
  },
  computed: {
    valueCom() {
      // #ifndef VUE3
      return this.value;
      // #endif

      // #ifdef VUE3
      return this.modelValue;
      // #endif
    },
    getCursorSpacing() {
      // 先将值转为px单位,再转为数值
      return Number(uni.upx2px(this.cursorSpacing));
    }
  },
  methods: {
    // 空点击事件,主要用于解决PC端H5由于无click事件导致触摸位置不准确的问题
    emptyClick() {

    },
    // 触摸事件开始
    btnTouchStart(callback) {
      // 先执行一遍方法,否则会造成松开手时,就执行了clearTimer,导致无法实现功能
      this[callback]();
      // 如果没开启长按功能,直接返回
      if (!this.longPress) return;
      clearInterval(this.timer); //再次清空定时器,防止重复注册定时器
      this.timer = null;
      this.timer = setInterval(() => {
        // 执行加或减函数
        this[callback]();
      }, this.pressTime);
    },
    // 清除定时器
    clearTimer() {
      this.$nextTick(() => {
        clearInterval(this.timer);
        this.timer = null;
      });
    },
    // 减
    minus() {
      this.computeVal('minus');
    },
    // 加
    plus() {
      this.computeVal('plus');
    },
    // 为了保证小数相加减出现精度溢出的问题
    calcPlus(num1, num2) {
      let baseNum, baseNum1, baseNum2;
      try {
        baseNum1 = num1.toString().split('.')[1].length;
      } catch (e) {
        baseNum1 = 0;
      }
      try {
        baseNum2 = num2.toString().split('.')[1].length;
      } catch (e) {
        baseNum2 = 0;
      }
      baseNum = Math.pow(10, Math.max(baseNum1, baseNum2));
      let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2; //精度
      return ((num1 * baseNum + num2 * baseNum) / baseNum).toFixed(precision);
    },
    // 为了保证小数相加减出现精度溢出的问题
    calcMinus(num1, num2) {
      let baseNum, baseNum1, baseNum2;
      try {
        baseNum1 = num1.toString().split('.')[1].length;
      } catch (e) {
        baseNum1 = 0;
      }
      try {
        baseNum2 = num2.toString().split('.')[1].length;
      } catch (e) {
        baseNum2 = 0;
      }
      baseNum = Math.pow(10, Math.max(baseNum1, baseNum2));
      let precision = baseNum1 >= baseNum2 ? baseNum1 : baseNum2;
      return ((num1 * baseNum - num2 * baseNum) / baseNum).toFixed(precision);
    },
    computeVal(type) {
      uni.hideKeyboard();
      if (this.disabled) return;
      let value = 0;
      // 新增stepFirst开始
      // 减
      if (type === 'minus') {
        if (this.stepFirst > 0 && this.inputVal == this.stepFirst) {
          value = this.min;
        } else {
          value = this.calcMinus(this.inputVal, this.step);
        }
      } else if (type === 'plus') {
        if (this.stepFirst > 0 && this.inputVal < this.stepFirst) {
          value = this.stepFirst;
        } else {
          value = this.calcPlus(this.inputVal, this.step);
        }
      }
      if (this.stepStrictly) {
        let strictly = value % this.step;
        if (strictly > 0) {
          value -= strictly;
        }
      }
      if (value > this.max) {
        value = this.max;
      } else if (value < this.min) {
        value = this.min;
      }
      // 新增stepFirst结束
      this.inputVal = value;
      this.handleChange(value, type);
    },
    // 处理用户手动输入的情况
    onBlur(event) {
      let val = 0;
      let value = event.detail.value;
      // 如果为非0-9数字组成,或者其第一位数值为0,直接让其等于min值
      // 这里不直接判断是否正整数,是因为用户传递的props min值可能为0
      if (!/(^\d+$)/.test(value) || value[0] == 0) val = this.min;
      val = +value;

      // 新增stepFirst开始
      if (this.stepFirst > 0 && this.inputVal < this.stepFirst && this.inputVal > 0) {
        val = this.stepFirst;
      }
      // 新增stepFirst结束
      if (this.stepStrictly) {
        let strictly = val % this.step;
        if (strictly > 0) {
          val -= strictly;
        }
      }
      if (val > this.max) {
        val = this.max;
      } else if (val < this.min) {
        val = this.min;
      }
      this.$nextTick(() => {
        this.inputVal = val;
      });
      this.handleChange(val, 'blur');
    },
    handleChange(value, type) {
      if (this.disabled) return;
      // 清除定时器,避免造成混乱
      if (this.innerChangeTimer) {
        clearTimeout(this.innerChangeTimer);
        this.innerChangeTimer = null;
      }
      // 发出input事件,修改通过v-model绑定的值,达到双向绑定的效果
      this.changeFromInner = true;
      // 一定时间内,清除changeFromInner标记,否则内部值改变后
      // 外部通过程序修改value值,将会无效
      this.innerChangeTimer = setTimeout(() => {
        this.changeFromInner = false;
      }, 150);
      this.$emit('input', Number(value));
      this.$emit('update:modelValue', Number(value));
      this.$emit(type, {
        // 转为Number类型
        value: Number(value),
        index: this.index
      });
    },
    /**
     * 验证十进制数字
     */
    isNumber(value) {
      return /^(?:-?\d+|-?\d{1,3}(?:,\d{3})+)?(?:\.\d+)?$/.test(value);
    }
  }
};
</script>

<style lang="scss" scoped>
.vk-data-input-number-box {
  display: inline-flex;
  align-items: center;
  box-sizing: border-box;
}

.u-number-input {
  position: relative;
  text-align: center;
  padding: 0;
  margin: 0rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2rpx solid #f4f4f4;
  border-left: 0;
  border-right: 0;
  box-sizing: border-box;
}

.u-icon-plus,
.u-icon-minus {
  width: 60rpx;
  display: flex;
  justify-content: center;
  align-items: center;
  border: 2rpx solid #f4f4f4;
  box-sizing: border-box;
}

.u-icon-plus {
  border-radius: 0 8rpx 8rpx 0;
}

.u-icon-minus {
  border-radius: 8rpx 0 0 8rpx;
}

.u-icon-disabled {
  color: #c8c9cc !important;
  background-color: #f2f3f5 !important;
}

.u-input-disabled {
  color: #c8c9cc !important;
  background-color: #f2f3f5 !important;
}

.num-btn {
  font-weight: 550;
  line-height: 50rpx;
}
</style>

类型声明

import { Component } from '@uni-helper/uni-app-types'

/** SKU 弹出层 */
export type SkuPopup = Component<SkuPopupProps>

/** SKU 弹出层实例 */
export type SkuPopupInstance = InstanceType<SkuPopup>

/** SKU 弹出层属性 */
export type SkuPopupProps = {
  /** 双向绑定,true 为打开组件,false 为关闭组件 */
  modelValue: boolean
  /** 商品信息本地数据源 */
  localdata: SkuPopupLocaldata
  /** 按钮模式 1:都显示 2:只显示购物车 3:只显示立即购买 */
  mode?: 1 | 2 | 3
  /** 该商品已抢完时的按钮文字 */
  noStockText?: string
  /** 库存文字 */
  stockText?: string
  /** 点击遮罩是否关闭组件 */
  maskCloseAble?: boolean
  /** 顶部圆角值 */
  borderRadius?: string | number
  /** 最小购买数量 */
  minBuyNum?: number
  /** 最大购买数量 */
  maxBuyNum?: number
  /** 每次点击后的数量 */
  stepBuyNum?: number
  /** 是否只能输入 step 的倍数 */
  stepStrictly?: boolean
  /** 是否隐藏库存的显示 */
  hideStock?: false
  /** 主题风格 */
  theme?: 'default' | 'red-black' | 'black-white' | 'coffee' | 'green'
  /** 默认金额会除以100(即100=1元),若设置为0,则不会除以100(即1=1元) */
  amountType?: 1 | 0
  /** 自定义获取商品信息的函数(已知支付宝不支持,支付宝请改用localdata属性) */
  customAction?: () => void
  /** 是否显示右上角关闭按钮 */
  showClose?: boolean
  /** 关闭按钮的图片地址 */
  closeImage?: string
  /** 价格的字体颜色 */
  priceColor?: string
  /** 立即购买 - 按钮的文字 */
  buyNowText?: string
  /** 立即购买 - 按钮的字体颜色 */
  buyNowColor?: string
  /** 立即购买 - 按钮的背景颜色 */
  buyNowBackgroundColor?: string
  /** 加入购物车 - 按钮的文字 */
  addCartText?: string
  /** 加入购物车 - 按钮的字体颜色 */
  addCartColor?: string
  /** 加入购物车 - 按钮的背景颜色 */
  addCartBackgroundColor?: string
  /** 商品缩略图背景颜色 */
  goodsThumbBackgroundColor?: string
  /** 样式 - 不可点击时,按钮的样式 */
  disableStyle?: object
  /** 样式 - 按钮点击时的样式 */
  activedStyle?: object
  /** 样式 - 按钮常态的样式 */
  btnStyle?: object
  /** 字段名 - 商品表id的字段名 */
  goodsIdName?: string
  /** 字段名 - sku表id的字段名 */
  skuIdName?: string
  /** 字段名 - 商品对应的sku列表的字段名 */
  skuListName?: string
  /** 字段名 - 商品规格名称的字段名 */
  specListName?: string
  /** 字段名 - sku库存的字段名 */
  stockName?: string
  /** 字段名 - sku组合路径的字段名 */
  skuArrName?: string
  /** 字段名 - 商品缩略图字段名(未选择sku时) */
  goodsThumbName?: string
  /** 被选中的值 */
  selectArr?: string[]

  /** 打开弹出层 */
  onOpen: () => void
  /** 关闭弹出层 */
  onClose: () => void
  /** 点击加入购物车时(需选择完SKU才会触发)*/
  onAddCart: (event: SkuPopupEvent) => void
  /** 点击立即购买时(需选择完SKU才会触发)*/
  onBuyNow: (event: SkuPopupEvent) => void
}

/**  商品信息本地数据源 */
export type SkuPopupLocaldata = {
  /** 商品 ID */
  _id: string
  /** 商品名称 */
  name: string
  /** 商品图片 */
  goods_thumb: string
  /** 商品规格列表 */
  spec_list: SkuPopupSpecItem[]
  /** 商品SKU列表 */
  sku_list: SkuPopupSkuItem[]
}

/** 商品规格名称的集合 */
export type SkuPopupSpecItem = {
  /** 规格名称 */
  name: string
  /** 规格集合 */
  list: { name: string }[]
}

/** 商品SKU列表 */
export type SkuPopupSkuItem = {
  /** SKU ID */
  _id: string
  /**  商品 ID */
  goods_id: string
  /** 商品名称 */
  goods_name: string
  /** 商品图片 */
  image: string
  /** SKU 价格 * 100, 注意:需要乘以 100 */
  price: number
  /** SKU 规格组成, 注意:需要与 spec_list 数组顺序对应 */
  sku_name_arr: string[]
  /** SKU 库存 */
  stock: number
}

/** 当前选择的sku数据 */
export type SkuPopupEvent = SkuPopupSkuItem & {
  /** 商品购买数量 */
  buy_num: number
}

/** 全局组件类型声明 */
declare module 'vue' {
  export interface GlobalComponents {
    'vk-data-goods-sku-popup': SkuPopup
  }
}

商品详情对应的组件

<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'

import { getGoodsDetailApi } from '@/services/goods'
import { computed, ref } from 'vue'
import type { GoodsResult } from '@/types/goods'
import ServicePannel from './components/ServicePannel.vue'
import AddressPanel from './components/AddressPanel.vue'
import GoodsSkeleton from './components/GoodsSkeleton.vue'
import type { SkuPopupInstance, SkuPopupLocaldata } from '@/types/vk-data-goods-sku-popup'
const goodsInfo = ref<GoodsResult>()

// 是否显示SKU组件
const isShowSku = ref(false)
// 商品信息
const localdata = ref({} as SkuPopupLocaldata)

//获取商品详情
const getGoodsInfo = async () => {
  const res = await getGoodsDetailApi(query.id)
  goodsInfo.value = res.result
  // SKU组件所需格式
  localdata.value = {
    _id: res.result.id,
    name: res.result.name,
    goods_thumb: res.result.mainPictures[0],
    spec_list: res.result.specs.map((v) => ({ name: v.name, list: v.values })),
    sku_list: res.result.skus.map((v) => ({
      _id: v.id,
      goods_id: res.result.id,
      goods_name: res.result.name,
      image: v.picture,
      price: v.price * 100, // 注意:需要乘以 100
      stock: v.inventory,
      sku_name_arr: v.specs.map((vv) => vv.valueName),
    })),
  }
}

//启动骨架屏
const isLoading = ref(false)

onLoad(() => {
  isLoading.value = true
  getGoodsInfo()
  isLoading.value = false
})

// uniapp 获取页面参数
const query = defineProps<{
  id: string
}>()

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()

//高亮的指示点
const activeIndex = ref(0)

//当swiper下标发生变化时出发
const onChange: UniHelper.SwiperOnChange = (ev) => {
  // console.log(ev.detail?.current)
  activeIndex.value = ev.detail!.current
}

const onPreviewImage = (url: string) => {
  uni.previewImage({
    urls: goodsInfo.value!.mainPictures,
    current: url,
  })
}

const popup = ref<{
  open: (type?: UniHelper.UniPopupType) => void
  close: () => void
}>()

const isAddress = ref<0 | 1>(0)
const openPopup = (isAddr: 0 | 1) => {
  isAddress.value = isAddr
  popup.value?.open()
}


// 按钮模式
enum SkuMode {
  Both = 1,
  Cart = 2,
  Buy = 3,
}
const mode = ref<SkuMode>(SkuMode.Cart)
// 打开SKU弹窗修改按钮模式
const openSkuPopup = (val: SkuMode) => {
  // 显示SKU弹窗
  isShowSku.value = true
  // 修改按钮模式
  mode.value = val
}

// SKU组件实例
const skuPopupRef = ref<SkuPopupInstance>()
// 计算被选中的值
const selectArrText = computed(() => {
  return skuPopupRef.value?.selectArr?.join(' ').trim() || '请选择商品规格'
})

</script>

<template>
  <!-- SKU弹窗组件 -->
  <vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" :mode="mode" add-cart-background-color="#FFA868"
    buy-now-background-color="#27BA9B" ref="skuPopupRef" :actived-style="{
    color: '#27BA9B',
    borderColor: '#27BA9B',
    backgroundColor: '#E9F8F5',
  }" />
  <!-- 弹窗测试 -->
  <!-- <button @tap="isShowSku = true">打开 SKU 弹窗</button> -->

  <GoodsSkeleton v-if="isLoading" />
  <scroll-view scroll-y class="viewport" v-else>
    <!-- 基本信息 -->
    <view class="goods">
      <!-- 商品主图 -->
      <view class="preview">
        <swiper circular @change="onChange">
          <swiper-item @click="onPreviewImage(item)" v-for="item in goodsInfo?.mainPictures" :key="item">
            <image mode="aspectFill" :src="item" />
          </swiper-item>
        </swiper>
        <view class="indicator">
          <text class="current">{{ activeIndex + 1 }}</text>
          <text class="split">/</text>
          <text class="total">{{ goodsInfo?.mainPictures.length }}</text>
        </view>
      </view>

      <!-- 商品简介 -->
      <view class="meta">
        <view class="price">
          <text class="symbol">¥</text>
          <text class="number">{{ goodsInfo?.price }}</text>
        </view>
        <view class="name ellipsis">{{ goodsInfo?.name }} </view>
        <view class="desc"> {{ goodsInfo?.desc }} </view>
      </view>

      <!-- 操作面板 -->
      <view class="action">
        <view class="item arrow">
          <text class="label">选择</text>
          <text class="text ellipsis" @tap="openSkuPopup(SkuMode.Both)"> {{ selectArrText }} </text>
        </view>
        <view class="item arrow" @tap="openPopup(1)">
          <text class="label">送至</text>
          <text class="text ellipsis"> 请选择收获地址 </text>
        </view>
        <view class="item arrow" @tap="openPopup(0)">
          <text class="label">服务</text>
          <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
        </view>
      </view>
    </view>

    <uni-popup ref="popup" type="bottom" background-color="#fff">
      <ServicePannel v-show="isAddress === 0" @close="popup?.close" />
      <AddressPanel v-show="isAddress === 1" @close="popup?.close" />
    </uni-popup>

    <!-- 商品详情 -->
    <view class="detail panel">
      <view class="title">
        <text>详情</text>
      </view>
      <view class="content">
        <view class="properties">
          <!-- 属性详情 -->
          <view class="item" v-for="item in goodsInfo?.details.properties" :key="item.name">
            <text class="label">{{ item.name }}</text>
            <text class="value">{{ item.value }}</text>
          </view>
        </view>
        <!-- 图片详情 -->
        <image :key="item" v-for="item in goodsInfo?.details.pictures" mode="widthFix" :src="item"></image>
      </view>
    </view>

    <!-- 同类推荐 -->
    <view class="similar panel">
      <view class="title">
        <text>同类推荐</text>
      </view>
      <view class="content">
        <navigator v-for="item in goodsInfo?.similarProducts" :key="item.id" class="goods" hover-class="none"
          :url="`/pages/goods/goods?id=${item.id}`">
          <image class="image" mode="aspectFill" :src="item.picture"></image>
          <view class="name ellipsis">{{ item.name }}</view>
          <view class="price">
            <text class="symbol">¥</text>
            <text class="number">{{ item.price }} </text>
          </view>
        </navigator>
      </view>
    </view>
  </scroll-view>

  <!-- 用户操作 -->
  <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
    <view class="icons">
      <button class="icons-button"><text class="icon-heart"></text>收藏</button>
      <button class="icons-button" open-type="contact">
        <text class="icon-handset"></text>客服
      </button>
      <navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab">
        <text class="icon-cart"></text>购物车
      </navigator>
    </view>
    <view class="buttons">
      <view class="addcart" @tap="openSkuPopup(SkuMode.Cart)"> 加入购物车 </view>
      <view class="buynow" @tap="openSkuPopup(SkuMode.Buy)"> 立即购买 </view>
    </view>
  </view>
</template>

<style style lang="scss">
page {
  height: 100%;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.viewport {
  background-color: #f4f4f4;
}

.panel {
  margin-top: 20rpx;
  background-color: #fff;

  .title {
    display: flex;
    justify-content: space-between;
    align-items: center;
    height: 90rpx;
    line-height: 1;
    padding: 30rpx 60rpx 30rpx 6rpx;
    position: relative;

    text {
      padding-left: 10rpx;
      font-size: 28rpx;
      color: #333;
      font-weight: 600;
      border-left: 4rpx solid #27ba9b;
    }

    navigator {
      font-size: 24rpx;
      color: #666;
    }
  }
}

.arrow {
  &::after {
    position: absolute;
    top: 50%;
    right: 30rpx;
    content: '\e6c2';
    color: #ccc;
    font-family: 'erabbit' !important;
    font-size: 32rpx;
    transform: translateY(-50%);
  }
}

/* 商品信息 */
.goods {
  background-color: #fff;

  .preview {
    height: 750rpx;
    position: relative;

    .image {
      width: 750rpx;
      height: 750rpx;
    }

    .indicator {
      height: 40rpx;
      padding: 0 24rpx;
      line-height: 40rpx;
      border-radius: 30rpx;
      color: #fff;
      font-family: Arial, Helvetica, sans-serif;
      background-color: rgba(0, 0, 0, 0.3);
      position: absolute;
      bottom: 30rpx;
      right: 30rpx;

      .current {
        font-size: 26rpx;
      }

      .split {
        font-size: 24rpx;
        margin: 0 1rpx 0 2rpx;
      }

      .total {
        font-size: 24rpx;
      }
    }
  }

  .meta {
    position: relative;
    border-bottom: 1rpx solid #eaeaea;

    .price {
      height: 130rpx;
      padding: 25rpx 30rpx 0;
      color: #fff;
      font-size: 34rpx;
      box-sizing: border-box;
      background-color: #35c8a9;
    }

    .number {
      font-size: 56rpx;
    }

    .brand {
      width: 160rpx;
      height: 80rpx;
      overflow: hidden;
      position: absolute;
      top: 26rpx;
      right: 30rpx;
    }

    .name {
      max-height: 88rpx;
      line-height: 1.4;
      margin: 20rpx;
      font-size: 32rpx;
      color: #333;
    }

    .desc {
      line-height: 1;
      padding: 0 20rpx 30rpx;
      font-size: 24rpx;
      color: #cf4444;
    }
  }

  .action {
    padding-left: 20rpx;

    .item {
      height: 90rpx;
      padding-right: 60rpx;
      border-bottom: 1rpx solid #eaeaea;
      font-size: 26rpx;
      color: #333;
      position: relative;
      display: flex;
      align-items: center;

      &:last-child {
        border-bottom: 0 none;
      }
    }

    .label {
      width: 60rpx;
      color: #898b94;
      margin: 0 16rpx 0 10rpx;
    }

    .text {
      flex: 1;
      -webkit-line-clamp: 1;
    }
  }
}

/* 商品详情 */
.detail {
  padding-left: 20rpx;

  .content {
    margin-left: -20rpx;

    .image {
      width: 100%;
    }
  }

  .properties {
    padding: 0 20rpx;
    margin-bottom: 30rpx;

    .item {
      display: flex;
      line-height: 2;
      padding: 10rpx;
      font-size: 26rpx;
      color: #333;
      border-bottom: 1rpx dashed #ccc;
    }

    .label {
      width: 200rpx;
    }

    .value {
      flex: 1;
    }
  }
}

/* 同类推荐 */
.similar {
  .content {
    padding: 0 20rpx 200rpx;
    background-color: #f4f4f4;
    display: flex;
    flex-wrap: wrap;

    .goods {
      width: 340rpx;
      padding: 24rpx 20rpx 20rpx;
      margin: 20rpx 7rpx;
      border-radius: 10rpx;
      background-color: #fff;
    }

    .image {
      width: 300rpx;
      height: 260rpx;
    }

    .name {
      height: 80rpx;
      margin: 10rpx 0;
      font-size: 26rpx;
      color: #262626;
    }

    .price {
      line-height: 1;
      font-size: 20rpx;
      color: #cf4444;
    }

    .number {
      font-size: 26rpx;
      margin-left: 2rpx;
    }
  }

  navigator {
    &:nth-child(even) {
      margin-right: 0;
    }
  }
}

/* 底部工具栏 */
.toolbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1;
  background-color: #fff;
  height: 100rpx;
  padding: 0 20rpx var(--window-bottom);
  border-top: 1rpx solid #eaeaea;
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-sizing: content-box;

  .buttons {
    display: flex;

    &>view {
      width: 220rpx;
      text-align: center;
      line-height: 72rpx;
      font-size: 26rpx;
      color: #fff;
      border-radius: 72rpx;
    }

    .addcart {
      background-color: #ffa868;
    }

    .buynow,
    .payment {
      background-color: #27ba9b;
      margin-left: 20rpx;
    }
  }

  .icons {
    padding-right: 10rpx;
    display: flex;
    align-items: center;
    flex: 1;

    .icons-button {
      flex: 1;
      text-align: center;
      line-height: 1.4;
      padding: 0;
      margin: 0;
      border-radius: 0;
      font-size: 20rpx;
      color: #333;
      background-color: #fff;

      &::after {
        border: none;
      }
    }

    text {
      display: block;
      font-size: 34rpx;
    }
  }
}
</style>

购物车模块

类型声明

/** 购物车类型 */
export type CartItem = {
  /** 商品 ID */
  id: string
  /** SKU ID */
  skuId: string
  /** 商品名称 */
  name: string
  /** 图片 */
  picture: string
  /** 数量 */
  count: number
  /** 加入时价格 */
  price: number
  /** 当前的价格 */
  nowPrice: number
  /** 库存 */
  stock: number
  /** 是否选中 */
  selected: boolean
  /** 属性文字 */
  attrsText: string
  /** 是否为有效商品 */
  isEffective: boolean
}

import { Component } from '@uni-helper/uni-app-types'

/** 步进器 */
export type InputNumberBox = Component<InputNumberBoxProps>

/** 步进器实例 */
export type InputNumberBoxInstance = InstanceType<InputNumberBox>

/** 步进器属性 */
export type InputNumberBoxProps = {
  /** 输入框初始值(默认1) */
  modelValue: number
  /** 用户可输入的最小值(默认0) */
  min: number
  /** 用户可输入的最大值(默认99999) */
  max: number
  /**  步长,每次加或减的值(默认1) */
  step: number
  /** 是否禁用操作,包括输入框,加减按钮 */
  disabled: boolean
  /** 输入框宽度,单位rpx(默认80) */
  inputWidth: string | number
  /**  输入框和按钮的高度,单位rpx(默认50) */
  inputHeight: string | number
  /** 输入框和按钮的背景颜色(默认#F2F3F5) */
  bgColor: string
  /** 步进器标识符 */
  index: string
  /** 输入框内容发生变化时触发 */
  onChange: (event: InputNumberBoxEvent) => void
  /** 输入框失去焦点时触发 */
  onBlur: (event: InputNumberBoxEvent) => void
  /** 点击增加按钮时触发 */
  onPlus: (event: InputNumberBoxEvent) => void
  /** 点击减少按钮时触发 */
  onMinus: (event: InputNumberBoxEvent) => void
}

/** 步进器事件对象 */
export type InputNumberBoxEvent = {
  /** 输入框当前值 */
  value: number
  /** 步进器标识符 */
  index: string
}

/** 全局组件类型声明 */
declare module 'vue' {
  export interface GlobalComponents {
    'vk-data-input-number-box': InputNumberBox
  }
}

接口定义

import type { CartItem } from '@/types/car'
import { http } from '@/utils/http'
/**
 * 加入购物车
 * @param data 请求体参数
 */
export const postMemberCartAPI = (data: { skuId: string; count: number }) => {
  return http({
    method: 'POST',
    url: '/member/cart',
    data,
  })
}

/**
 * 获取购物车列表
 */
export const getMemberCartAPI = () => {
  return http<CartItem[]>({
    method: 'GET',
    url: '/member/cart',
  })
}

/**
 * 删除/清空购物车单品
 * @param data 请求体参数 ids SKUID 集合
 */
export const deleteMemberCartAPI = (data: { ids: string[] }) => {
  return http({
    method: 'DELETE',
    url: '/member/cart',
    data,
  })
}

/**
 * 修改购物车单品
 * @param skuId SKUID
 * @param data selected 选中状态 count 商品数量
 */
export const putMemberCartBySkuIdAPI = (
  skuId: string,
  data: { selected?: boolean; count?: number },
) => {
  return http({
    method: 'PUT',
    url: `/member/cart/${skuId}`,
    data,
  })
}

/**
 * 购物车全选/取消全选
 * @param data selected 是否选中
 */
export const putMemberCartSelectedAPI = (data: { selected: boolean }) => {
  return http({
    method: 'PUT',
    url: '/member/cart/selected',
    data,
  })
}

组件代码

<script setup lang="ts">
import {
  deleteMemberCartAPI,
  getMemberCartAPI,
  putMemberCartBySkuIdAPI,
  putMemberCartSelectedAPI,
} from '@/services/car'
import { useMemberStore } from '@/stores'
import type { CartItem, InputNumberBoxEvent } from '@/types/car'
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'

// 获取会员Store
const memberStore = useMemberStore()

// 获取购物车数据
const cartList = ref<CartItem[]>([])
const getMemberCartData = async () => {
  const res = await getMemberCartAPI()
  console.log(res.result)
  cartList.value = res.result
}

// 初始化调用: 页面显示触发
onShow(() => {
  // 用户已登录才允许调用
  if (memberStore.profile) {
    getMemberCartData()
  }
})

// 点击删除按钮
const onDeleteCart = (skuId: string) => {
  // 弹窗二次确认
  uni.showModal({
    content: '是否删除',
    success: async (res) => {
      if (res.confirm) {
        // 后端删除单品
        await deleteMemberCartAPI({ ids: [skuId] })
        // 重新获取列表
        getMemberCartData()
      }
    },
  })
}

// 修改商品数量
const onChangeCount = (ev: InputNumberBoxEvent) => {
  putMemberCartBySkuIdAPI(ev.index, { count: ev.value })
}

// 修改选中状态-单品修改
const onChangeSelected = (item: CartItem) => {
  // 前端数据更新-是否选中取反
  item.selected = !item.selected
  // 后端数据更新
  putMemberCartBySkuIdAPI(item.skuId, { selected: item.selected })
}

// 计算全选状态
const isSelectedAll = computed(() => {
  return cartList.value.length && cartList.value.every((v) => v.selected)
})

// 修改选中状态-全选修改
const onChangeSelectedAll = () => {
  // 全选状态取反
  const _isSelectedAll = !isSelectedAll.value
  // 前端数据更新
  cartList.value.forEach((item) => {
    item.selected = _isSelectedAll
  })
  // 后端数据更新
  putMemberCartSelectedAPI({ selected: _isSelectedAll })
}

// 计算选中单品列表
const selectedCartList = computed(() => {
  return cartList.value.filter((v) => v.selected)
})

// 计算选中总件数
const selectedCartListCount = computed(() => {
  return selectedCartList.value.reduce((sum, item) => sum + item.count, 0)
})

// 计算选中总金额
const selectedCartListMoney = computed(() => {
  return selectedCartList.value
    .reduce((sum, item) => sum + item.count * item.nowPrice, 0)
    .toFixed(2)
})

// 结算按钮
const gotoPayment = () => {
  if (selectedCartListCount.value === 0) {
    return uni.showToast({
      icon: 'none',
      title: '请选择商品',
    })
  }
  // 跳转到结算页
  uni.navigateTo({ url: '/pagesOrder/create/create' })
}
</script>

<template>
  <scroll-view scroll-y class="scroll-view">
    <!-- 已登录: 显示购物车 -->
    <template v-if="memberStore.profile">
      <!-- 购物车列表 -->
      <view class="cart-list" v-if="true">
        <!-- 优惠提示 -->
        <view class="tips">
          <text class="label">满减</text>
          <text class="desc">满1件, 即可享受9折优惠</text>
        </view>
        <!-- 滑动操作分区 -->
        <uni-swipe-action>
          <!-- 滑动操作项 -->
          <uni-swipe-action-item v-for="item in cartList" :key="item.id" class="cart-swipe">
            <!-- 商品信息 -->
            <view class="goods">
              <!-- 选中状态 -->
              <text @tap="onChangeSelected(item)" class="checkbox" :class="{ checked: item.selected }"></text>
              <navigator :url="`/pages/goods/goods?id=${item.id}`" hover-class="none" class="navigator">
                <image mode="aspectFill" class="picture" :src="item.picture"></image>
                <view class="meta">
                  <view class="name ellipsis">{{ item.name }}</view>
                  <view class="attrsText ellipsis">{{ item.attrsText }}</view>
                  <view class="price">{{ item.price }}</view>
                </view>
              </navigator>
              <!-- 商品数量 -->
              <view class="count">
                <vk-data-input-number-box v-model="item.count" :min="1" :max="item.stock" :index="item.skuId"
                  @change="onChangeCount" />
              </view>
            </view>
            <!-- 右侧删除按钮 -->
            <template #right>
              <view class="cart-swipe-right">
                <button class="button delete-button" @tap="onDeleteCart(item.skuId)">删除</button>
              </view>
            </template>
          </uni-swipe-action-item>
        </uni-swipe-action>
      </view>
      <!-- 购物车空状态 -->
      <view class="cart-blank" v-else>
        <image src="/static/images/blank_cart.png" class="image" />
        <text class="text">购物车还是空的,快来挑选好货吧</text>
        <navigator open-type="switchTab" url="/pages/index/index" hover-class="none">
          <button class="button">去首页看看</button>
        </navigator>
      </view>
      <!-- 吸底工具栏 -->
      <view class="toolbar">
        <text @tap="onChangeSelectedAll" class="all" :class="{ checked: isSelectedAll }">全选</text>
        <text class="text">合计:</text>
        <text class="amount">{{ selectedCartListMoney }}</text>
        <view class="button-grounp">
          <view class="button payment-button" :class="{ disabled: selectedCartListCount === 0 }" @tap="gotoPayment">
            去结算({{ selectedCartListCount }})
          </view>
        </view>
      </view>
    </template>
    <!-- 未登录: 提示登录 -->
    <view class="login-blank" v-else>
      <text class="text">登录后可查看购物车中的商品</text>
      <navigator url="/pages/login/login" hover-class="none">
        <button class="button">去登录</button>
      </navigator>
    </view>
    <!-- 猜你喜欢 -->
    <XtxGuess ref="guessRef"></XtxGuess>
    <!-- 底部占位空盒子 -->
    <view class="toolbar-height"></view>
  </scroll-view>
</template>

<style lang="scss">
// 根元素
:host {
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background-color: #f7f7f8;
}

// 滚动容器
.scroll-view {
  flex: 1;
}

// 购物车列表
.cart-list {
  padding: 0 20rpx;

  // 优惠提示
  .tips {
    display: flex;
    align-items: center;
    line-height: 1;
    margin: 30rpx 10rpx;
    font-size: 26rpx;
    color: #666;

    .label {
      color: #fff;
      padding: 7rpx 15rpx 5rpx;
      border-radius: 4rpx;
      font-size: 24rpx;
      background-color: #27ba9b;
      margin-right: 10rpx;
    }
  }

  // 购物车商品
  .goods {
    display: flex;
    padding: 20rpx 20rpx 20rpx 80rpx;
    border-radius: 10rpx;
    background-color: #fff;
    position: relative;

    .navigator {
      display: flex;
    }

    .checkbox {
      position: absolute;
      top: 0;
      left: 0;

      display: flex;
      align-items: center;
      justify-content: center;
      width: 80rpx;
      height: 100%;

      &::before {
        content: '\e6cd';
        font-family: 'erabbit' !important;
        font-size: 40rpx;
        color: #444;
      }

      &.checked::before {
        content: '\e6cc';
        color: #27ba9b;
      }
    }

    .picture {
      width: 170rpx;
      height: 170rpx;
    }

    .meta {
      flex: 1;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      margin-left: 20rpx;
    }

    .name {
      height: 72rpx;
      font-size: 26rpx;
      color: #444;
    }

    .attrsText {
      line-height: 1.8;
      padding: 0 15rpx;
      font-size: 24rpx;
      align-self: flex-start;
      border-radius: 4rpx;
      color: #888;
      background-color: #f7f7f8;
    }

    .price {
      line-height: 1;
      font-size: 26rpx;
      color: #444;
      margin-bottom: 2rpx;
      color: #cf4444;

      &::before {
        content: '¥';
        font-size: 80%;
      }
    }

    // 商品数量
    .count {
      position: absolute;
      bottom: 20rpx;
      right: 5rpx;

      display: flex;
      justify-content: space-between;
      align-items: center;
      width: 220rpx;
      height: 48rpx;

      .text {
        height: 100%;
        padding: 0 20rpx;
        font-size: 32rpx;
        color: #444;
      }

      .input {
        height: 100%;
        text-align: center;
        border-radius: 4rpx;
        font-size: 24rpx;
        color: #444;
        background-color: #f6f6f6;
      }
    }
  }

  .cart-swipe {
    display: block;
    margin: 20rpx 0;
  }

  .cart-swipe-right {
    display: flex;
    height: 100%;

    .button {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 50px;
      padding: 6px;
      line-height: 1.5;
      color: #fff;
      font-size: 26rpx;
      border-radius: 0;
    }

    .delete-button {
      background-color: #cf4444;
    }
  }
}

// 空状态
.cart-blank,
.login-blank {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  height: 60vh;

  .image {
    width: 400rpx;
    height: 281rpx;
  }

  .text {
    color: #444;
    font-size: 26rpx;
    margin: 20rpx 0;
  }

  .button {
    width: 240rpx !important;
    height: 60rpx;
    line-height: 60rpx;
    margin-top: 20rpx;
    font-size: 26rpx;
    border-radius: 60rpx;
    color: #fff;
    background-color: #27ba9b;
  }
}

// 吸底工具栏
.toolbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: var(--window-bottom);
  z-index: 1;

  height: 100rpx;
  padding: 0 20rpx;
  display: flex;
  align-items: center;
  border-top: 1rpx solid #ededed;
  border-bottom: 1rpx solid #ededed;
  background-color: #fff;
  box-sizing: content-box;

  .all {
    margin-left: 25rpx;
    font-size: 14px;
    color: #444;
    display: flex;
    align-items: center;
  }

  .all::before {
    font-family: 'erabbit' !important;
    content: '\e6cd';
    font-size: 40rpx;
    margin-right: 8rpx;
  }

  .checked::before {
    content: '\e6cc';
    color: #27ba9b;
  }

  .text {
    margin-right: 8rpx;
    margin-left: 32rpx;
    color: #444;
    font-size: 14px;
  }

  .amount {
    font-size: 20px;
    color: #cf4444;

    .decimal {
      font-size: 12px;
    }

    &::before {
      content: '¥';
      font-size: 12px;
    }
  }

  .button-grounp {
    margin-left: auto;
    display: flex;
    justify-content: space-between;
    text-align: center;
    line-height: 72rpx;
    font-size: 13px;
    color: #fff;

    .button {
      width: 240rpx;
      margin: 0 10rpx;
      border-radius: 72rpx;
    }

    .payment-button {
      background-color: #27ba9b;

      &.disabled {
        opacity: 0.6;
      }
    }
  }
}

// 底部占位空盒子
.toolbar-height {
  height: 100rpx;
}
</style>
<script setup lang="ts">
import CartMain from './components/CarMain.vue'
</script>
<template>
  <CartMain />
</template>
<style lang="scss">
page {
  height: 100%;
}
</style>
<script setup lang="ts">
import CartMain from './components/CartMain.vue'
</script>
<template>
  <CartMain />
</template>
<style lang="scss">
page {
  height: 100%;
}
</style>
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

isuweijie

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

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

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

打赏作者

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

抵扣说明:

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

余额充值