uni-app 小兔鲜小程序(三)

477 阅读21分钟

黑马程序员前端项目uniapp小兔鲜儿微信小程序项目视频教程,基于Vue3+Ts+Pinia+uni-app的最新组合技术栈开发的电商业务全流程

十、SKU 模块

学会使用插件市场,下载并使用 SKU 组件,实现商品详情页规格展示和交互。

存货单位(SKU)

SKU 概念

存货单位(Stock Keeping Unit),库存管理的最小可用单元,通常称为“单品”。

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

image-20240423154347266.png

插件市场

uni-app 插件市场,是 uni-app 官方插件生态集中地。

SKU 属于电商常见业务,插件市场有现成的 SKU 插件,我们下载并在项目中使用。

sku_picture_1.png

下载 SKU 插件

经过综合评估,我们选择该SKU 插件,请下载插件到本地。

体验地址

sku_picture_2.png

::: tip 常见问题

Q:如何评估第三方插件的质量?

A:查看插件的评分、评价、下载量、更新频率以及文档完整性,以确保插件具有良好的社区口碑、兼容性、性能和维护状况。

:::

使用 SKU 插件

组件安装到自己项目

  1. 复制 vk-data-goods-sku-popupvk-data-input-number-box 到项目的根 components 目录下。

    \uni_modules\vk-data-goods-sku-popup\components\vk-data-goods-sku-popup\vk-data-goods-sku-popup.vue

  2. 复制例子代码并运行体验。

插件文档(部分)

Props 参数

Props说明类型默认值可选值
v-model双向绑定,true 为打开组件,false 为关闭组件Booleanfalsetrue、false
mode模式 1:都显示 2:只显示购物车 3:只显示立即购买Number11、2、3
localdata商品信息本地数据源Object--

Event 事件名

Event说明回调参数
add-cart点击添加到购物车时(需选择完 SKU 才会触发)selectShop:当前选择的 sku 数据
buy-now点击立即购买时(需选择完 SKU 才会触发)selectShop:当前选择的 sku 数据
open打开组件时-
close关闭组件时-

常见问题

Q:为什么插件使用时无需导入?

A:pages.jsoneasycom 配置中,默认自动扫描 xxx/xxx.vue 格式的组件,实现自动导入

Q:为什么组件代码 Git 提交时报错?

A:插件未采用 eslint 校验代码,请在插件源文件中添加 /* eslint-disable */,禁用 eslint

<!-- 在 `vk-data-goods-sku-popup.vue``vk-data-input-number-box.vue` 组件禁用 `eslint`。 -->
<script>
/* eslint-disable */
// 省略组件源代码
</script>

温馨提示: 插件的作者已合并 eslint-disable PR ,现在已无需手动添加该注释。

核心业务

渲染商品规格

image-20240423164909188.png

类型声明

尽管该插件未采用 TS 开发,但作者提供了详细的插件文档,我们可以依据文档为插件添加 TS 类型声明文件,从而提高项目数据校验的安全性。新建 vk-data-goods-sku-popup.d.ts,为方便跟插件关联,一并将这声明文件放到components而不是types文件夹里

// src/components/vk-data-goods-sku-popup.d.ts
import { Component } from '@uni-helper/uni-app-types'

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

/** SKU 弹出层实例 */
export type SkuPopupInstanceType = 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
  }
}

显示Sku弹窗,渲染商品信息

使用以下两个属性:

  • localdata 绑定商品 SKU 数据来源
  • v-model 双向绑定,显示/隐藏组件

注意:后端返回的数据格式和插件所需的格式不一致,我们需要按插件要求进行处理。

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

    // 获取商品详情信息
    const goods = ref<GoodsResult>()
    const getGoodsByIdData = 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" />
      <!-- 弹窗测试 -->
      <button @tap="isShowSku = true">打开 SKU 弹窗</button>
    </template>

打开弹窗交互

image-20240423174652497.png

SKU 弹窗的按钮有三种形式。

<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
// 按钮模式
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"
    add-cart-background-color="#FFA868"
    buy-now-background-color="#27BA9B"
  /><!-- 显示两个按钮, 注意这个按钮绑定后面改成绑定到其父元素上去 -->
  <view class="item arrow" @tap="openSkuPopup(SkuMode.Both)">请选择商品规格</view>
  <!-- 显示一个按钮 -->
  <view class="addcart" @tap="openSkuPopup(SkuMode.Cart)"> 加入购物车 </view>
  <view class="payment" @tap="openSkuPopup(SkuMode.Buy)"> 立即购买 </view>
