[uni-app]小兔鲜-06地址+sku+购物车

收址模块

准备 地址管理分包页面 和 添加地址分包页面

新增和修改地址

import type { AddressParams, AddressItem } 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}`,
    })
}

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

/** 收货地址项 */
export type AddressItem = AddressParams & {
    /** 收货地址 id */
    id: string
    /** 省市区 */
    fullLocation: string
}

<script setup lang="ts">
import {
  postMemberAddressAPI,
  getMemberAddressByIdAPI,
  putMemberAddressByIdAPI,
} from '@/services/address'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'

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

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

// 表单数据
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 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>()

// 提交表单
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 getMemberAddressByIdData = async () => {
  // 有 id 才调用接口
  if (query.id) {
    // 发送请求
    const res = await getMemberAddressByIdAPI(query.id)
    // 把数据合并到表单中
    Object.assign(form.value, res.result)
  }
}

// 页面加载
onLoad(() => {
  getMemberAddressByIdData()
})
</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="请填写收货人手机号码" v-model="form.contact" />
      </uni-forms-item>
      <uni-forms-item name="fullLocation" class="form-item">
        <text class="label">所在地区</text>
        <picker
          @change="onRegionChange"
          class="picker"
          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"
          :checked="form.isDefault === 1"
          @change="onSwitchChange"
        />
      </view>
    </uni-forms>
  </view>
  <!-- 提交按钮 -->
  <button @tap="onSubmit" class="button">保存并使用</button>
</template>

表单校验:

  1. 结构: 使用uni-forms 和 uni-forms-item组件实现表单数据的校验
  2. 规则: 通过rules属性指定校验规则, 通过model属性指定表单数据, 通过name属性指定校验项
  3. 校验: 通过formRef拿到组件实例, 调用组件实例的方法, 触发校验功能

地址管理页列表渲染

<script setup lang="ts">
import { getMemberAddressAPI } 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()
})
</script>

<template>
  <view class="viewport">
    <!-- 地址列表 -->
    <scroll-view class="scroll-view" scroll-y>
      <view v-if="true" class="address">
        <view class="address-list">
          <!-- 滑动操作分区 -->
          <uni-swipe-action>
            <!-- 滑动操作项 -->
            <uni-swipe-action-item v-for="item in addressList" :key="item.id">
              <!-- 默认插槽 -->
              <!-- 收获地址项 -->
              <view class="item">
                <view class="item-content" @tap="onChangeAddress(item)">
                  <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}`"
                    @tap.stop="() => {}"
                    @tap.prevent="() => {}"
                  >
                    修改
                  </navigator>
                </view>
              </view>
              <!-- 右侧插槽 -->
              <template #right>
                <button class="delete-button">删除</button>
              </template>
            </uni-swipe-action-item>
          </uni-swipe-action>
        </view>
      </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>
  1. onLoad() 页面加载,只执行一次
  2. onShow() 页面展示,反复执行

删除地址

<script setup lang="ts">
import { deleteMemberAddressByIdAPI } from '@/services/address'
import type { AddressItem } from '@/types/address'
import { useAddressStore } from '@/stores/modules/address'
import { onShow } from '@dcloudio/uni-app'

// 删除收货地址
const onDeleteAddress = (id: string) => {
  // 二次确认
  uni.showModal({
    content: '删除地址?',
    success: async (res) => {
      if (res.confirm) {
        // 根据id删除收货地址
        await deleteMemberAddressByIdAPI(id)
        // 重新获取收货地址列表
        getMemberAddressData()
      }
    },
  })
}

// 修改收货地址
const onChangeAddress = (item: AddressItem) => {
  // 修改地址
  const addressStore = useAddressStore()
  addressStore.changeSelectedAddress(item)
  // 返回上一页
  uni.navigateBack()
}
</script>