</template>

渲染被选中的值

image-20240423174828427.png

  1. 通过 ref 获取组件实例。
  2. 通过 computed 计算出被选中的值,渲染到界面中。
<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
// SKU组件实例
const skuPopupRef = ref<SkuPopupInstanceType>()
// 计算被选中的值
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',
    }"
  />
  <!-- 操作面板 -->
  <view class="action">
    <view @tap="openSkuPopup(SkuMode.Both)" class="item arrow">
      <text class="label">选择</text>
      <text class="text ellipsis"> {{ selectArrText }} </text>
    </view>
  </view>
</template>

至此,已经完成 SKU 组件的交互,接下来进入到购物车模块,并实现加入购物车功能。

注意:这里弹窗打不开,是因为 vk-data-goods-sku-popup.d.ts 的全局组件类型声明没生效,需要手动导入该vue组件使用

<!-- src/pages/goods/goods.vue -->
<srcipt setup lang="ts">
    import vkDataGoodsSkuPopup from '@/components/vk-data-goods-sku-popup.vue'
</srcipt>

十一、购物车模块

完成加入购物车,购物车列表交互,计算结算金额等业务。

加入购物车

在商品详情页把 选中规格后的商品(SKU) 加入购物车。

image-20240424114458948.png

接口相关

接口详情

接口地址:/member/cart

请求方式:POST

登录权限:

请求参数:

Body

字段名称是否必须默认值备注
skuId商品库存单位
count购买商品数量

接口封装

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

参考代码

通过 SKU 组件提供的 add-cart 事件,获取加入购物车时所需的参数。

    <!-- src/pages/goods/goods.vue -->
    <script setup lang="ts">
    import type { SkuPopupEvent } from '@/components/vk-data-goods-sku-popup.d.ts'
    import { postMemberCartAPI } from '@/services/cart'

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

    <template>
      <!-- SKU弹窗组件 -->
      <vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" @add-cart="onAddCart" />
    </template>

购物车列表

购物车列表需要访问后才能登录,因此需要利用store会员信息判断登录状态

image-20240424144546702.png

静态结构

<!-- src/pages/cart/cart.vue -->
<script setup lang="ts">
//
</script>

<template>
  <scroll-view scroll-y class="scroll-view">
    <!-- 已登录: 显示购物车 -->
    <template v-if="true">
      <!-- 购物车列表 -->
      <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 2" :key="item" class="cart-swipe">
            <!-- 商品信息 -->
            <view class="goods">
              <!-- 选中状态 -->
              <text class="checkbox" :class="{ checked: true }"></text>
              <navigator
                :url="`/pages/goods/goods?id=1435025`"
                hover-class="none"
                class="navigator"
              >
                <image
                  mode="aspectFill"
                  class="picture"
                  src="https://yanxuan-item.nosdn.127.net/da7143e0103304f0f3230715003181ee.jpg"
                ></image>
                <view class="meta">
                  <view class="name ellipsis">人手必备,儿童轻薄透气防蚊裤73-140cm</view>
                  <view class="attrsText ellipsis">黄色小象 140cm</view>
                  <view class="price">69.00</view>
                </view>
              </navigator>
              <!-- 商品数量 -->
              <view class="count">
                <text class="text">-</text>
                <input class="input" type="number" value="1" />
                <text class="text">+</text>
              </view>
            </view>
            <!-- 右侧删除按钮 -->
            <template #right>
              <view class="cart-swipe-right">
                <button 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" :class="{ checked: true }">全选</text>
        <text class="text">合计:</text>
        <text class="amount">100</text>
        <view class="button-grounp">
          <view class="button payment-button" :class="{ disabled: true }"> 去结算(10) </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>

登录状态

已登录显示购物车列表,否则应引导用户去登录。

<!-- src/pages/cart/cart.vue -->
<script setup lang="ts">
import { useMemberStore } from '@/stores'

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

<template>
  <scroll-view scroll-y class="scroll-view">
    <!-- 已登录: 显示购物车 -->
    <template v-if="memberStore.profile">
      <!-- 购物车列表 -->
    </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>
  </scroll-view>
</template>

列表渲染

调用接口获取当前登录用户购物车中的商品列表。

接口信息

接口地址:/member/cart

请求方式:GET

登录权限:

请求参数:无

接口封装

// src/services/cart.ts
import type { CartItem } from '@/types/cart'
/**
 * 获取购物车列表
 */
export const getMemberCartAPI = () => {
  return http<CartItem[]>({
    method: 'GET',
    url: '/member/cart',
  })
}

类型声明

    // src/types/cart.d.ts
    /** 购物车类型 */
    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
    }

渲染列表

在页面初始化的时候判断用户是否已登录,已登录则获取购物车列表。

<!-- src/pages/cart/cart.vue -->
<script setup lang="ts">
import { useMemberStore } from '@/stores'
import { ref } from 'vue'
import type { CartItem } from '@/types/cart'
import { getMemberCartAPI } from '@/services/cart'
import { onLoad } from '@dcloudio/uni-app'

// 获取会员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">
        ...
        <!-- 滑动操作分区 -->
        <uni-swipe-action>
          <!-- 滑动操作项 -->
          <uni-swipe-action-item v-for="item in cartList" :key="item.skuId" class="cart-swipe">
            <!-- 商品信息 -->
            <view class="goods">
              <!-- 选中状态 -->
              <text class="checkbox" :class="{ checked: true }"></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">
                <text class="text">-</text>
                <input class="input" type="number" v-model="item.count" />
                <text class="text">+</text>
              </view>
            </view>
            <!-- 右侧删除按钮 -->
            <template #right>
              <view class="cart-swipe-right">
                <button class="button delete-button">删除</button>
              </view>
            </template>
          </uni-swipe-action-item>
        </uni-swipe-action>
      </view>
      ...
    </template>
    ...
  </scroll-view>
</template>

猜你喜欢

这部分是在购物车列表下方进行展示,之前在用户模块的 “我的”用户页 也封装过这个组件,所以不再赘述,只将怎么用到购物车页上

<!-- src/pages/cart/cart.vue -->
<script setup lang="ts">
import { useGuessList } from '@/composables'

// 猜你喜欢组合式函数
const { guessRef, onScrolltolower } = useGuessList()
</script>
<template>
    <scroll-view enable-back-to-top @scrolltolower="onScrolltolower">
            ...
            <!-- 猜你喜欢 -->
            <XtxGuess ref="guessRef"></XtxGuess>
            ...
    </scroll-view>
</template>

删除购物车

通过侧滑删除购物车的商品,使用 uni-swipe-action 组件实现。

image-20240424160225425.png

接口详情

接口地址:/member/cart

请求方式:DELETE

登录权限:

请求参数:

Body

字段名称是否必须类型备注
idsstring[]SKUID 集合

接口封装

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

参考代码

<!-- src/pages/cart/cart.vue -->
<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>
  <!-- 右侧删除按钮 -->
  <template #right>
    <view class="cart-swipe-right">
      <button @tap="onDeleteCart(item.skuId)" class="button delete-button">删除</button>
    </view>
  </template>
</template>

修改商品信息

修改购买数量,修改选中状态。

image-20240424163057842.png

接口详情

接口地址:/member/cart/:id

请求方式:PUT

登录权限:

请求参数:

路径参数

字段名称是否必须默认值备注
id商品的 skuId

Body

字段名称是否必须默认值备注
selected非必须是否选中
count非必须数量

接口封装