<template>
  <view class="viewport">
    <!-- 地址列表 -->
    <scroll-view class="scroll-view" scroll-y>
      <view v-if="true" class="address">
        <view class="address-list">
          <!-- 滑动操作分区 -->
          <uni-swipe-action>
            <!-- 滑动操作项 -->
            <uni-swipe-action-item v-for="item in addressList" :key="item.id">
              <!-- 默认插槽 -->
              <!-- 收获地址项 -->
              <view class="item">
                <view class="item-content" @tap="onChangeAddress(item)">
                  <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}`"
                    @tap.stop="() => {}"
                    @tap.prevent="() => {}"
                  >
                    修改
                  </navigator>
                </view>
              </view>
              <!-- 右侧插槽 -->
              <template #right>
                <button class="delete-button" @tap="onDeleteAddress(item.id)">删除</button>
              </template>
            </uni-swipe-action-item>
          </uni-swipe-action>
        </view>
      </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>

sku模块

概念: 存货单位( Stock Keeping Unit ), 库存管理的最小单元, 通常称为'单品'

SKU常见于电商领域, 对于前端工程师而言, 更多关注SKU算法,基于后端的SKU数据渲染页面并实现交互

uni-app插件市场可以是官方插件集中地, 其中可以找到SKU组件使用

  1. 找到合适的组件后, 直接下载即可
  2. 下载完成后, 跟随文档的指引, 把组件安装到自己的项目中即可
  3. 当前项目使用eslint进行代码校验, 但是上述插件没有进行校验,导致代码无法通过提交检查,
  4. 改动作者代码成本巨高, 所以要禁用当前文件的eslint的校验
  5. 在script标签的第一行, 添加禁用当前文件的语法校验

使用SKU组件, 渲染商品信息

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/runtime-core' {
  export interface GlobalComponents {
    'vk-data-goods-sku-popup': SkuPopup
  }
}

<script setup lang="ts">
// 获取商品详情
const goods = ref<GoodsResult>()
const getGoodsData = async () => {
  const res = await getGoodsByIdApi(query.id)
  goods.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),
    })),
  }
}


// 控制sku组件
const isShowSku = ref(false)
// 商品信息
const localdata = ref({} as SkuPopupLocaldata)
</script>

<template>
  <!-- sku弹窗组件 -->
  <vk-data-goods-sku-popup
    v-model="isShowSku"
    :localdata="localdata"
    add-cart-background-color="#FFA868"
    buy-now-background-color="#27BA9B"
    :actived-style="{
      color: '#27BA9B',
      borderColor: '#27BA9B',
      backgroundColor: '#E9F8F5',
    }"
  />
      
  <scroll-view scroll-y class="viewport">
    <!-- 基本信息 -->
    <view class="goods">
      ... ...

      <!-- 操作面板 -->
      <view class="action">
        <view class="item arrow" @tap="isShowSku = true">
          <text class="label">选择</text>
          <text class="text ellipsis"> {{ selectArrText }} </text>
        </view>
       ... ...
      </view>
      
    </view>
  </scroll-view>
      
</template>

设置按钮模式: 加入购物车/立即购买/选择都可以打开sku弹窗, sku弹窗展示的按钮不同

<script setup lang="ts">
import type {
  SkuPopupLocaldata
} from '@/components/vk-data-goods-sku-popup/vk-data-goods-sku-popup'


// 控制sku组件
const isShowSku = ref(false)
// 商品信息
const localdata = ref({} as SkuPopupLocaldata)

// 按钮模式
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
}

</script>

<template>
  <!-- sku弹窗组件 -->
  <vk-data-goods-sku-popup
    v-model="isShowSku"
    :localdata="localdata"
    :mode="mode"
    ref="skuPopupRef"
    @add-cart="onAddCart"
    @buy-now="onBuyNow"
    add-cart-background-color="#FFA868"
    buy-now-background-color="#27BA9B"
    :actived-style="{
      color: '#27BA9B',
      borderColor: '#27BA9B',
      backgroundColor: '#E9F8F5',
    }"
  />
      
  <scroll-view scroll-y class="viewport">
    <!-- 基本信息 -->
    <view class="goods">
     ... ...
      <!-- 操作面板 -->
      <view class="action">
        <view class="item arrow" @tap="openSkuPopup(1)">
          <text class="label">选择</text>
          <text class="text ellipsis"> {{ selectArrText }} </text>
        </view>
         ... ...
      </view>
    </view>

  </scroll-view>

  <!-- 用户操作 -->
  <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
    ... ...
    <view class="buttons">
      <view class="addcart" @tap="openSkuPopup(2)"> 加入购物车 </view>
      <view class="buynow" @tap="openSkuPopup(3)"> 立即购买 </view>
    </view>
  </view>
</template>
  1. sku组件可以用过mode选项控制组件内按钮按需展示隐藏, 1展示全部按钮, 2展示加入购物车按钮,3展示立即购买按钮
  2. 直接指定1,2,3语义化不好, 我们使用枚举进行指定

在商品界面展示SKU组件中选中的值

<script setup lang="ts">
// 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"
    ref="skuPopupRef"
    add-cart-background-color="#FFA868"
    buy-now-background-color="#27BA9B"
    :actived-style="{
      color: '#27BA9B',
      borderColor: '#27BA9B',
      backgroundColor: '#E9F8F5',
    }"
  />
  <scroll-view scroll-y class="viewport">
    <!-- 基本信息 -->
    <view class="goods">
      ... ...
      <!-- 操作面板 -->
      <view class="action">
        <view class="item arrow" @tap="openSkuPopup(1)">
          <text class="label">选择</text>
          <text class="text ellipsis"> {{ selectArrText }} </text>
        </view>
        <view class="item arrow" @tap="openPopop('address')">
          <text class="label">送至</text>
          <text class="text ellipsis"> 请选择收获地址 </text>
        </view>
        <view class="item arrow" @tap="openPopop('service')">
          <text class="label">服务</text>
          <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
        </view>
      </view>
    </view>
    ... ...
  </scroll-view>
</template>

加入购物车和立即购买

<script setup lang="ts">
import type {
  SkuPopupEvent,
} from '@/components/vk-data-goods-sku-popup/vk-data-goods-sku-popup'
import { postMemberCartAPI } from '@/services/cart'

// 加入购物车事件
const onAddCart = async (ev: SkuPopupEvent) => {
  await postMemberCartAPI({ skuId: ev._id, count: ev.buy_num })
  uni.showToast({ title: '添加成功' })
  isShowSku.value = false
}

// 立即购买
const onBuyNow = (ev: SkuPopupEvent) => {
  uni.navigateTo({ url: `/pagesOrder/create/create?skuId=${ev._id}&count=${ev.buy_num}` })
  isShowSku.value = false
}
</script>

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

  <!-- 用户操作 -->
  <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
    ... ...
    <view class="buttons">
      <view class="addcart" @tap="openSkuPopup(2)"> 加入购物车 </view>
      <view class="buynow" @tap="openSkuPopup(3)"> 立即购买 </view>
    </view>
  </view>
</template>

购物车模块

渲染列表

/** 购物车类型 */
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 type { CartItem } from '@/types/cart'
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 {
  getMemberCartAPI,
} from '@/services/cart'
import { useMemberStore } from '@/stores'
import type { CartItem } from '@/types/cart'
import { onShow } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'

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

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

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

<template>
  <scroll-view scroll-y class="scroll-view">
    <!-- 已登录: 显示购物车 -->
    <template v-if="memberStore.profile">
      <!-- 购物车列表 -->
      <view class="cart-list" v-if="cartList.length">
        <!-- 优惠提示 -->
        <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.skuId" 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.nowPrice }}</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 @tap="onDeleteCart(item.skuId)" class="button delete-button">删除</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 class="all" @tap="onChangeSelectedAll" :class="{ checked: isSelectedAll }">全选</text>
        <text class="text">合计:</text>
        <text class="amount">{{ selectedCartListMoney }}</text>
        <view class="button-grounp">
          <view @tap="gotoPayment" class="button payment-button" :class="{ disabled: true }">
            去结算({{ 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>

删除商品

<script setup lang="ts">
import {
  deleteMemberCartAPI,
} from '@/services/cart'

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

<template>
  <scroll-view scroll-y class="scroll-view">
    <!-- 已登录: 显示购物车 -->
    <template v-if="memberStore.profile">
      <!-- 购物车列表 -->
      <view class="cart-list" v-if="cartList.length">
       
        <!-- 滑动操作分区 -->
        <uni-swipe-action>
          <!-- 滑动操作项 -->
          <uni-swipe-action-item v-for="item in cartList" :key="item.skuId" class="cart-swipe">
            <!-- 商品信息 -->
            <view class="goods">
              ... ...
            </view>
            <!-- 右侧删除按钮 -->
            <template #right>
              <view class="cart-swipe-right">
                <button @tap="onDeleteCart(item.skuId)" class="button delete-button">删除</button>
              </view>
            </template>
          </uni-swipe-action-item>
        </uni-swipe-action>
  
      </view>
    </template>
  </scroll-view>
</template>

修改商品数量

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/runtime-core' {
  export interface GlobalComponents {
    'vk-data-input-number-box': InputNumberBox
  }
}

<script setup lang="ts">
import type { InputNumberBoxEvent } from '@/components/vk-data-input-number-box/vk-data-input-number-box'
import {
  putMemberCartBySkuIdAPI,
} from '@/services/cart'

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

<template>
  <scroll-view scroll-y class="scroll-view">
    <template v-if="memberStore.profile">
      <!-- 购物车列表 -->
      <view class="cart-list" v-if="cartList.length">
        ... ...
        <!-- 滑动操作分区 -->
        <uni-swipe-action>
          <!-- 滑动操作项 -->
          <uni-swipe-action-item v-for="item in cartList" :key="item.skuId" class="cart-swipe">
            <!-- 商品信息 -->
            <view class="goods">
              ... ...
              <!-- 商品数量 -->
              <view class="count">
                <vk-data-input-number-box
                  v-model="item.count"
                  :min="1"
                  :max="item.stock"
                  :index="item.skuId"
                  @change="onChangeCount"
                />
              </view>
            </view>
            ... ...
          </uni-swipe-action-item>
        </uni-swipe-action>
      </view>
    </template>
  </scroll-view>
</template>

商品状态修改

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

// 修改选中状态-单品修改
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 })
}
</script>

<template>
  <scroll-view scroll-y class="scroll-view">
    <template v-if="memberStore.profile">
      <!-- 购物车列表 -->
      <view class="cart-list" v-if="cartList.length">
        <!-- 滑动操作分区 -->
        <uni-swipe-action>
          <!-- 滑动操作项 -->
          <uni-swipe-action-item v-for="item in cartList" :key="item.skuId" class="cart-swipe">
            <!-- 商品信息 -->
            <view class="goods">
              <!-- 选中状态 -->
              <text
                @tap="onChangeSelected(item)"
                class="checkbox"
                :class="{ checked: item.selected }"
              ></text>
            </view>
            ... ...
          </uni-swipe-action-item>
        </uni-swipe-action>
      </view>
  
      <!-- 吸底工具栏 -->
      <view class="toolbar">
        <text class="all" @tap="onChangeSelectedAll" :class="{ checked: isSelectedAll }">全选</text>
        ... ...
      </view>
    </template>
  </scroll-view>
</template>

底部结算信息

<script setup lang="ts">
import { ref, computed } from 'vue'

// 计算选中单品列表
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="toolbar">
        <text class="text">合计:</text>
        <text class="amount">{{ selectedCartListMoney }}</text>
        <view class="button-grounp">
          <view @tap="gotoPayment" class="button payment-button" :class="{ disabled: true }">
            去结算({{ selectedCartListCount }})
          </view>
        </view>
      </view>
    </template>
  </scroll-view>
</template>

购物车页面复用: 小程序跳转到tabbar页面时, 会关闭其他所有非tabbar页, 所以小程序tabbar页没有后退按钮

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

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

<template>
  <scroll-view scroll-y class="scroll-view">
     ... ...
  </scroll-view>
</template>

<script setup lang="ts">
import CartMain from './components/CartMain.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>

{
	"pages": [
		... ...
    // 购物车tabber页
		{
			"path": "pages/cart/cart",
			"style": {
				"navigationBarTitleText": "购物车"
			}
		},
    // 购物车普通页
		{
			"path": "pages/cart/cart2",
			"style": {
				"navigationBarTitleText": "购物车"
			}
		},
	  ... ...
	],
}

<template>
  <!-- 用户操作 -->
  <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
    <view class="icons">
      ... ...
      <navigator class="icons-button" url="/pages/cart/cart2" open-type="navigate">
        <text class="icon-cart"></text>购物车
      </navigator>
  
    </view>
    ... ...
  </view>
</template>
相关推荐
Swift社区2 分钟前
HarmonyOS 实践 - 设计模式在代码中的作用
javascript
哑巴语天雨23 分钟前
React+Vite项目框架
前端·react.js·前端框架
初遇你时动了情36 分钟前
react 项目打包二级目 使用BrowserRouter 解决页面刷新404 找不到路由
前端·javascript·react.js
乔峰不是张无忌3301 小时前
【HTML】动态闪烁圣诞树+雪花+音效
前端·javascript·html·圣诞树
鸿蒙自习室1 小时前
鸿蒙UI开发——组件滤镜效果
开发语言·前端·javascript
ddd君317741 小时前
组件的声明、创建、渲染
vue.js
m0_748250741 小时前
高性能Web网关:OpenResty 基础讲解
前端·openresty
前端没钱2 小时前
从 Vue 迈向 React:平滑过渡与关键注意点全解析
前端·vue.js·react.js
汪洪墩2 小时前
【Mars3d】设置backgroundImage、map.scene.skyBox、backgroundImage来回切换
开发语言·javascript·python·ecmascript·webgl·cesium
NoneCoder2 小时前
CSS系列(29)-- Scroll Snap详解
前端·css