// src/services/cart.ts
/**
 * 修改购物车单品
 * @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,
  })
}

修改商品数量

复用之前 SKU 插件中已经导入的 步进器组件 修改商品数量,然后补充好下面的类型声明文件,让组件类型更安全。

声明文件

// src/components/vk-data-input-number-box.d.ts
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
  }
}

参考代码

<!-- src/pages/cart/cart.vue -->
<script setup lang="ts">
import { putMemberCartBySkuIdAPI } from '@/services/cart'
import vkDataInputNumberBox from '@/components/vk-data-input-number-box.vue'
import type { InputNumberBoxEvent } from '@/components/vk-data-input-number-box.d.ts'

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

<template>
  <!-- 商品数量 -->
  <view class="count">
    <vk-data-input-number-box
      v-model="item.count"
      :min="1"
      :max="item.stock"
      :index="item.skuId"
      @change="onChangeCount"
    />
  </view>
</template>

修改商品状态

修改单个商品选中会影响全选状态,修改全选状态同理。

image-20240424172632776.png

全选商品

接口地址:/member/cart/selected

请求方式:PUT

登录权限:

请求参数:

路径参数

字段名称是否必须默认值备注
idskuId

Body

字段名称是否必须默认值备注
selected是否全选
ids商品集合

接口封装

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

参考代码

<!-- src/pages/cart/cart.vue -->
<script setup lang="ts">
// 修改选中状态-单品修改
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>
  <!-- 商品信息 -->
  <view class="goods">
    <!-- 选中状态 -->
    <text @tap="onChangeSelected(item)" class="checkbox" :class="{ checked: item.selected }">
    </text>
  </view>
  <!-- 吸底工具栏 -->
  <view class="toolbar">
    <text @tap="onChangeSelectedAll" class="all" :class="{ checked: isSelectedAll }">全选</text>
  </view>
</template>

底部结算信息

计算总钱数(总金额):

计算并展示购物车中选中商品所要支付的总金额,在用户切换商品选中状态和改变购数量后总的金额也要相应的进行重新计算,要实现这个功能我们仍然借助计算属性来实现

image-20240424175928752.png

<!-- src/pages/cart/cart.vue -->
<script setup lang="ts">
// 计算选中单品列表
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>

带返回按钮的购物车

tabBar页:小程序跳转到 tabBar 页时,会关闭其他所有非tabBar页,所以小程序的 tabBar页没有后退按钮

有历史记录的普通页才会显示后退按钮,比如从商品详情页的购物车按钮点击进入时;

为了解决小程序 tabBar 页面限制 导致无法返回上一页的问题,可将购物车业务独立为组件,使其既可从底部 tabBar 访问,又可在商品详情页中跳转并返回

这样就需要 两个购物车页面 实现该功能,其中一个页面为 tabBar 页,另一个为普通页。

目录结构如下:

    pages/cart
    ├── components
    │   └── CartMain.vue ............. 购物车业务组件
    ├── cart2.vue .................... 普通页(带后退按钮)
    └── cart.vue   ................... TabBar页(不带后退按钮)

实现步骤

image-20240425101419934.png

参考代码

把原本的购物车业务独立封装成组件:将原来cart.vue所有代码剪切到CartMain.vue中

<!-- src/pages/cart/components/CartMain.vue -->
<script setup lang="ts">
import { useMemberStore } from '@/stores'
...
</script>
<template>
    <scroll-view scroll-y class="scroll-view" enable-back-to-top @scrolltolower="onScrolltolower">
    ...
    </scroll-view>
</template>
<style lang="scss">
...
</style>

新建普通页cart2.vue,添加pages.json路由

// src/pages.json
"pages": [
    {
      "path": "pages/cart/cart2",
      "style": {
        "navigationBarTitleText": "购物车"
      }
    },
]

在两个购物车页面cart、cart2分别导入购物车业务组件

<!-- src/pages/cart/cart.vue  tabBar 购物车(不带返回按钮) -->
<script setup lang="ts">
import CartMain from './components/CartMain.vue'
</script>

<template>
  <CartMain />
</template>
<!-- src/pages/cart/cart2.vue  普通页跳转的购物车(带返回按钮)-->
<script setup lang="ts">
import CartMain from './components/CartMain.vue'
import { ref } from 'vue'

// 标记类型为普通购物页,为该页的安全区域作准备(为后面作铺垫)
const type = ref(2)
</script>

<template>
  <CartMain :type="type" />
</template>

<style lang="scss">
//
</style>

商品详情页的购物车修改为普通页面跳转,然后npm run dev:mp-weixin重启下前端,这样才能识别到普通跳转

<!-- src/pages/goods/goods.vue用户操作(navigate代表普通跳转,为默认选项,可不写)-->
...
<navigator class="icons-button" url="/pages/cart/cart2" open-type="navigate">
    <text class="icon-cart"></text>购物车
</navigator>

因为普通页跳转的购物车页下方的 吸底工具栏 不像 tabBar 页自动集成了《安全距离》,所以需要单独作判断;注意:

Q:为什么不用marginBottom来隔开吸底工具栏到底部的距离?

A:因为虽然空出安全距离了,但普通页跳转的购物车页下面的猜你喜欢列表会漏出来,不太美观,所以用内边距比较好

image-20240425151508060.png

<!-- src/pages/cart/components/CartMain.vue -->
<script setup lang="ts">  
// uniapp 获取页面参数(由前面的cart2.vue页传参进来)
const query = defineProps<{
  type: number
}>()

// 获取屏幕边界到安全区距离
const { safeAreaInsets } = uni.getSystemInfoSync()
</script>
<template>
    <!-- 吸底工具栏 -->
    <!-- 普通页跳转-->
    <view
            class="toolbar"
            v-if="query.type === 2"
            :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
            >
            ... // 上下这两部分完全一样
    </view>
    <!-- tabBar跳转-->
    <view class="toolbar" v-else>
            ... // 上下这两部分完全一样
    </view>
</template>

十二、订单模块

由于该模块字数太多,超过掘金文章发布的最大字符数限制,故而挪到第四页中展示...

十三、项目打包

微信小程序端

把当前 uni-app 项目打包成微信小程序端,并发布上线。

image-20240502160239585.png

操作步骤

  1. 运行打包命令 pnpm build:mp-weixin
  2. 预览和测试,微信开发者工具导入生成的 /dist/build/mp-weixin 目录
  3. 上传小程序代码
  4. 提交审核和发布

步骤图示

项目打包上线需要使用到多个工具,注意工具之间的职责。

VSCode ----> 微信开发者工具 ----> 微信公众平台

了解:开发者也可独立使用 miniprogram-ci 进行小程序代码的上传等操作。

::: tip 举一反三

打包成其他小程序端的步骤类似,只是更换了 打包命令开发者工具

:::

条件编译

::: tip 常见问题

Q:按照 uni-app 规范开发可保证多平台兼容,但每个平台有自己的一些特性,该如何处理?

A:通过 条件编译,让代码按条件编译到指定平台。

:::

build_picture_2.png

网页端不支持微信平台授权登录等功能,可通过 条件编译,实现不同端渲染不同的登录界面。

条件编译语法

通过特殊注释,以 #ifdef#ifndef平台名称 开头,以 #endif 结尾。

多平台编译: #ifdef H5 || MP-WEIXIN 表示在 H5 端 或 微信小程序端 代码。

条件编译支持: 支持 .vue, .ts, .js, .scss, .css, pages.json 等文件。

微信平台特有API,需要条件编译:wx.login()、wx.requestPayment()、open-type【快速搜索有关代码,然后添加上条件编译即可】

<!-- src/pages/login/login.vue -->
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'

// #ifdef MP-WEIXIN
// 获取code登录凭证
let code = ""
onLoad(async ()=> {
    const res = await wx.login()
    code = res.code
})
// 获取用户手机号(企业写法)
const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = async (ev) => {
  // 获取参数
  const encryptedData = ev.detail!.encryptedData!
  const iv = ev.detail!.iv!
  // 登录请求
  const res = await postLoginWxMinAPI({ code, encryptedData, iv })
  loginSuccess(res.result)
}
// #endif

</script>

<template>
  <!-- 小程序端授权登录 -->
  <!-- #ifdef MP-WEIXIN -->
  <button class="button phone" open-type="getPhoneNumber" @getphonenumber="onGetphonenumber">
     <text class="icon icon-phone"></text>
     手机号快捷登录
  </button>
  <!-- #endif -->
</template>

<style>
/* 如果出现样式兼容,也可添加条件编译 */
page {
  /* #ifdef H5 */
  background-color: pink;
  /* #endif */
}
</style>
<!-- src/pagesOrder/detail/detail.vue -->
<script setup lang="ts">
// 订单支付
const onOrderPay = async () => {
  // 通过环境变量区分开发环境
  if (import.meta.env.DEV) {
    // 开发环境:模拟支付,修改订单状态为已支付
    await getPayMockAPI({ orderId: query.id })
  } else {
    // #ifdef MP-WEIXIN
    // 生产环境:获取支付参数 + 发起微信支付
    const res = await getPayWxPayMiniPayAPI({ orderId: query.id })
    await wx.requestPayment(res.result)
    // #endif
  }
  // 关闭当前页,再跳转支付结果页
  uni.redirectTo({ url: `/pagesOrder/payment/payment?id=${query.id}` })
}
</script>
<!-- src/pages/goods/goods.vue -->
<!-- 客服 -->
<!-- #ifdef MP-WEIXIN -->
<button class="icons-button" open-type="contact">
    <text class="icon-handset"></text>客服
</button>
<!-- #endif -->
<!-- src/pages/my/my.vue -->
<!-- 客服 -->
<!-- #ifdef MP-WEIXIN-->
<button class="contact icon-handset" open-type="contact">售后</button>
<!-- #endif -->
<!-- src/pagesMember/settings/settings.vue -->
<!-- #ifdef MP-WEIXIN -->
<view class="list">
   <button hover-class="none" class="item arrow" open-type="openSetting">授权管理</button>
   <button hover-class="none" class="item arrow" open-type="feedback">问题反馈</button>
   <button hover-class="none" class="item arrow" open-type="contact">联系我们</button>
</view>
<!-- #endif -->

打包为 H5 端

把当前 uni-app 项目打包成网页(H5)端,并配置路由基础路径。

实现步骤

  1. 运行打包命令 pnpm build:h5
  2. 预览和测试,使用浏览器打开 /dist/build/h5 目录下的 index.html 文件
  3. 由运维部署到服务器

路由基础路径

默认的路由基础路径为 / 根路径,部分网站并不是部署到根路径,需要按运维要求调整。

    // src/manifest.json
    {
      /* 网页端特有配置 */
      "h5": {
        "router": {
          // 基础路径:./ 为相对路径
          "base": "./"
        }
      }
      /* 小程序特有相关 */,
      "mp-weixin": {
        // …省略
      },
      "vueVersion": "3"
    }
<!-- src/pages/login/login.vue -->
<template>
    ...
    <!-- 网页端表单验证 -->
    <!-- #ifdef H5 -->
    <input class="input" type="text" placeholder="请输入用户名/手机号码" />
    <input class="input" type="text" placeholder="请输入密码" password />
    <button class="button phone">登录</button>
    <!-- #endif -->
</template>

打包为 APP 端

App 端 的打包,分为预览、测试、发行,使用 HBuilderX 工具等步骤

运行为APP端:

开启开发者选项:在设置里直接搜,如果手机里搜不出来,可以连续点击手机 “版本” 按钮,直到出现开发者工具为止,然后打开它

如果出现版本兼容异常提示,可到uni-app官网去更新依赖到指定版本:npx @dcloudio/uvm@latest 3.2.0 改成 3.8.12

image-20240503153644302.png

Android 端

步骤:注册DCloud账号,获取AppID,设置图标,进行打包,设置包名,使用免费云端证书,打正式包

image-20240506105206942.png

image-20240506110750195.png

IOS 端

提前在苹果电脑上安装好xcode,然后在hbuilder上跑起项目,调试完后进行打包处理,但ios证书申请并不免费,需要有苹果开发者账号

image-20240506114029458.png

跨端兼容

uni-app 跨端注意uni-app CSS支持uni-app 条件编译

视口差异

小程序端不支持 * 选择器,可改成采用原始的css逗号分隔写法

页面视口差异(tabBar页、普通页)

image-20240506145007662.png

image-20240506145128424.png

解决方案:

  • 购物车页因为下方的结算栏,会导致猜你喜欢滚动不了或者不能完全展示多页商品,可设置 page { height: 100%; } 解决
/* src/pages/cart/cart.vue && src/pages/cart/cart2.vue */
page {
    height: 100%;
}
  • 购物车结算栏在不同端的固定定位问题,可用 --window-bottom 解决
.toolbar {
    position: fixed;
    bottom: calc(var(--window-bottom));
}

样式隔离

微信小程序端 是多页面应用,样式不会被 scoped 隔离,多页面之间样式通用,但:

H5端 是单页面应用,默认开启 scoped 样式隔离;

App端 默认是系统 webview 渲染,其组件样式也会默认被隔离;

而骨架屏需要用到首页组件的一些基础样式,但因为scoped隔离了导致没法用,因此 H5 跟 Android 端样式显示异常

image-20240506150130534.png

解决方案:

分别将骨架屏涉及到的组件样式拆分成独立的scss文件,然后将样式导入首页组件和骨架屏中使用(只举例轮播图组件的,其他同理)

// src/components/XtxSwiper.scss
/* 轮播图 */
.carousel {
  // height: 100%;
  height: 280rpx;
  position: relative;
  overflow: hidden;
  transform: translateY(0);
  background-color: #efefef;
  .indicator {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 16rpx;
    display: flex;
    justify-content: center;
    .dot {
      width: 30rpx;
      height: 6rpx;
      margin: 0 8rpx;
      border-radius: 6rpx;
      background-color: rgba(255, 255, 255, 0.4);
    }
    .active {
      background-color: #fff;
    }
  }
  .navigator,
  .image {
    width: 100%;
    height: 100%;
  }
}
<!-- src/components/XtxSwiper.vue -->
<style lang="scss">
    @import '@/components/XtxSwiper.scss';
</style>
<!-- src/pages/index/PageSkeleton.vue -->
<style lang="scss">
    /* H5端默认开启 scoped 样式隔离,导致组件基础样式失效,需要额外导入样式 */
    /* #ifdef H5 || APP-PLUS */
    @import '@/components/XtxSwiper.scss';
    @import './CategoryPanel.scss';
    @import './HotPanel.scss';
    @import '@/components/XtxGuess.scss';
    /* #endif */
</style>

其他页面的骨架屏涉及到的组件操作同理

调试:

运行 npm run dev:h5 后,在浏览器(H5)上的骨架屏加载完后一闪而过,可以先改回原来的骨架屏一直展示,方便调试

<!-- <PageSkeleton v-if="isLoading" /> -->
<PageSkeleton v-if="true" />

同时有另外一个小bug

Android 端首页骨架屏加载时出现顶部导航栏滚动的现象,因为:

小程序端加载page时能作用在父元素 uni-page-body 标签上,但Android端并不能渲染出 uni-page-body,因此只能渲染在其爷爷元素id="app"上,所以样式里应该多加个 #app

<!-- src/pages/index/index.vue -->
<style lang="scss">
/* #ifdef APP-PLUS */
#app,
/* #endif */
page {
  background-color: #f7f7f7;
  height: 100%;
  display: flex;
  flex-direction: column;
}
</style>

组件兼容

商品详情页下方客服按钮渲染时,由于只在小程序端有效,在其他端失效时flex布局会乱,因为多了层class="navigator-wrap"的a标签,flex渲染不到,因此多加一层样式即可

image-20240507093903489.png

/* src/pages/goods/goods.scss */
/* #ifdef H5 || APP-PLUS */
.toolbar .icons .navigator-wrap {
  flex: 1;
}
/* #endif */

JS API兼容

image-20240507102415807.png

<!-- src/pagesMember/profile/profile.vue -->
<script setup lang="ts">
// 修改头像
const onAvatarChange = () => {
  // #ifdef MP-WEIXIN
  // 调用拍照/选择图片
  uni.chooseMedia({
    // 文件个数
    count: 1,
    // 文件类型
    mediaType: ['image'],
    success: (res) => {
      // 本地路径
      const { tempFilePath } = res.tempFiles[0]
      // 上传图片
      uploadFile(tempFilePath)
    },
  })
  // #endif

  // #ifdef H5 || APP-PLUS
  uni.chooseImage({
    count: 1,
    success: (res) => {
      // 本地路径
      const tempFilePath = res.tempFilePaths[0]
      // 上传图片
      uploadFile(tempFilePath)
    },
  })
  // #endif
}

// 文件上传 封装
const uploadFile = (tempFilePath: string) => {
  // 文件上传
  uni.uploadFile({
    url: '/member/profile/avatar', // [!code ++]
    name: 'file', // 后端数据字段名  // [!code ++]
    filePath: tempFilePath, // 新头像  // [!code ++]
    success: (res) => {
      // 判断状态码是否上传成功
      if (res.statusCode === 200) {
        // 提取头像
        const { avatar } = JSON.parse(res.data).result
        // 当前页面更新头像
        profile.value!.avatar = avatar // [!code ++]
        // 更新 Store 头像
        memberStore.profile!.avatar = avatar // [!code ++]
        uni.showToast({ icon: 'success', title: '更新成功' })
      } else {
        uni.showToast({ icon: 'error', title: '出现错误' })
      }
    },
  })
}
</script>

uniCloud 云开发

云开发简介

image-20240507110438601.png

准备工作uni-app官网也有介绍)

通过云开发完成H5、APP端省市区地址的兼容功能

注意:uni-app cli项目内使用uniCloud需要使用HBuilderX的运行菜单运行项目,且需要在uniCloud目录关联服务空间

image-20240507111157555.png

写uni-data-picker组件

image-20240507112557826.png

image-20240507142132149.png

<!-- src/pagesMember/address/address-form.vue -->
<script setup lang="ts">

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

// #ifdef H5 || APP-PLUS
const onCityChange: UniHelper.UniDataPickerOnChange = (ev) => {
  // 省市区(后端参数)
  const [provinceCode, cityCode, countyCode] = ev.detail.value.map((v) => v.value)
  // 合并数据用于表单提交
  Object.assign(form.value, { provinceCode, cityCode, countyCode })
}
// #endif

</script>

<template>
<uni-forms-item class="form-item" name="countyCode">
    <text class="label">所在地区</text>
    <!-- #ifdef MP-WEIXIN -->
    <picker
        class="picker"
        mode="region"
        :value="form.fullLocation.split(' ')"
        @change="onRegionChange"
        >
            <view v-if="form.fullLocation">{{ form.fullLocation }}</view>
            <view v-else class="placeholder">请选择省/市/区(县)</view>
    </picker>
    <!-- #endif -->
    <!-- #ifdef H5 || APP-PLUS -->
    <uni-data-picker
                 placeholder="请选择地址"
                 popup-title="请选择城市"
                 collection="opendb-city-china"
                 field="code as value, name as text"
                 orderby="value asc"
                 :step-searh="true"
                 self-field="code"
                 parent-field="parent_code"
                 :clear-icon="false"
                     @change="onCityChange"
                     v-model="form.countyCode"
                 />
    <!-- #endif -->
</uni-forms-item>
</template>

<style lang="scss">
/* #ifdef H5 || APP-PLUS */
:deep(.selected-area) {
  height: auto;
  flex: 0 1 auto;
}
/* #endif */
</style>

注意:针对H5端点击地址栏修改时会闪退回首页,需要添加@tap.prevent="() =>()" 来阻止冒泡

<!-- src/pagesMember/address/address.vue -->
<tamplate>
    <navigator
               class="edit"
               hover-class="none"
               :url="`/pagesMember/address/address-form?id=${item.id}`"
               @tap.stop="() => {}"
               @tap.prevent="() => {}"
               >
        修改
    </navigator>
</tamplate>

十四、搜索框

教学项目中并没有关于商品搜索框的相关教程,以下是自己研究写得

image-20240509101347470.png

<script setup leng="ts">
import { getCategoryTopAPI } from '@/services/category'
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'// 获取屏幕边界到安全区距离
const { safeAreaInsets } = uni.getSystemInfoSync()
​
// 搜索的值
const seek = ref('')
// 搜索栏是否显示
const isShow = ref(false)
// 搜索输入
const input = () => {
  isShow.value = true
}
// 搜索框获取焦点时
const focus = () => {
  isShow.value = true
}
// 搜索框失去焦点时
const blur = () => {
  isShow.value = false
}
​
// 声明搜索的商品
interface Good {
  id: number
  name: string
}
​
// 搜索栏所有商品列表
const searchList = ref<Good[]>([])
const getCategoryTopData = async () => {
  const res = await getCategoryTopAPI()
  // 查询时,过滤出模糊匹配的数据
  const filtered = res.result.filter((v) => v.children)
​
  // 将数据对象里嵌套的所有商品扁平化处理并返回一个新的数组
  searchList.value = filtered.flatMap((category) =>
    category.children.flatMap((subCategory) =>
      subCategory.goods.map((good) => ({ id: Number(good.id), name: good.name })),
    ),
  )
}
​
// 过滤出符合条件的商品
const filterList = computed(() => {
  var arr: any[] = []
  // 展示所有搜索商品(商品过多,没必要先展示所有的)
  // searchList.value.forEach((item) => arr.push(item))
  // 展示过滤后的商品
  if (seek.value) {
    arr = searchList.value.filter((item) => item.name.includes(seek.value))
  }
  return arr
})
​
// 加载列表
onLoad(() => {
  getCategoryTopData()
})
</script><template>
<!-- 搜索栏 -->
    <view class="search">
      <uni-search-bar
        v-model="seek"
        @input="input"
        @focus="focus"
        @blur="blur"
        placeholder="搜索商品"
        bgColor="#80ddcc"
        class="easyInput"
        :radius="8"
      >
        <template v-slot:searchIcon>
          <uni-icons color="#ffffff" size="18" type="search" />
        </template>
      </uni-search-bar>
      <!-- 搜索栏提示框 -->
      <view
        class="InputList"
        v-show="isShow && filterList.length"
        :style="{ top: (safeAreaInsets?.top || 0) + 84 + 'px' }"
      >
        <navigator
          v-for="(item, index) in filterList"
          :key="index"
          class="listSon"
          :url="`/pages/goods/goods?id=${item.id}`"
        >
          {{ item.name }}
        </navigator>
      </view>
    </view>
</template><style lang="scss">
    .search {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 10rpx;
    /* 搜索框 */
    .easyInput {
      width: 100%;
    }
    /* 搜索列表 */
    .InputList {
      width: 93%;
      height: 480rpx;
      min-height: 200rpx;
      position: absolute;
      left: 31rpx;
      top: 70rpx;
      overflow: hidden;
      overflow-y: scroll;
      border-radius: 10rpx;
      color: black;
      background-color: #ffffff;
      z-index: 1;
    }
    /* 单个搜索数据 */
    .listSon {
      height: 60rpx;
      line-height: 60rpx;
      font-size: 32rpx;
      text-indent: 2em;
      white-space: nowrap; /* 禁止文本换行 */
      overflow: hidden; /* 隐藏超出部分 */
      text-overflow: ellipsis; /* 显示省略号 */
      font-size: 30rpx;
    }
  }
</style>

完结!