Skip to content

uniapp小程序2

七、小兔鲜儿 - 微信登录

涉及知识点:微信授权登录,文件上传,Store 状态管理等。

1. 微信登录

微信小程序的开放能力,允许开发者获取微信用户的基本信息(昵称、性别、手机号码等),开发者常用来实现注册/登录的功能。

1.1 登录方式

常见登录/注册方式:

  1. 用户名/手机号 + 密码
  2. 手机号 + 验证码
  3. 授权登录

实际开发过程中常常需要实现以上的一种或多种方式,我们的项目主要实现授权登录

微信授权登录

用户在使用小程序时,其实已登录微信,其本质上就是:微信授权给小程序读取微信用户信息

微信授权登录

传统登录方式

传统登录方式,一般是通过输入密码或者手机验证码实现登录。

传统密码登录

实现步骤:

image-20240727110949311

1.2 静态结构

登录页 src/pages/login/login.vue

vue
<script setup lang="ts">
//
</script>

<template>
  <view class="viewport">
    <view class="logo">
      <image
        src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/logo_icon.png"
      ></image>
    </view>
    <view class="login">
      <!-- 网页端表单登录 -->
      <!-- <input class="input" type="text" placeholder="请输入用户名/手机号码" /> -->
      <!-- <input class="input" type="text" password placeholder="请输入密码" /> -->
      <!-- <button class="button phone">登录</button> -->

      <!-- 小程序端授权登录 -->
      <button class="button phone">
        <text class="icon icon-phone"></text>
        手机号快捷登录
      </button>
      <view class="extra">
        <view class="caption">
          <text>其他登录方式</text>
        </view>
        <view class="options">
          <!-- 通用模拟登录 -->
          <button>
            <text class="icon icon-phone">模拟快捷登录</text>
          </button>
        </view>
      </view>
      <view class="tips">登录/注册即视为你同意《服务条款》和《小兔鲜儿隐私协议》</view>
    </view>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
}
.viewport {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: 20rpx 40rpx;
}
.logo {
  flex: 1;
  text-align: center;
  image {
    width: 220rpx;
    height: 220rpx;
    margin-top: 15vh;
  }
}
.login {
  display: flex;
  flex-direction: column;
  height: 60vh;
  padding: 40rpx 20rpx 20rpx;
  .input {
    width: 100%;
    height: 80rpx;
    font-size: 28rpx;
    border-radius: 72rpx;
    border: 1px solid #ddd;
    padding-left: 30rpx;
    margin-bottom: 20rpx;
  }
  .button {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 80rpx;
    font-size: 28rpx;
    border-radius: 72rpx;
    color: #fff;
    .icon {
      font-size: 40rpx;
      margin-right: 6rpx;
    }
  }
  .phone {
    background-color: #28bb9c;
  }
  .wechat {
    background-color: #06c05f;
  }
  .extra {
    flex: 1;
    padding: 70rpx 70rpx 0;
    .caption {
      width: 440rpx;
      line-height: 1;
      border-top: 1rpx solid #ddd;
      font-size: 26rpx;
      color: #999;
      position: relative;
      text {
        transform: translate(-40%);
        background-color: #fff;
        position: absolute;
        top: -12rpx;
        left: 50%;
      }
    }
    .options {
      display: flex;
      justify-content: center;
      align-items: center;
      margin-top: 70rpx;
      button {
        padding: 0;
        background-color: transparent;
      }
    }
    .icon {
      font-size: 24rpx;
      color: #444;
      display: flex;
      flex-direction: column;
      align-items: center;
      &::before {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 80rpx;
        height: 80rpx;
        margin-bottom: 6rpx;
        font-size: 40rpx;
        border: 1rpx solid #444;
        border-radius: 50%;
      }
    }
    .icon-weixin::before {
      border-color: #06c05f;
      color: #06c05f;
    }
  }
}
.tips {
  position: absolute;
  bottom: 80rpx;
  left: 20rpx;
  right: 20rpx;
  font-size: 22rpx;
  color: #999;
  text-align: center;
}
</style>

1.3 获取登录凭证

前端:调用 wx.login() 接口获取登录凭证(code)。

后端:通过凭证(code)向微信服务器生成用户登录态

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

// 微信登录参数
const loginParams = ref<WxLoginParams>({
  code: '',
  encryptedData: '',
  iv: '',
})

onLoad(async () => {
  // 1. 获取 code 登录凭证
  const result = await wx.login()
  // 存储 code
  loginParams.value.code = result.code
})
</script>

注意:

  • code 获取不要在 getphonenumber 事件回调函数调用,否则可能会出现错误!!!

  • 用户登录态,不包含用户昵称、性别、手机号码等信息,作用是用于后端与微信服务器通讯

1.4 获取手机号码

出于安全限制,小程序【规定】想获取用户的手机号,必须由用户主动【点击按钮】并【允许申请】才可获取加密的手机号信息。

前端:为 button设置 open-type="getPhoneNumber"并绑定@getphonenumber事件在事件处理函数中获取加密的手机号信息(encryptedDataiv

后端:解密手机号信息,把手机号和用户登录态关联在一起。

授权登录

参考代码

vue
<script setup lang="ts">
// 2.点击手机号登录按钮 获取encryptedData和iv
const onGetPhoneNumber: UniHelper.ButtonOnGetphonenumber = async (event) => {
  // 存储 encryptedData
  loginParams.value.encryptedData = event.detail.encryptedData as string
  // 存储 iv
  loginParams.value.iv = event.detail.iv as string
  // 3. 调用微信登录接口
  const result = await postLoginWxMinAPI(loginParams.value)
  console.log(result)
}
</script>

<template>
  <view class="viewport">
    <view class="login">
      <!-- 小程序端授权登录 -->
      <button
        class="button phone"
        open-type="getPhoneNumber"
        @getphonenumber="onGetPhoneNumber"
      >
        <text class="icon icon-phone"></text>
        手机号快捷登录
      </button>
    </view>
  </view>
</template>
Tips:为何无法唤起获取手机号的界面

Tips:为何无法唤起获取手机号的界面

  • 获取手机号功能目前针对非个人开发者,所以个人开发者无法唤起获取手机号界面 详见文档
  • 项目提供了模拟登录 API 用于练习,模拟手机号一键登录,请看后续章节

1.5 微信登录接口(生产环境)

获取手机号功能,目前针对非个人开发者,且完成了认证的小程序开放详见文档

接口调用

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426849

请求封装 src/services/login.ts

ts
import type { WxLoginParams, WxLoginResponse } from '@/types/login'
import { http } from '@/utils/http'
/**
 *微信登录接口方法
 * @param data 微信登录参数 code,encryptedData,iv
 */
export const postLoginWxMinAPI = (data: WxLoginParams) =>
  http.post<WxLoginResponse>('/login/wxMin', data)

类型声明 src/types/login.d.ts

ts
/**微信登录接口参数*/
export interface WxLoginParams {
  /**通过wx.login接口获取到的code*/
  code: string
  /**通过getphonenumber事件回调中获取 */
  encryptedData: string
  /**通过getphonenumber事件回调中获取 */
  iv: string
}
/**微信登录返回的数据类型 */
export interface WxLoginResponse {
  /**用户id */
  id: 0
  /**用户手机号 */
  mobile: 'string'
  /**token有效期三天 */
  token: 'string'
  /**用户昵称 */
  nickname: 'string'
  /**用户头像 */
  avatar: 'string'
  /**用户名 */
  account: 'string'
}

参考代码

小兔鲜儿项目采用常见的 登录凭证 + 手机号 实现授权登录。

vue
// src/pages/login/login.vue

<script setup lang="ts">
import { postLoginWxMinAPI } from '@/services/login'
import { onLoad } from '@dcloudio/uni-app'
import type { WxLoginParams } from '@/types/login'
import { ref } from 'vue'

// 微信登录参数
const loginParams = ref<WxLoginParams>({
  code: '',
  encryptedData: '',
  iv: '',
})

// 2.点击手机号登录按钮 获取encryptedData和iv
const onGetPhoneNumber: UniHelper.ButtonOnGetphonenumber = async (event) => {
  // 存储 encryptedData
  loginParams.value.encryptedData = event.detail.encryptedData as string
  // 存储 iv
  loginParams.value.iv = event.detail.iv as string
  // 3. 调用微信登录接口
  const result = await postLoginWxMinAPI(loginParams.value)
  // 成功提示
  uni.showToast({ icon: 'none', title: '登录成功' })
}

onLoad(async () => {
  // 1. 获取 code 登录凭证
  const result = await wx.login()
  // 存储 code
  loginParams.value.code = result.code
})
</script>

<template>
  <view class="viewport">
    <view class="login">
      <button
        class="button phone"
        open-type="getPhoneNumber"
        @getphonenumber="onGetPhoneNumber"
      >
        <text class="icon icon-phone"></text>
        手机号快捷登录
      </button>
    </view>
  </view>
</template>

1.6 模拟手机登录(开发环境)

获取手机号功能,目前针对非个人开发者,且完成了认证的小程序开放详见文档

为了更好实现登录后续业务,后端提供内部测试接口,只需要传手机号即可实现快捷登录。

请求接口

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426851

请求封装

ts
/**
 * 模拟微信手机号登录的接口方法
 * @param phoneNumber  手机号
 */
export const postLoginWxMinSimpleAPI = (phoneNumber: string) =>
  http.post<WxLoginResponse>('/login/wxMin/simple', { phoneNumber })

1.7 用户信息持久化存储

Pinia 的持久化存储插件在 项目起步 模块已经搭建完成,现在只需要在用户登录成功后,补充 TS 类型声明并保存用户信息即可。

参考代码

保存用户信息的仓库:src/stores/modules/member.ts

ts
import type { WxLoginResponse } from '@/types/login'
import { defineStore } from 'pinia'
import { ref } from 'vue'

// 定义 Store
export const useMemberStore = defineStore(
  'member',
  () => {
    // 会员信息
    const profile = ref<WxLoginResponse>()
    // 保存会员信息,登录时使用,并存储到本地
    const setProfile = (val: WxLoginResponse) => {
      profile.value = val
    }
    // 清理会员信息,退出时使用,并清空本地存储
    const clearProfile = () => {
      profile.value = undefined
    }
    // 记得 return
    return {
      profile,
      setProfile,
      clearProfile,
    }
  },
  // 使用 pinia-plugin-persistedstate插件对store中的数据进行持久化存储
  {
    // 网页端配置持久化存储
    // persist: true,
    // 调整为兼容多端的API
    persist: {
      storage: {
        getItem(key) {
          return uni.getStorageSync(key)
        },
        setItem(key, value) {
          uni.setStorageSync(key, value)
        },
      },
    },
  },
)

登录页面:src/pages/login/login

vue
<script setup lang="ts">
import { postLoginWxMinAPI, postLoginWxMinSimpleAPI } from '@/services/login'
import { onLoad } from '@dcloudio/uni-app'
import type { WxLoginParams, WxLoginResponse } from '@/types/login'
import { ref } from 'vue'
import { useMemberStore } from '@/stores/modules/member'

// 获取存储用户信息的仓库
const memberStore = useMemberStore()
// 微信登录参数
const loginParams = ref<WxLoginParams>({
  code: '',
  encryptedData: '',
  iv: '',
})

// 2.点击手机号登录按钮 获取encryptedData和iv(非个人开发者才可以调用)
const onGetPhoneNumber: UniHelper.ButtonOnGetphonenumber = async (event) => {
  // 存储 encryptedData
  loginParams.value.encryptedData = event.detail.encryptedData as string
  // 存储 iv
  loginParams.value.iv = event.detail.iv as string
  // 3. 调用微信登录接口
  const result = await postLoginWxMinAPI(loginParams.value)
  loginSuccess(result.result)
}
// 模拟手机号快捷登录
const onGetPhoneNumberSimple = async () => {
  // 自己手动传递一个手机号码
  const result = await postLoginWxMinSimpleAPI('13188888888')
  loginSuccess(result.result)
}
// 登录成功后(保存用户信息到仓库,成功提示,页面跳转)
const loginSuccess = async (userData: WxLoginResponse) => {
  // 将用户的信息存储到仓库中
  memberStore.setProfile(userData)
  // 成功提示 等待成功提示完毕后再进行跳转
  await uni.showToast({ icon: 'success', title: '登录成功', duration: 2000 })
  // 页面跳转,返回上一个页面
  // uni.navigateBack()
  // 页面跳转,跳转到我的页面
  uni.switchTab({ url: '/pages/my/my' })
}

onLoad(async () => {
  // 1. 获取 code 登录凭证
  const result = await wx.login()
  // 存储 code
  loginParams.value.code = result.code
})
</script>
Tips:持久化存储pinia中的数据

Tips:持久化存储pinia中的数据

持久化存储pinia中的数据的方案:

  1. 使用 localStroge.setItem()H5端 wx.setStorageSync()小程序端 uni.setStorageSync()多端兼容 ..... (手动)
  2. 使用插件:pinia-plugin-persistedstate(自动)

pinia-plugin-persistedstate使用步骤:

  1. 安装:

    pnpm add pinia-plugin-persistedstate
  2. 在pinia 中使用插件

    ts
    import { createPinia } from 'pinia'
    + import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
    
    const pinia = createPinia()
    + pinia.use(piniaPluginPersistedstate)
  3. 自动存储 store 中的数据(H5端)

    创建 Store 时,将 persist 选项设置为 true

    ts
    import { defineStore } from 'pinia'
    import { ref } from 'vue'
    
    export const useStore = defineStore(
      'main',
      () => {
        const someState = ref('你好 pinia')
        return { someState }
      },
    +  {
    +    persist: true,
    +  },
    )
  4. 调整为兼容多端的API

    js
    export const useStore = defineStore(
      'main',
      () => {
        const someState = ref('你好 pinia')
        return { someState }
      },
    + {
    +    // 网页端配置持久化存储
    +    // persist: true,
    +    // 调整为兼容多端的API
    +    persist: {
    +      storage: {
    +        getItem(key) {
    +          return uni.getStorageSync(key)
    +        },
    +        setItem(key, value) {
    +          uni.setStorageSync(key, value)
    +        },
    +      },
    +    },
    +  },
    )
  5. 存储数据,清除数据

    当设置仓库中属性值时,会自动将该属性存储到浏览器本地存储中

    存储格式:
    store名       {"属性名":"值"}

    当将属性值修改为 undefined 时,会将该属性值从浏览器本地存储中清除

2. 刷新token

  1. JWT Token与Refresh Token的基本概念
    • JWT Token定义及应用:JSON Web Tokens(JWT)是一种开放标准(RFC 7519),它定义了一种简洁的方法用于作为JSON对象安全地在各方之间传递信息。这些信息可以被验证和信任,因为它们是数字签名的。特别适用于传输认证信息,例如在用户登录网站后,服务器会生成一个JWT token,包含了用户的认证信息和权限等信息。
    • Refresh Token的定义及作用:Refresh Token是一种长时间有效的token,用来在access token(通常是JWT token)过期时获取新的access token,而不需要用户重新登录。这极大地提升了用户体验,因为用户不需要频繁地进行登录操作。
  2. Refresh Token的应用时机
    • 当Access Token过期时:当JWT token过期时,可以使用Refresh Token去获取新的Access Token,这样可以保证用户的会话继续进行,不会因为token过期而突然被迫登出。
    • 基于用户活跃周期:设定一个用户的活跃周期,在这个活跃周期内,即使access token过期,只要活跃周期未到,就可以使用refresh token获取新的access token。如果超出了活跃周期,就需要用户重新登录。
  3. 刷新Token的技术实现
    • 后端提供刷新接口:通过后端提供的刷新接口,客户端在access token过期时携带refresh token请求新的access token。这种方式要求refresh token的有效期要长于access token。
    • 前端Cookie实现:将access token存储在cookie中,并在每次请求成功后覆盖并延长cookie的过期时间。这种方式下,只要用户有操作就会自动延长token的过期时间,直至无操作让cookie自然过期。
  4. 刷新Token的安全性考虑
    • 存储与传输安全:Refresh Token由于其长时间的有效期,必须被安全地存储和传输,防止泄露给未授权的第三方。
    • 列入黑名单机制:一旦Refresh Token可能被泄露,应迅速将其列入黑名单,阻止其使用。同时,对Refresh Token的操作需要进行严格的认证检查。
  5. 用户无感知的Token刷新
    • 前端拦截请求实现:在发起请求前判断token是否过期,若已过期则挂起请求,先进行token刷新再继续请求。这样可以避免不必要的请求,节省流量。
    • 后端拦截响应实现:先发起请求,如果接口返回表示token过期的响应码或信息,再进行token刷新并重试请求。这种方法简单粗暴,但可能会多消耗一次请求。

此外,需要考虑以下因素以优化刷新策略:

  • 本地时间和服务器时间的一致性:确保前端设备的时间准确,避免因时间误差导致提前或滞后刷新token。
  • 异常处理机制:设计合理的异常处理流程,如刷新token失败时的备选方案,确保用户体验不受影响。
  • 并发请求的处理:在并发请求场景下,处理好多个请求同时触发刷新逻辑的情况,避免产生冲突或重复请求。
  • 性能和开销考量:衡量刷新机制对系统性能的影响,优化算法以减少计算和网络开销。

综上所述,刷新token的恰当使用不仅能够提高应用的安全性,还能够显著提升用户体验。开发者应当根据具体应用场景选择合适的刷新策略,并考虑到上述相关因素来实现高效、安全的刷新机制。

实现思路:

  • 本项目中只有一个 access token(过期时间三天)不存在 Refresh Token ,但存在刷新token的接口可以在 token过期后重新根据过期 token 和用户的信息,可再次换取新的 token

  • 设置一个用户活跃的时间 > token的有效期,在此期间内用户登录应用 tokne过期重新获取新的tokne,并重置活跃时间。如超过活跃时间且 token过期,用户将重新登录获取token,重置活跃时间

  • 响应拦截器中,响应码为401 则token过期用户需要重新登录或刷新token

实现步骤:

方案一:设置用户活跃时间为 7 天,token 有效期为 3 天

  1. 当用户在登录后的 7 天都未使用此应用,在响应拦截器中后端返回 401 就需要重新登陆
  2. 当用户在登录后的 7 内使用应用且此时 token 已经过期,在响应拦截器中返回 401 但还未到活跃结束时间,此时发请求刷新token,重新发请求,并重置活跃时间

方案二:token 过期时就自动刷新token

  1. 在响应拦截器中,当响应状态码为 401时就发请求重新获取 token
  2. 重新发送此次请求并携带新的 token

八、小兔鲜儿 - 用户模块

在用户登录/注册成功后,展示会员信息,更新会员信息。

1. 会员中心页(我的)

主要实现两部分业务:

  1. 渲染当前登录会员的昵称和头像,从 Store 中获取。
  2. 猜你喜欢分页加载,可封装成**组合式函数(hook)**实现复用逻辑。
我的

1.1 静态结构

会员中心页面 src/pages/my/my.vue

vue
<script setup lang="ts">
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 订单选项
const orderTypes = [
  { type: 1, text: '待付款', icon: 'icon-currency' },
  { type: 2, text: '待发货', icon: 'icon-gift' },
  { type: 3, text: '待收货', icon: 'icon-check' },
  { type: 4, text: '待评价', icon: 'icon-comment' },
]
</script>

<template>
  <scroll-view class="viewport" scroll-y enable-back-to-top>
    <!-- 个人资料 -->
    <view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }">
      <!-- 情况1:已登录 -->
      <view class="overview" v-if="false">
        <navigator url="/pagesMember/profile/profile" hover-class="none">
          <image
            class="avatar"
            mode="aspectFill"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/avatar_3.jpg"
          ></image>
        </navigator>
        <view class="meta">
          <view class="nickname"> 黑马程序员 </view>
          <navigator class="extra" url="/pagesMember/profile/profile" hover-class="none">
            <text class="update">更新头像昵称</text>
          </navigator>
        </view>
      </view>
      <!-- 情况2:未登录 -->
      <view class="overview" v-else>
        <navigator url="/pages/login/login" hover-class="none">
          <image
            class="avatar gray"
            mode="aspectFill"
            src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-04-06/db628d42-88a7-46e7-abb8-659448c33081.png"
          ></image>
        </navigator>
        <view class="meta">
          <navigator url="/pages/login/login" hover-class="none" class="nickname">
            未登录
          </navigator>
          <view class="extra">
            <text class="tips">点击登录账号</text>
          </view>
        </view>
      </view>
      <navigator class="settings" url="/pagesMember/settings/settings" hover-class="none">
        设置
      </navigator>
    </view>
    <!-- 我的订单 -->
    <view class="orders">
      <view class="title">
        我的订单
        <navigator class="navigator" url="/pagesOrder/list/list?type=0" hover-class="none">
          查看全部订单<text class="icon-right"></text>
        </navigator>
      </view>
      <view class="section">
        <!-- 订单 -->
        <navigator
          v-for="item in orderTypes"
          :key="item.type"
          :class="item.icon"
          :url="`/pagesOrder/list/list?type=${item.type}`"
          class="navigator"
          hover-class="none"
        >
          {{ item.text }}
        </navigator>
        <!-- 客服 -->
        <button class="contact icon-handset" open-type="contact">售后</button>
      </view>
    </view>
    <!-- 猜你喜欢 -->
    <view class="guess">
      <XtxGuess ref="guessRef" />
    </view>
  </scroll-view>
</template>
<style lang="scss">
page {
  height: 100%;
  overflow: hidden;
  background-color: #f7f7f8;
}
.viewport {
  height: 100%;
  background-repeat: no-repeat;
  background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/center_bg.png);
  background-size: 100% auto;
}
/* 用户信息 */
.profile {
  margin-top: 20rpx;
  position: relative;

  .overview {
    display: flex;
    height: 120rpx;
    padding: 0 36rpx;
    color: #fff;
  }
  .avatar {
    width: 120rpx;
    height: 120rpx;
    border-radius: 50%;
    background-color: #eee;
  }
  .gray {
    filter: grayscale(100%);
  }
  .meta {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: flex-start;
    line-height: 30rpx;
    padding: 16rpx 0;
    margin-left: 20rpx;
  }
  .nickname {
    max-width: 350rpx;
    margin-bottom: 16rpx;
    font-size: 30rpx;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .extra {
    display: flex;
    font-size: 20rpx;
  }
  .tips {
    font-size: 22rpx;
  }
  .update {
    padding: 3rpx 10rpx 1rpx;
    color: rgba(255, 255, 255, 0.8);
    border: 1rpx solid rgba(255, 255, 255, 0.8);
    margin-right: 10rpx;
    border-radius: 30rpx;
  }
  .settings {
    position: absolute;
    bottom: 0;
    right: 40rpx;
    font-size: 30rpx;
    color: #fff;
  }
}
/* 我的订单 */
.orders {
  position: relative;
  z-index: 99;
  padding: 30rpx;
  margin: 50rpx 20rpx 0;
  background-color: #fff;
  border-radius: 10rpx;
  box-shadow: 0 4rpx 6rpx rgba(240, 240, 240, 0.6);
  .title {
    height: 40rpx;
    line-height: 40rpx;
    font-size: 28rpx;
    color: #1e1e1e;
    .navigator {
      font-size: 24rpx;
      color: #939393;
      float: right;
    }
  }
  .section {
    width: 100%;
    display: flex;
    justify-content: space-between;
    padding: 40rpx 20rpx 10rpx;
    .navigator,
    .contact {
      text-align: center;
      font-size: 24rpx;
      color: #333;
      &::before {
        display: block;
        font-size: 60rpx;
        color: #ff9545;
      }
    }
    .contact {
      padding: 0;
      margin: 0;
      border: 0;
      background-color: transparent;
      line-height: inherit;
    }
  }
}
/* 猜你喜欢 */
.guess {
  background-color: #f7f7f8;
  margin-top: 20rpx;
}
</style>

1.2 获取用户数据并渲染

  1. 获取用户仓库中的用户数据
js
// 获取存储用户信息的仓库
const memberStore = useMemberStore()
// 用户信息
const { profile } = storeToRefs(memberStore)
  1. 渲染
vue
<template>
  <scroll-view class="viewport" scroll-y enable-back-to-top>
    <!-- 个人资料 -->
    <view class="profile" :style="`padding-top: ${safeAreaInsets?.top}px`">
      <!-- 情况1:已登录 -->
+     <view class="overview" v-if="profile?.token">
        <navigator url="/pagesMember/profile/profile" hover-class="none">
          <image
            class="avatar"
+            mode="aspectFill"
            :src="" data-missing="profile.avatar"
          ></image>
        </navigator>
        <view class="meta">
          <view class="nickname"> {{ profile.nickname || profile.account }} </view>
          <navigator
            class="extra"
            url="/pagesMember/profile/profile"
            hover-class="none"
          >
            <text class="update">更新头像昵称</text>
          </navigator>
        </view>
      </view>
      <!-- 情况2:未登录 -->
+      <view class="overview" v-else>
        ....
      </view>
     ....

1.3 封装猜你喜欢分页加载hook

使用猜你喜欢自定义组件的步骤:

  1. 获取猜你喜欢自定义组件 CustomGuess 的实例对象(获取暴露的获取更多数据和重置数据的方法)

    vue
    <script>
    + // 猜你喜欢组件实例对象
    + const guessRef = ref<CustomGuessInstance>()
    </script>
    <template>
      .....
    + <CustomGuess ref="guessRef" />
    </template>

    为自定义组件添加类型 src/types/components.d.ts

    js
    // CustomGuess自定义组件实例类型
    export type CustomGuessInstance = InstanceType<typeof CustomGuess>
  2. scroll-view组件的上拉加载更多事件回调中调用猜你喜欢组件暴露的获取更多数据的方法

  3. scroll-view组件的下拉刷新更多事件回调中调用猜你喜欢组件暴露的重置数据的方法

    vue
    <script>
    import { debounce, throttle } from 'lodash'
    .....
    // 是否正在下拉刷新
    let refreshTrigger = ref(false)
    
    // scroll-view 组件上拉触底事件(节流)
    const onScrolltolower = throttle(() => {
      // 调用猜你喜欢组件的加载更多方法
      guessRef.value?.getMore()
    }, 1000)
    // scroll-view 组件的下拉刷新事件(防抖)
    const onScrollRefresh = debounce(async () => {
      // 设置正在下拉刷新
      refreshTrigger.value = true
      // 调用猜你喜欢组件的重置数据方法
      await guessRef.value?.resetData()
      // 关闭下拉刷新
      refreshTrigger.value = false
    }, 500)
    ....
    </script>
    <templae>
      <scroll-view
        class="viewport"
        scroll-y
        enable-back-to-top
        refresher-enabled
        refresher-background="#00c09d"
        refresher-default-style="white"
        :refresher-triggered="refreshTrigger"
        @refresherrefresh="onScrollRefresh"
        @scrolltolower="onScrolltolower"
      >
       .....
    </templae>

由于在首页也使用了相同步骤代码可以将以上代码封装成一个hook

封装猜你喜欢组件hook:

src/hoos/useCustomGuess.ts 或存放到 src/composables文件夹下

js
import type { CustomGuessInstance } from '@/types/components'
import { ref } from 'vue'
// @ts-ignore
import { throttle, debounce } from 'lodash'

export default function () {
  /**获取猜你喜欢子组件的实例对象,并传入子组件实例类型*/
  const guessRef = ref<CustomGuessInstance>()
  /**控制scroll-view组件的下拉刷新状态 */
  let refreshTrigger = ref(false)
  
  /**scroll-view 组件上拉触底事件(节流)*/
  const onScrolltolower = throttle(() => {
    // 调用猜你喜欢组件的加载更多方法
    guessRef.value?.getMore()
  }, 1000)
  
  /** scroll-view 组件的下拉刷新事件(防抖)*/
  const onScrollRefresh = debounce(async () => {
    // 设置正在下拉刷新
    refreshTrigger.value = true
    // 调用猜你喜欢组件的重置数据方法
    await guessRef.value?.resetData()
    // 关闭下拉刷新
    refreshTrigger.value = false
  }, 500)
  
  // 将数据和方法暴露出去
  return { guessRef, refreshTrigger, onScrolltolower, onScrollRefresh }
}

hook本质为一个setup函数,必须使用 usexxx的命名方式

在页面组件中使用:

js
// 1.导入
import useCustomGuess from '@/hooks/useCustomGuess'
// 2.获取猜你喜欢hook中的数据
const { guessRef, refreshTrigger, onScrolltolower, onScrollRefresh } =
  useCustomGuess()
// 3.直接使用导入的属性、方法

在首页和我的页面中进行导入使用,替换原本的代码

Tips:在不同的页面中可以按需进行导入

1.4 自定义导航栏优化

注意:使用自定义导航时,如果不在 scroll-view 顶部状态栏设置背景色则在上拉滚动时会无法看清状态栏

Tips:根据不同机型距离手机顶部的安全距离为页面状态栏设置背景色,且在上拉一定距离后展示默认导航栏效果,增强用户体验

当用户上拉页面后,为页面动态添加一个默认导航栏的效果

用户返回到顶部时,隐藏添加的默认导航栏展示自定义导航栏

image-20240728134711114image-20240728134814749image-20240728134337781

vue
<script>
+// 是否展示顶部固定导航栏
+const showCustomNav = ref(false)

/* 自定义导航栏优化 */
+const onScroll: UniHelper.ScrollViewOnScroll = (event) => {
+  if (event.detail.scrollTop > 200) {  // 当下拉距离超过200px时显示,否则进行隐藏
+    showCustomNav.value = true
+  } else {
+    showCustomNav.value = false
+  }
+}
</script>
<template>
+  <view class="container">
+    <view class="custom-nav" :style="`padding-top: ${safeAreaInsets?.top}px`">
+      <view class="title" v-show="showCustomNav">个人中心</view>
+    </view>
    <scroll-view
     class="viewport"
      scroll-y
      enable-back-to-top
      refresher-enabled
      refresher-background="#00c09d"
      refresher-default-style="white"
      :refresher-triggered="refreshTrigger"
      @refresherrefresh="onScrollRefresh"
      @scrolltolower="onScrolltolower"
+      @scroll="onScroll"
    >
    ....
    </scroll-view>
  </view>
</template/>
<style lang="scss">
.container {
  height: 100%;
  display: flex;
  flex-direction: column;
}
.custom-nav {
  background-color: #00c6a2;
  .title {
    height: 65rpx;
    color: white;
    font-size: 36rpx;
    text-align: center;
  }
}
.viewport{
 flex: 1;
}
</style>
Tips:scroll-view+ felx布局实现 固定定位效果

Tips:scroll-view+ felx布局实现 固定定位效果

vue
<template>
  <view class="container">
    <!-- 会固定到顶部 -->
    <view class="custom-nav" :style="`padding-top: ${safeAreaInsets?.top}px`">
      <view class="title" v-show="showCustomNav">个人中心</view>+    
    </view>
    <!-- 滚动容器 -->
    <scroll-view
     class="viewport"
      scroll-y
    >
    ....
    </scroll-view>
  </view>
</template>

<style lang="scss">
.container {
  height: 100%;
  display: flex; // 父盒子开启弹性布局
  flex-direction: column; // 子元素纵向排布
}
.custom-nav {
  background-color: #00c6a2;
  .title {
    height: 65rpx;
    color: white;
    font-size: 36rpx;
    text-align: center;
  }
}
.viewport{
  flex:1;  // 滚动容器占剩余的全部部分
}
</style>

2. 会员设置页

2.1 新建分包页面

会员模块的二级页面,按模块处理成分包页面,有以下好处:

  1. 按模块管理页面,方便项目维护。
  2. 减少主包体积,用到的时候再加载分包,属于性能优化解决方案。
Tips:小程序分包

Tips:小程序分包

  • 小程序分包:将小程序的代码分割成多个部分,分别打包成多个小程序包,减少小程序的加载时间,提高用户体验,一般将不常用的非 tabBar 页面进行分包设置
  • 分包预下载:在小程序启动时只加载主包资源,当进入分包页面时才加载分包资源,可以通过设置分包预下载在进入指定页面时,指定加载分包资源
新建分包页面

通过 VS Code 插件 uni-create-view 可以快速新建分包页面,自动配置分包路由

一般将分包放在一个独立的文件夹下 eg:src/pagesMember(会员相关的分包页面)

新建分包页面后会自动在 pages.json中配置当前分包规则

json
{
  ...
  // 分包加载规则
  "subPackages": [
    {
      // 子包的根目录
      "root": "pagesMember",
      // 分包中页面的路径
      "pages": [
        {
          "path": "settings/settings",
          "style": {
            "navigationBarTitleText": "设置"
          }
        }
      ]
    }
  ]
}

注意:

​ 在其他页面中跳转到分包页面需要在路径前加上分包路径 eg: /pagesMember/settings/settings

2.2 分包预下载

当用户进入【我的】页面时,由框架自动预下载【会员模块】的分包,提升进入后续分包页面时的启动速度

配置分包预下载:src/pages.json

json
{
  ...
  // 分包加载规则
  "subPackages": [
    {
      // 子包的根目录
      "root": "pagesMember",
      // 页面路径和窗口表现
      "pages": [
        {
          "path": "settings/settings",
          "style": {
            "navigationBarTitleText": "设置"
          }
        }
      ]
    }
  ],
  // 分包预下载规则
  "preloadRule": {
    // 页面路径,进入哪个页面进行分包预下载
    "/pages/my/my": {
      // 在指定网络下预下载,"all" 不限网络,"wifi" 仅 wifi 下预下载
      "network": "all",
      // 进入页面后预下载分包的 root 或 name __APP__ 表示主包
      "packages": ["pagesMember"]
    }
  }
}

2.2 静态结构

设置页:src/pagesMember/settings/settings.vue

vue
<script setup lang="ts">
//
</script>

<template>
  <view class="viewport">
    <!-- 列表1 -->
    <view class="list" v-if="true">
      <navigator url="/pagesMember/address/address" hover-class="none" class="item arrow">
        我的收货地址
      </navigator>
    </view>
    <!-- 列表2 -->
    <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>
    <!-- 列表3 -->
    <view class="list">
      <navigator hover-class="none" class="item arrow" url=" ">关于小兔鲜儿</navigator>
    </view>
    <!-- 操作按钮 -->
    <view class="action">
      <view class="button">退出登录</view>
    </view>
  </view>
</template>

<style lang="scss">
page {
  background-color: #f4f4f4;
}
.viewport {
  padding: 20rpx;
}
/* 列表 */
.list {
  padding: 0 20rpx;
  background-color: #fff;
  margin-bottom: 20rpx;
  border-radius: 10rpx;
  .item {
    line-height: 90rpx;
    padding-left: 10rpx;
    font-size: 30rpx;
    color: #333;
    border-top: 1rpx solid #ddd;
    position: relative;
    text-align: left;
    border-radius: 0;
    background-color: #fff;
    &::after {
      width: auto;
      height: auto;
      left: auto;
      border: none;
    }
    &:first-child {
      border: none;
    }
    &::after {
      right: 5rpx;
    }
  }
  .arrow::after {
    content: '\e6c2';
    position: absolute;
    top: 50%;
    color: #ccc;
    font-family: 'erabbit' !important;
    font-size: 32rpx;
    transform: translateY(-50%);
  }
}
/* 操作按钮 */
.action {
  text-align: center;
  line-height: 90rpx;
  margin-top: 40rpx;
  font-size: 32rpx;
  color: #333;
  .button {
    background-color: #fff;
    margin-bottom: 20rpx;
    border-radius: 10rpx;
  }
}
</style>

2.3 退出登录

设置页需实现以下业务:

  1. 退出登录,清理用户信息,返回上一页。
  2. 根据登录状态,按需展示页面内容。

参考效果:

登录状态

实现步骤:

image-20240728173921500

  1. 用户点击退出按钮,显示模态框询问是否确定退出登录
  2. 用户点击取消:无需操作
  3. 用户点击确定:清除用户相关仓库和本地存储中的数据
  4. 返回上一页
  5. 设置页面的内容根据用户是否登录进行条件渲染

参考代码

src/pagesMember/settings/settings.vue

vue
<script setup lang="ts">
import { useMemberStore } from '@/stores/modules/member'

// 获取用户仓库对象
const memberStore = useMemberStore()
// 点击退出按钮的事件回调
const onLogout = async () => {
  // 显示确认框,并获取用户是否点击确定按钮 wx.showModal支持Promise风格调用
  const { confirm } = await uni.showModal({
    title: '提示',
    content: '是否退出登录?',
    confirmColor: '#61abef',
  })
  // 点击取消按钮,不作人任何处理
  if (!confirm) return
  // 点击确定按钮
  // 清除用户仓库和本地存储中的数据
  memberStore.clearProfile() // 清除用户仓库数据
  // 返回上一级页面
  uni.navigateBack()
}
</script>

<template>
  <view class="viewport">
    <!-- 列表1 -->
    <view class="list" v-if="memberStore.profile">
      <navigator url="./address/address" hover-class="none" class="item arrow">
        我的收货地址
      </navigator>
    </view>
    <!-- 列表2 -->
    <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>
    <!-- 列表3 -->
    <view class="list">
      <navigator hover-class="none" class="item arrow" url=" ">关于小兔鲜儿</navigator>
    </view>
    <!-- 操作按钮 -->
    <view class="action" v-if="memberStore.profile">
      <view @tap="onLogout" class="button">退出登录</view>
    </view>
  </view>
</template>

3. 会员信息页

用户可以对会员信息进行更新操作,涉及到表单数据提交、图片读取、文件上传等知识点。

会员信息页

3.1 静态结构

会员信息页,作为会员的分包页面

新建 uniapp 分包页面:src/pagesMember/profile/profile.vue

vue
<script setup lang="ts">
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
</script>

<template>
  <view class="viewport">
    <!-- 导航栏 -->
    <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
      <navigator open-type="navigateBack" class="back icon-left" hover-class="none"></navigator>
      <view class="title">个人信息</view>
    </view>
    <!-- 头像 -->
    <view class="avatar">
      <view class="avatar-content">
        <image class="image" src="" data-missing=" " mode="aspectFill" />
        <text class="text">点击修改头像</text>
      </view>
    </view>
    <!-- 表单 -->
    <view class="form">
      <!-- 表单内容 -->
      <view class="form-content">
        <view class="form-item">
          <text class="label">账号</text>
          <text class="account">账号名</text>
        </view>
        <view class="form-item">
          <text class="label">昵称</text>
          <input class="input" type="text" placeholder="请填写昵称" value="" />
        </view>
        <view class="form-item">
          <text class="label">性别</text>
          <radio-group>
            <label class="radio">
              <radio value="男" color="#27ba9b" :checked="true" />

            </label>
            <label class="radio">
              <radio value="女" color="#27ba9b" :checked="false" />

            </label>
          </radio-group>
        </view>
        <view class="form-item">
          <text class="label">生日</text>
          <picker
            class="picker"
            mode="date"
            start="1900-01-01"
            :end="new Date()"
            value="2000-01-01"
          >
            <view v-if="false">2000-01-01</view>
            <view class="placeholder" v-else>请选择日期</view>
          </picker>
        </view>
        <view class="form-item">
          <text class="label">城市</text>
          <picker class="picker" mode="region" :value="['广东省', '广州市', '天河区']">
            <view v-if="false">广东省广州市天河区</view>
            <view class="placeholder" v-else>请选择城市</view>
          </picker>
        </view>
        <view class="form-item">
          <text class="label">职业</text>
          <input class="input" type="text" placeholder="请填写职业" value="" />
        </view>
      </view>
      <!-- 提交按钮 -->
      <button class="form-button">保 存</button>
    </view>
  </view>
</template>

<style lang="scss">
page {
  background-color: #f4f4f4;
}
.viewport {
  display: flex;
  flex-direction: column;
  height: 100%;
  background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/order_bg.png);
  background-size: auto 420rpx;
  background-repeat: no-repeat;
}
// 导航栏
.navbar {
  position: relative;
  .title {
    height: 40px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 16px;
    font-weight: 500;
    color: #fff;
  }
  .back {
    position: absolute;
    height: 40px;
    width: 40px;
    left: 0;
    font-size: 20px;
    color: #fff;
    display: flex;
    justify-content: center;
    align-items: center;
  }
}
// 头像
.avatar {
  text-align: center;
  width: 100%;
  height: 260rpx;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  .image {
    width: 160rpx;
    height: 160rpx;
    border-radius: 50%;
    background-color: #eee;
  }
  .text {
    display: block;
    padding-top: 20rpx;
    line-height: 1;
    font-size: 26rpx;
    color: #fff;
  }
}
// 表单
.form {
  background-color: #f4f4f4;
  &-content {
    margin: 20rpx 20rpx 0;
    padding: 0 20rpx;
    border-radius: 10rpx;
    background-color: #fff;
  }
  &-item {
    display: flex;
    height: 96rpx;
    line-height: 46rpx;
    padding: 25rpx 10rpx;
    background-color: #fff;
    font-size: 28rpx;
    border-bottom: 1rpx solid #ddd;
    &:last-child {
      border: none;
    }
    .label {
      width: 180rpx;
      color: #333;
    }
    .account {
      color: #666;
    }
    .input {
      flex: 1;
      display: block;
      height: 46rpx;
    }
    .radio {
      margin-right: 20rpx;
    }
    .picker {
      flex: 1;
    }
    .placeholder {
      color: #808080;
    }
  }
  &-button {
    height: 80rpx;
    text-align: center;
    line-height: 80rpx;
    margin: 30rpx 20rpx;
    color: #fff;
    border-radius: 80rpx;
    font-size: 30rpx;
    background-color: #27ba9b;
  }
}
</style>
tips:picker选择器

tips:picker选择器

从底部弹起的滚动选择器。支持五种选择器,通过mode来区分,分别是普通选择器,多列选择器,时间选择器,日期选择器,省市区选择器,默认是普通选择器

html
<picker mode="region|date|time|multiSelector|selector"></picker>

3.2 获取会员信息

需要登录后才能获取用户个人信息,在 项目起步 模块已封装请求拦截器,拦截器中自动添加 token ,无需再手动添加。

接口调用

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426935

接口封装

src/services/profile.ts

ts
import type { userInfoResponse } from '@/types/member'
import { http } from '@/utils/http'
/**
 * 获取用户信息的接口方法
 */
export const getUserInfoAPI = () => http.get<userInfoResponse>('/member/profile')

类型声明

src/types/profile.d.ts

ts
/** 获取用户信息接口返回的数据类型 */
export interface UserInfoResponse {
  /** 用户Id */
  id: string
  /** 头像 */
  avatar: string
  /** 昵称 */
  nickname: string | null
  /** 账号名称 */
  account: string
  /**性别 */
  gender: '女' | '男' | null
  /** 出生日期 */
  birthday: string | null
  /** 省市区的名称 */
  fullLocation: string
  /**职业*/
  profession: string | null
}

3.3 渲染会员信息

略....

注意:当返回的数据为 null 时,要进行替换

html
<input :value="userInfo?.nickname || ''"/>

3.4 更新会员头像

  1. 通过 uni.chooseMedia() 读取用户相册的照片或者拍照
  2. 或通过 button设置按钮open-type=chooseAvatar使用开放能力获取用户微信头像(推荐)
  3. 通过 uni.uploadFile() 上传用户图片实现持久存储(获取的头像/照片的路径为临时路径)

image-20240728203111948image-20240728203013151

接口信息

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426933

实现步骤:

image-20240728203324643

  1. 使用 button组件包裹 image组件并设置 open-type=chooseAvatar @chooseavatar事件回调
  2. @chooseavatar事件回调中获取临时头像地址
  3. 上传到服务器获取永久头像地址
  4. 更新头像

参考代码

封装上传头像的接口:

src/utils/http.ts

js
......
// 简化请求函数
export const http = {
  ......
  delete: <T>(url: string, data = {}, config = {}) => {
    ......
  },
  // 上传文件
  upload: <T>({ url = '', filePath = '', name = 'file', config = {} }) => {
    return new Promise<Data<T>>((resolve, reject) => {
      uni.uploadFile({
        ...config,
        // url, 接口暂时不通
        filePath,
        name,
        // 使用尚硅谷上传头像接口
        url: 'https://gmall-prod.atguigu.cn/mall-api' + url,
        header: {
          token:
         'eyJhbGciOiJIUzUxMiIsInppcCI6IkdaSVAifQ.H4sIAAAAAAAAAKtWKi5NUrJScgwN8dANDXYNUtJRSq0oULIyNDcysjQxtLQ011EqLU4t8kwBiuko5SXmpgKVP9uy6nnjaiBSqgUAA0qU4EIAAAA.G4QL8RhgFTDlBkPu32O2swNN4MIbMl1wttdNpt2bYhw86iZN1ppkNWvZvsFS0mXpcR0p6C7Q821FfnXOhbohEQ',
        },
        // 上传成功
        async success(res: UniApp.UploadFileSuccessCallbackResult) {
          // 判断响应状态码
          if (res.statusCode >= 200 && res.statusCode < 300) {
            // 状态码2xx
            // 2.1 提取核心数据 res.data
            resolve(JSON.parse(res.data as string))
          } else {
            // 其他错误
            uni.showToast({
              title: JSON.parse(res.data as string).msg || '请求错误',
              icon: 'none',
            })
            reject(res)
          }
        },
        // 请求失败(网络错误)
        fail(err) {
          uni.showToast({
            title: '请检查网络连接',
            icon: 'none',
            mask: true,
          })
          reject(err)
        },
      })
    })
  },
}

注意:

  • uni.uploadFile()请求参数中的name字段的值由后端指定
  • 接口返回的数据为 string格式需要使用 JSON.parse()进行转换

src/services/profile.ts

js
/**
 * 上传头像的接口方法
 * @param filePath 临时头像地址
 */
export const uploadProfileAvatarAPI = (filePath: string) =>
  http.upload({
    // url: '/member/profile/avatar', // 接口不通
    url: '/fileUpload', // 尚硅谷上传头像接口
    filePath: filePath,
    name: 'file',
  })

src/pagesMember/profile/profile.vue

vue
<script>
// 更换头像的回调
const onChooseAvatar: UniHelper.ButtonOnChooseavatar = async (event) => {
  // 调用上传文件的接口方法
  const result: any = await uploadProfileAvatarAPI(event.detail.avatarUrl)
  if (result.code === 200) {
    // 更新当前页面头像
    userInfo.value!.avatar = result.data
    // 更新仓库中用户头像
    memberStore.profile!.avatar = result.data
    // 提示用户更新成功
    uni.showToast({
      title: '更新头像成功',
      icon: 'success',
    })
  } else {
    uni.showToast({
      title: '更新头像失败',
      icon: 'error',
    })
  }
}
</script>
<template>
  <!-- 头像 -->
    <view class="avatar">
+      <button
+        class="avatar-button"
+        open-type="chooseAvatar"
+        @chooseavatar="onChooseAvatar"
      >
        <image class="image" :src="" data-missing="userInfo" />
+      </button>
      <text class="text">点击修改头像</text>
    </view>
</template>
<style>
.avatar-button {
+  background-color: transparent;
+  &::after {
+    border: none;
+  }
  image {
    width: 160rpx;
    height: 160rpx;
    border-radius: 50%;
  }
}
</style>
Tips:上传文件

Tips:上传文件

Tips:使用 button 包裹 image的样式设置

Tips:使用 button 包裹 image的样式设置

vue
<template>
<button
  class="avatar-button"
  open-type="chooseAvatar"
  @chooseavatar="onChooseAvatar"
>
  <image class="image" :src="" data-missing="userInfo" />
</button>
</template>
<style>
.avatar-button {
+  background-color: transparent;
+  &::after {
+    border: none;
+  }
  image {
    width: 160rpx;
    height: 160rpx;
    border-radius: 50%;
  }
}
</style>

3.5 更新表单信息

涉及到 <input><radio><picker> 表单组件的数据收集。

接口信息

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426932

接口封装

ts
import type { UpdateUserInfoParams, UserInfoResponse } from '@/types/profile'
import { http } from '@/utils/http'
/**
 * 获取用户信息的接口方法
 */
export const getUserInfoAPI = () => http.get<UserInfoResponse>('/member/profile')
/**
 * 上传头像的接口方法
 * @param filePath 临时头像地址
 */
export const uploadProfileAvatarAPI = (filePath: string) =>
  http.upload({
    // url: '/member/profile/avatar', // 接口不通
    url: '/fileUpload', // 尚硅谷上传头像接口
    filePath: filePath,
    name: 'file',
  })
/**
 * 更新用户用户信息的接口方法
 * @param data 请求参数
 * @returns 返回用户信息
 */
+export const updateUserInfoAPI = (data: UpdateUserInfoParams) =>
+  http.put<UserInfoResponse>('/member/profile', data)

类型声明

类型声明封装升级(可选),提取用户信息通用部分,再复用类型。

ts
/**通用用户信息类型 */
+export interface CommonUserInfo {
+  /** 昵称 */
+  nickname?: string
+  /**性别 */
+  gender?: '女' | '男' 
+  /** 出生日期 */
+  birthday?: string 
+  /**职业*/
+  profession?: string
+}
/** 获取用户信息接口返回的数据类型 */
export interface UserInfoResponse extends CommonUserInfo {
  /** 用户Id */
  id: string
  /** 头像 */
  avatar: string
  /** 账号名称 */
  account: string
  /** 省市区的名称 */
  fullLocation?: string
}
+export interface UpdateUserInfoParams extends CommonUserInfo {
+  /**省份编码 */
+  provinceCode?: string  // 可选参数
+  /** 城市编码*/  
+  cityCode?: string  // 可选参数
+  /**区/县编码 */
+  countyCode?: string  // 可选参数
+}
Tips:接口继承

Tips:接口继承

A 接口继承另外 B 接口,会将 B 接口中的类型全部添加到 A 接口中

实现步骤:

  • <input> 组件使用 v-model 收集数据
  • <radio-group> 组件使用 @change 事件收集数据
  • <picker> 组件使用 @change 事件收集数据
  • 提交表单,更新会员信息,Store 中的昵称记得修改,用于会员中心页展示

参考代码:

vue
<script setup lang="ts">
......

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 用户信息
const userInfo = ref<UserInfoResponse>({} as UserInfoResponse)
// 获取用户仓库
const memberStore = useMemberStore()
// 省市区编码(不涉及页面数据展示,无需使用响应式定义)
let reginCode: string[] = []

// 获取用户信息
const getUserInfo = async () => {
  const result = await getUserInfoAPI()
  // 存储用户信息
  userInfo.value = result.result
}
// 更换头像的回调
const onChooseAvatar: UniHelper.ButtonOnChooseavatar = async (event) => {
  // 调用上传文件的接口方法
  const result: any = await uploadProfileAvatarAPI(event.detail.avatarUrl)
  if (result.code === 200) {
    // 更新当前页面头像
    userInfo.value!.avatar = result.data
    // 更新仓库中用户头像
    memberStore.profile!.avatar = result.data
    // 提示用户更新成功
    uni.showToast({
      title: '更新头像成功',
      icon: 'success',
    })
  } else {
    uni.showToast({
      title: '更新头像失败',
      icon: 'error',
    })
  }
}
// 更改性别的回调
const onGenderChange: UniHelper.RadioGroupOnChange = (event) => {
  // 修改用户信息
  userInfo.value.gender = event.detail.value as '男' | '女'
}
// 选择日期的回调
const onSelectData: UniHelper.DatePickerOnChange = (event) => {
  // 存储用户选择的出生日期
  userInfo.value.birthday = event.detail.value
}
// 更改地址的回调
const onSelectRegion: UniHelper.RegionPickerOnChange = (event) => {
  // 存储用户选择的省市区,用于页面展示
  userInfo.value.fullLocation = event.detail.value.join(' ')
  // 存储省市区编码,用于更新数据
  reginCode = event.detail.code as string[]
}
// 保存用户信息
const onSave = async () => {
  // 获取参数
  const { nickname, profession, gender, birthday } = userInfo.value
  // 发请求更新用户信息,会返回最新的用数据
  const result = await updateUserInfoAPI({
    nickname,
    profession,
    gender,
    birthday,
    provinceCode: reginCode[0],
    cityCode: reginCode[1],
    countyCode: reginCode[2],
  })
  // 更新当前页面用户信息
  userInfo.value = result.result
  // 修改用户仓库中的用户数据
  memberStore.profile!.nickname = result.result.nickname as string
  // 提示成功
  await uni.showToast({
    title: '修改成功',
    icon: 'success',
  })
  // 成功提示延迟0.5s后返回上一级页面
  setTimeout(() => {
    // 返回上一级页面
    uni.navigateBack()
  }, 500)
}

onLoad(() => {
  // 获取用户信息
  getUserInfo()
})
</script>
<template>
  <view class="viewport">
    <!-- 导航栏 -->
    <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
      <navigator
        open-type="navigateBack"
        class="back icon-left"
        hover-class="none"
      ></navigator>
      <view class="title">个人信息</view>
    </view>
    <!-- 头像 -->
    <view class="avatar">
      <button
        class="avatar-button"
        open-type="chooseAvatar"
        @chooseavatar="onChooseAvatar"
      >
        <image class="image" :src="" data-missing="userInfo.avatar" />
      </button>
      <text class="text">点击修改头像</text>
    </view>
    <!-- 表单 -->
    <view class="form">
      <!-- 表单内容 -->
      <view class="form-content">
        <view class="form-item">
          <text class="label">账号</text>
          <text class="account">{{ userInfo.account }}</text>
        </view>
        <view class="form-item">
          <text class="label">昵称</text>
          <input
            class="input"
            type="nickname"
            placeholder="请填写昵称"
            v-model="userInfo.nickname"
          />
        </view>
        <view class="form-item">
          <text class="label">性别</text>
          <radio-group @change="onGenderChange">
            <label class="radio">
              <radio
                value="男"
                color="#27ba9b"
                :checked="userInfo.gender === '男'"
                style="transform: scale(0.7)"
              />

            </label>
            <label class="radio">
              <radio
                value="女"
                color="#27ba9b"
                :checked="userInfo.gender === '女'"
                style="transform: scale(0.7)"
              />

            </label>
          </radio-group>
        </view>
        <view class="form-item">
          <text class="label">出生日期</text>
          <picker
            class="picker"
            mode="date"
            start="1900-01-01"
            :end="new Date()"
            :value="userInfo.birthday"
            @change="onSelectData"
          >
            <view v-if="userInfo.birthday">{{ userInfo.birthday }}</view>
            <view class="placeholder" v-else>请选择日期</view>
          </picker>
        </view>
        <view class="form-item">
          <text class="label">城市</text>
          <picker
            class="picker"
            mode="region"
            :value="userInfo.fullLocation?.split(' ')"
            @change="onSelectRegion"
          >
            <view v-if="userInfo.fullLocation">{{ userInfo.fullLocation }}</view>
            <view class="placeholder" v-else>请选择城市</view>
          </picker>
        </view>
        <view class="form-item">
          <text class="label">职业</text>
          <input
            class="input"
            type="text"
            placeholder="请填写职业"
            v-model="userInfo.profession"
          />
        </view>
      </view>
      <!-- 提交按钮 -->
      <button class="form-button" @tap="onSave">保 存</button>
    </view>
  </view>
</template>
Tips:定义对象类型的数据

Tips:定义对象类型的数据

问题:定义对象类型的数据如果不指定初始值 TS可能推断其为 undefined在使用 v-model或对数据进行操作时就会因为属性可能为 undefined而报错(eg:v-model不允许绑定的值为 undefined

image-20240728223649270

解决方法:

  1. 在定义数据时为其每个字段指定其初始值

    js
    const userInfo = ref<UserInfoResponse>({
      id:'',
      nickName:'',
      birthday:'',
      ......
    })
  2. 定义数据时设置初始值为空对象并指定空对象的类型

    js
    const userInfo = ref<UserInfoResponse>({} as UserInfoResponse)
Tips:消息提示与页面跳转

Tips:消息提示与页面跳转

uni.showToastuni.navigateBack()均为异步函数,同时使用时会出现未展示提示消息就进行页面跳转的情况,await等待提示框展示后代码才继续向下执行,为 uni.navigateBack()设置一个定时器,等待消息提示延迟 0.5s后在进行页面跳转

js
  // 提示成功
  await uni.showToast({  // 等待消息提示框展示后代码继续向下执行
    title: '修改成功',
    icon: 'success',
  })
  // 成功提示延迟0.5s后返回上一级页面
  setTimeout(() => {
    // 返回上一级页面
    uni.navigateBack()
  }, 500)

九、小兔鲜儿 - 地址模块

  • 获取不同类型的表单数据
  • 动态设置导航栏的标题
  • 使用 uni-ui 组件库的组件
  • 完成收货地址的增删改查的功能

1. 准备工作

1.1 静态结构

地址模块共两个页面:地址管理页,地址表单页 ,划分到会员分包中。

picture_31

地址管理页

src/pagesMember/address/address.vue

vue
<script setup lang="ts">
//
</script>

<template>
  <view class="viewport">
    <!-- 地址列表 -->
    <scroll-view class="scroll-view" scroll-y>
      <view v-if="true" class="address">
        <view class="address-list">
          <!-- 收货地址项 -->
          <view class="item">
            <view class="item-content">
              <view class="user">
                黑马小王子
                <text class="contact">13111111111</text>
                <text v-if="true" class="badge">默认</text>
              </view>
              <view class="locate">广东省 广州市 天河区 黑马程序员</view>
              <navigator
                class="edit"
                hover-class="none"
                :url="`/pagesMember/address-form/address-form?id=1`"
              >
                修改
              </navigator>
            </view>
          </view>
          <!-- 收货地址项 -->
          <view class="item">
            <view class="item-content">
              <view class="user">
                黑马小公主
                <text class="contact">13222222222</text>
                <text v-if="false" class="badge">默认</text>
              </view>
              <view class="locate">北京市 北京市 顺义区 黑马程序员</view>
              <navigator
                class="edit"
                hover-class="none"
                :url="`/pagesMember/address-form/address-form?id=2`"
              >
                修改
              </navigator>
            </view>
          </view>
        </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>

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

地址表单页

src/pagesMember/address-form/address-form.vue

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

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

<template>
  <view class="content">
    <form>
      <!-- 表单内容 -->
      <view class="form-item">
        <text class="label">收货人</text>
        <input class="input" placeholder="请填写收货人姓名" value="" />
      </view>
      <view class="form-item">
        <text class="label">手机号码</text>
        <input class="input" placeholder="请填写收货人手机号码" value="" />
      </view>
      <view class="form-item">
        <text class="label">所在地区</text>
        <picker class="picker" mode="region" value="">
          <view v-if="false">广东省 广州市 天河区</view>
          <view v-else class="placeholder">请选择省/市/区(县)</view>
        </picker>
      </view>
      <view class="form-item">
        <text class="label">详细地址</text>
        <input class="input" placeholder="街道、楼牌号等信息" value="" />
      </view>
      <view class="form-item">
        <label class="label">设为默认地址</label>
        <switch class="switch" color="#27ba9b" :checked="true" />
      </view>
    </form>
  </view>
  <!-- 提交按钮 -->
  <button 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>

1.2 动态设置标题

新建地址修改地址 使用同一个地址表单页面,需要根据页面参数 id 动态设置页面标题。

vue
<script setup lang="ts">
// 获取页面路由参数(有id为修改无id为新增)
const query = defineProps<{
  id?: string
}>()
// 动态设置标题
uni.setNavigationBarTitle({
  title: query.id ? '修改收货地址' : '新增收货地址',
})
</script>

2. 新建收货地址

新用户没有收货地址,先完成新建地址,新建成功返回地址管理页。

主要功能:前端收集表单的数据,提交表单给后端

添加地址

2.1 接口封装

接口调用

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426957

接口封装

src/services/address.ts

ts
import type { AddressParams } from '@/types/address'
import { http } from '@/utils/http'
/**
 * 新增收货地址接口方法
 * @param data 请求参数新增地址信息
 * @returns id 新增地址id
 */
export const addAddressAPI = (data: AddressParams) =>
  http.post<{ id: number }>('/member/address', data)

类型声明

src/types/address.d.ts

ts
/**新增/修改地址请求参数类型 */
export interface AddressParams {
  /**收货人姓名*/
  receiver: string
  /**联系方式 */
  contact: string
  /**所在省份编码 */
  provinceCode: string
  /**所在城市编码 */
  cityCode: string
  /**所在区/县编码 */
  countyCode: string
  /**详细地址 */
  address: string
  /** 是否为默认,1为是,0为否 */
  isDefault: number
}

2.2 实现步骤

地址表单页,input 组件通过 v-model 获取数据,其他表单组件结合 @change 事件获取

vue
<script setup lang="ts">
import { addAddressAPI } from '@/services/address'
import { ref } from 'vue'
//@ts-ignore
import { throttle } from 'lodash'

// 表单数据
const form = ref({
  receiver: '', // 收货人
  contact: '', // 联系方式
  fullLocation: '', // 省市区(前端展示)
  provinceCode: '', // 省份编码(后端参数)
  cityCode: '', // 城市编码(后端参数)
  countyCode: '', // 区/县编码(后端参数)
  address: '', // 详细地址
  isDefault: 0, // 默认地址,1为是,0为否
})
// 获取页面路由参数(有id为修改无id为新增)
const query = defineProps<{
  id?: string
}>()
// 动态设置标题
uni.setNavigationBarTitle({
  title: query.id ? '修改收货地址' : '新增收货地址',
})
// picker选择器选择地址后的回调
const onReginSelect: UniHelper.RegionPickerOnChange = (event) => {
  // 获取选择的地址编码和地址
  const { code, value } = event.detail
  form.value.provinceCode = code![0]
  form.value.cityCode = code![1]
  form.value.countyCode = code![2]
  form.value.fullLocation = value.join(' ')
}
// switch选择器改变时的回调
const onSwitchChange: UniHelper.SwitchOnChange = (event) => {
  // 更新默认选择的值
  form.value.isDefault = event.detail.value ? 1 : 0
}
// 点击保存按钮的事件回调(关键按钮--节流)
const onSave = throttle(async () => {
  // 发请求更新数据
  await addAddressAPI(form.value)
  // 提示
  await uni.showToast({
    title: '更新成功',
    icon: 'success',
  })
  // 返回上一页(提示延迟0.5s)
  setTimeout(() => {
    uni.navigateBack()
  }, 500)
}, 1500)
</script>

<template>
  <view class="content">
    <form>
      <!-- 表单内容 -->
      <view class="form-item">
        <text class="label">收货人</text>
        <input
          class="input"
          placeholder="请填写收货人姓名"
          v-model="form.receiver"
        />
      </view>
      <view class="form-item">
        <text class="label">手机号码</text>
        <input
          class="input"
          placeholder="请填写收货人手机号码"
          v-model="form.contact"
        />
      </view>
      <view class="form-item">
        <text class="label">所在地区</text>
        <picker
          class="picker"
          mode="region"
          @change="onReginSelect"
          :value="form.fullLocation.split(' ')"
        >
          <view v-if="form.fullLocation">{{ form.fullLocation }}</view>
          <view v-else class="placeholder">请选择省/市/区(县)</view>
        </picker>
      </view>
      <view class="form-item">
        <text class="label">详细地址</text>
        <input
          class="input"
          placeholder="街道、楼牌号等信息"
          v-model="form.address"
        />
      </view>
      <view class="form-item">
        <label class="label">设为默认地址</label>
        <switch
          class="switch"
          color="#27ba9b"
          style="transform: scale(0.7)"
          @change="onSwitchChange"
          :checked="form.isDefault === 1"
        />
      </view>
    </form>
  </view>
  <!-- 提交按钮 -->
  <button class="button" @click="onSave">保存并使用</button>
</template>
Tips:

Tips:

要想使用 v-model 收集对象类型的数据,最好为其设置初始值,v-model绑定的值不能为 undefined

2.3 地理定位获取地址

地理定位介绍:

小程序地理定位是指通过小程序开发平台提供的 API,来获取用户的地理位置信息。用户在使用小程序时,可以授权小程序获取自己的地理位置信息

image-20240729200758357
  1. wx.getLocation() :获取当前的地理位置
  2. wx.chooseLocation():打开地图选择位置

使用详情参考:慕尚花坊项目笔记

3. 收货地址列表

为了能及时看到新建的收货地址,需在 onShow 生命周期中获取地址列表数据

地址管理页

3.1 接口调用

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426956

接口封装

src/types/address.ts

ts
/**
 * 获取收货地址列表接口方法
 * @returns 地址列表
 */
export const getAddressListAPI = () => http.get<AddressItem[]>('/member/address')

类型声明

复用地址类型:src/types/global.d.ts

新增地址参数类型、修改地址参数类型、获取地址列表中的地址对象、获取商品信息中用户地址列表中的地址对象的类型相同,唯一区别在于是否有 id,fullLocation,因此将地址对象的类型定义为全局类型

ts
export interface AddressItem {
  /**地址id(作为请求参数时无需id,作为返回数据时需要id)*/
  id?: string // 非必须属性
  /**收货人姓名*/
  receiver: string
  /**联系方式 */
  contact: string
  /**省市区地址 */
  fullLocation?: string // 非必须属性
  /**所在省份编码 */
  provinceCode: string
  /**所在城市编码 */
  cityCode: string
  /**所在区/县编码 */
  countyCode: string
  /**详细地址 */
  address: string
  /** 是否为默认,1为是,0为否 */
  isDefault: number
}

注意:需要修改 goood.d.ts类型声明文件,并删除 address.d.ts类型声明文件,并修改已有的类型导入文件

3.2 数据渲染

地址管理页

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

const addressList = ref<AddressItem[]>([])
// 获取地址列表
const getAddressList = async () => {
  const result = await getAddressListAPI()
  // 存储地址列表
  addressList.value = result.result
}

onShow(() => {
  // 页面展示时获取地址列表数据
  getAddressList()
})
</script>

<template>
  <view class="viewport">
    <!-- 地址列表 -->
    <scroll-view class="scroll-view" scroll-y>
      <view v-if="addressList.length" class="address">
        <view class="address-list">
          <!-- 收货地址项 -->
          <view 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>
          </view>
        </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>
Tips:onShow与onLoad的区别

Tips:onShow与onLoad的区别

  • onLoad:页面初次加载时调用,只调用一次
  • onShow:页面展示时调用,点击左上角箭头返回时会调用

在使用 navigorTo()进行页面跳转后,返回原页面会触发原页面的 onShow但不会触发 onLoad

4. 修改收货地址

通过页面参数 id 来区分当前是修改地址还是新建地址

修改地址页

4.1 数据回显

修改地址之前,需要先实现数据回显,用户再进行有针对性的修改。

接口详情

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426955

接口封装

src/services/address.ts

ts
/**
 * 获取收货地址详情的接口方法
 * @param id 收货地址id(path参数)
 */
export const getAddressInfoAPI = (id: string) =>
  http.get<AddressItem>(`/member/address/${id}`)

获取数据并渲染页面

页面初始化的时候根据 id 获取地址详情,把获取的数据赋值给form,用于数据回显

vue
<script setup lang="ts">
// 表单数据 要使用v-model收集数据因此需要进行初始化数据
const form = ref<AddressItem>({
  receiver: '', // 收货人
  contact: '', // 联系方式
  fullLocation: '', // 省市区(前端展示)
  provinceCode: '', // 省份编码(后端参数)
  cityCode: '', // 城市编码(后端参数)
  countyCode: '', // 区/县编码(后端参数)
  address: '', // 详细地址
  isDefault: 0, // 默认地址,1为是,0为否
})
// 获取页面路由参数(有id为修改无id为新增)
const query = defineProps<{
  id?: string
}>()
.....
// 获取地址详情数据
const getAddressInfo = async () => {
  // 如果路由参数中有id,获取地址详情数据
  if (query.id) {
    const result = await getAddressInfoAPI(query.id!)
    // 将获取的数据存储到form中
    form.value = result.result
  }
}
......
// 页面加载
onLoad(() => {
  // 如果路由参数中有id,获取地址详情数据
  getAddressInfo()
})
</script>
Tips:query参数和path参数

Tips:query参数和 path 参数

  • path 参数:直接写在路径后 eg:/member/address/path1
  • query参数:在路径后面通过 ? 分割 eg:/member/address?id=xx&name=xx

当为get请求时可以将 query参数作为 data进行传递,代替在 url上直接进行拼接

4.2 更新地址

将用户修改后的地址信息重新发送到服务端进行存储

接口详情

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426954

接口封装

ts
/**
 * 修改收货地址接口方法
 * @param data 修改地址信息(data中有id)
 */
export const updateAddressAPI = (data: AddressItem) =>
  http.put<{ id: number }>(`/member/address/${data.id}`, data)

提交判断

点击提交按钮后根据收集的数据form中是否有地址 id 来判断提交表单到底是新建地址还是更新地址

vue
<script setup lang="ts">
// 点击保存按钮的事件回调(关键按钮--节流)
const onSave = throttle(async () => {
  // 判断是修改还是新增(form中是否有id)
  form.value.id
    ? await updateAddressAPI(form.value)
    : await addAddressAPI(form.value)
  // 提示
  await uni.showToast({
    title: form.value.id ? '更新成功' : '新增成功',
    icon: 'success',
  })
  // 返回上一页(提示延迟0.5s)
  setTimeout(() => {
    uni.navigateBack()
  }, 500)
}, 1500)
</script>

5. 表单校验

通过 uni-ui 组件库的 uni-forms 组件实现表单校验。

表单校验

5.1 操作步骤

  1. 定义校验规则

    js
    // 定义校验规则
    +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: '请选择详细地址' }],
    +  },
    +}
  2. 修改表单结构并绑定校验规则:

    1. uni-forms 设置 rules 属性校验规则,绑定model属性值为表单数据对象,并通过 ref 获取form 组件实例对象

      html
      <uni-forms ref="formRef" :modelValue="form" :rules="rules">
    2. uni-forms-item 设置 name 属性为当前校验字段

      html
      <uni-forms-item name="contact">
  3. 提交时校验表单

    js
    // 获取表单组件实例,用于调用表单方法
    const formRef = ref<UniHelper.UniFormsInstance>() 
    await formRef.value?.validate?.()

5.2 参考代码

vue
<script setup lang="ts">
// 定义校验规则
+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?.() 
    // 校验通过后再发送请求
    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: '请填写完整信息' }) 
+  }
+}
</script>

<template>
  <view class="content">
+    <uni-forms
+      ref="formRef"
+      :modelValue="form"
+      :rules="rules"
+      border
+      :label-width="110"
+    >
      <!-- 表单内容 -->
+      <uni-forms-item name="receiver" required label="收货人">
        <input
          class="input"
          placeholder="请填写收货人姓名"
          v-model="form.receiver"
        />
      </uni-forms-item>
+      <uni-forms-item name="contact" required label="手机号码">
        <input
          class="input"
          placeholder="请填写收货人手机号码"
          v-model="form.contact"
        />
      </uni-forms-item>
+      <uni-forms-item name="fullLocation" required label="所在地区">
        <picker
          class="picker"
          mode="region"
          @change="onReginSelect"
          :value="form.fullLocation!.split(' ')"
        >
          <view v-if="form.fullLocation" class="picker-content">{{
            form.fullLocation
          }}</view>
          <view v-else class="placeholder">请选择省/市/区(县)</view>
        </picker>
      </uni-forms-item>
 +     <uni-forms-item name="address" required label="详细地址">
        <input
          class="input"
          placeholder="街道、楼牌号等信息"
          v-model="form.address"
        />
      </uni-forms-item>
      <uni-forms-item label="设为默认地址">
        <switch
          class="switch"
          color="#27ba9b"
          style="transform: scale(0.7)"
          @change="onSwitchChange"
          :checked="form.isDefault === 1"
        />
      </uni-forms-item>
    </uni-forms>
  </view>
  <!-- 提交按钮 -->
  <button class="button" @click="onSave">保存并使用</button>
</template>

6. 删除收货地址

通过 uni-ui 组件库的 uni-swipe-action 组件实现侧滑删除。

侧滑删除

6.1 侧滑组件用法

vue
<template>
  <!-- 滑动操作分区 -->
  <uni-swipe-action>
    <!-- 滑动操作项 -->
    <uni-swipe-action-item>
      <!-- 默认插槽,展示内容 -->
      <view>内容</view>
      <!-- 右侧插槽,展示左滑后右侧内容 -->
      <template #right>
        <button class="delete-button">删除</button>
      </template>
    </uni-swipe-action-item>
  </uni-swipe-action>
</template>

6.2 接口封装

接口详情

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426953

接口封装

ts
/**
 * 删除已有收货地址的接口方法
 * @param id 收货地址id(path参数)
 * @returns
 */
export const deleteAddressAPI = (id: string) =>
  http.delete<{ id: string }>(`/member/address/${id}`)

6.3 实现步骤

  1. 使用 uni-swipe-actionuni-swipe-action-item 组件修改地址列表结构,并为删除按钮添加样式

  2. 绑定删除事件,并传递当前地址 id

    html
    <template>
    <!-- 滑动操作组件 -->
    +<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>
        <!-- 滑动操作组件右侧按钮 -->
    +    <template #right>
    +      <button class="delete-button" @click="onDeleteAddress(item.id!)">
    +        删除
    +      </button>
    +    </template>
    +  </uni-swipe-action-item>
    +</uni-swipe-action>
    </template>
    <style>
      .delete-button {
        padding: 0;
        width: 100rpx;
        font-size: 28rpx;
        color: #fff;
        background-color: #d03d3d;
        border-radius: 0rpx;
        display: flex;
        justify-content: center;
        align-items: center;
      }
    </style>
  3. 事件回调中二次确认删除

  4. 删除地址,提示用户,重新获取数据

    js
    +// 点击删除地址按钮的回调
    +const onDeleteAddress = async (id: string) => {
    +  // 询问是否确定删除
    +  const { confirm } = await uni.showModal({
    +    title: '提示',
    +    content: '是否确定删除该地址?',
    +    confirmColor: '#27ba9b',
    +  })
    +  // 用户点击取消按钮
    +  if (!confirm) return
    +  // 调用删除地址API
    +  await deleteAddressAPI(id)
    +  // 提示删除成功
    +  uni.showToast({
    +    title: '删除成功',
    +    icon: 'success',
    +  })
    +  // 重新获取收货地址列表数据
    +  getAddressList()
    +}

6.4 参考代码

vue
<script setup lang="ts">
......

const addressList = ref<AddressItem[]>([])
// 获取地址列表
const getAddressList = async () => {
  const result = await getAddressListAPI()
  // 存储地址列表
  addressList.value = result.result
}
+// 点击删除地址按钮的回调
+const onDeleteAddress = async (id: string) => {
+  // 询问是否确定删除
+  const { confirm } = await uni.showModal({
+    title: '提示',
+    content: '是否确定删除该地址?',
+    confirmColor: '#27ba9b',
+  })
+  // 用户点击取消按钮
+  if (!confirm) return
+  // 调用删除地址API
+  await deleteAddressAPI(id)
+  // 提示删除成功
+  uni.showToast({
+    title: '删除成功',
+    icon: 'success',
+  })
+  // 重新获取收货地址列表数据
+  getAddressList()
+}

onShow(() => {
  // 页面展示时获取地址列表数据
  getAddressList()
})
</script>

<template>
  <view class="viewport">
    <!-- 地址列表 -->
    <scroll-view class="scroll-view" scroll-y>
      <view v-if="addressList.length" 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 class="delete-button" @click="onDeleteAddress(item.id!)">
                删除
              </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>
Tips:uni-swipe-action组件优化

Tips:uni-swipe-action组件优化

SwipeAcitonItem组件标签添存在 autoClose属性(默认为 true)可以在其他组件开启的时候,自动关闭当前组件,

注意:长列表使用会有性能问题

十、小兔鲜儿 - SKU 模块

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

1. 存货单位(SKU)

SKU 概念

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

SKU 常见于电商领域,对于前端工程师而言,更多关注 SKU 算法用户交互体验

2. 插件市场

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

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

插件市场

2.1 下载 SKU 插件

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

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

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

2. 使用 SKU 插件

组件安装到自己项目

  1. 下载示例项目ZIP
  2. 复制 vk-data-goods-sku-popupvk-data-input-number-box 到项目根目录下的 components
  3. 复制示例代码并运行体验

插件文档(部分)

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关闭组件时-

为什么插件(SKU组件)使用时无需导入?

pages.jsoneasycom 配置中,开启了自动扫描功能 "autoscan": true 开启后将会自动扫描符合 components/组件名称/组件名称.vue 目录结构的组件,实现自动导入

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

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

vk-data-goods-sku-popup.vuevk-data-input-number-box.vue 组件禁用 eslint

vue
<script>
/* eslint-disable */
// 省略组件源代码
</script>

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

3. 插件类型问题

尽管该插件未采用 TS 开发,但作者提供了详细的插件文档,我们可以依据文档为插件添加 TS 类型声明文件,从而提高项目数据校验的安全性。

3.1 类型声明文件

vk-data-goods-sku-popup.d.ts

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

4. 核心业务

4.1 渲染商品规格

使用以下两个属性:

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

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

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

// 是否打开SKU弹窗
const isShowSku = ref(false)
// SKU展示的商品信息
const localdata = ref({} as SkuPopupLocaldata)
    
// 获取商品详情数据
const getGoodsInfo = async () => {
  // 显示加载提示框
  uni.showLoading()
  const result = await getGoodsInfoAPI(query.id)
  // 存储商品详情数据
  goodsInfo.value = result.result
  // 将数据转换为SKU组件需要的格式
  localdata.value = {
    _id: goodsInfo.value.id,
    name: goodsInfo.value.name,
    goods_thumb: goodsInfo.value.mainPictures[0],
    spec_list: goodsInfo.value.specs.map((item) => {
      return {
        name: item.name,
        list: item.values,
      }
    }),
    sku_list: goodsInfo.value.skus.map((item) => {
      return {
        _id: item.id,
        goods_id: goodsInfo.value!.id,
        goods_name: goodsInfo.value!.name,
        image: item.picture,
        price: item.price * 100, // 注意:价格需要*100
        stock: item.inventory,
        sku_name_arr: item.specs.map((v) => {
          return v.valueName
        }),
      }
    }),
  }
  // 关闭加载提示框
  uni.hideLoading()
}
</script>

<template>
  <!-- SKU弹窗组件 -->
  <vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" />
  <!-- 弹窗测试 -->
  <button @tap="isShowSku = true">打开 SKU 弹窗</button>
</template>

4.2 打开弹窗交互

SKU 弹窗的按钮有三种形式需要根据点击不同的按钮展示不同的模式

vk-data-goods-sku-popupmode属性: 1:都显示 2:只显示购物车 3:只显示立即购买

image-20240730213538397image-20240730213708092
vue
<script setup lang="ts">
// SKU弹窗模式
const skuMode = ref<1 | 2 | 3>(1)

// 打开SKU弹窗
const openSkuPopup = async (mode: 1 | 2 | 3) => {
  // 重新获取商品数据
  await getGoodsInfo()
  // 根据点击的按钮不同,展示不同的SKU模式 mode 1:都显示 2:只显示购物车 3:只显示立即购买
  skuMode.value = mode
  isShowSku.value = true
}
</script>

<template>
 <!-- SKU弹窗组件 -->
  <vk-data-goods-sku-popup
    ref="skuPopup" 
    v-model="isShowSku" // 是否显示
    border-radius="20" // 圆角大小
    :z-index="990" // 层级
    :localdata="localdata" // 数据
    :mode="skuMode"  // 模式
    no-stock-text="售罄" // 无库存的提示文字
    :show-close="false" // 是否展示右上角关闭按钮
    buy-now-background-color="#27ba9b" // 购买按钮背景色
    add-cart-background-color="#ffa868" // 加入购物车按钮背景色
    :actived-style="{     // 规格按钮点击时的样式
      color: '#27ba9b',
      borderColor: '#27ba9b',
      backgroundColor: '#e9f8f5',
    }"
    @add-cart="addCart" // 点击加入购物车按钮回调
    @buy-now="buyNow" // 点击立即购买按钮回调
  ></vk-data-goods-sku-popup>

  <!-- 显示两个按钮 -->
  <text class="text ellipsis" @tap="openSkuPopup(1)"> 请选择商品规格 </text>
  <view class="buttons">
    <!-- 只显示加入购物车按钮 -->
    <view class="addcart" @tap="openSkuPopup(2)"> 加入购物车 </view>
    <!-- 只显示立即购买按钮 -->
    <view class="buynow" @tap="openSkuPopup(3)"> 立即购买 </view>
  </view>
</template>

可以根据使用文档中的属性,自定义SKU弹出框的样式

Tips:enum枚举类型

Tips:enum枚举类型

将所有可能的值枚举出来,并可以为每个值映射一个更有语意的变量,既可以作为类型,又可以访问其值

js
nemu SkuMode {
 Both = 1,
    Car = 2,
    Buy = 3
}
const mode = ref<SkuMode>(SkuMode.Both)
mode.value = SkuMode.Car

4.3 将已选规格渲染到页面

  1. 通过 ref 获取组件实例
  2. 通过 computed 计算出被选中的值,渲染到界面中

image-20240730222340181

vue
<script setup lang="ts">
// SKU组件实例对象
const skuPopupRef = ref<SkuPopupInstanceType>()

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

<template>
  <!-- 操作面板 -->
  <view class="action">
    <view class="item arrow">
      <text class="label">选择</text>
      <text class="text ellipsis" @tap="openSkuPopup(1)">
+        {{ selectArrText }}
      </text>
    </view>
  </view>
<!-- SKU弹窗组件 -->
  <vk-data-goods-sku-popup
    ref="skuPopupRef"
    v-model="isShowSku"
    border-radius="20"
    :z-index="990"
    :localdata="localdata"
    :mode="skuMode"
    no-stock-text="售罄"
    :show-close="false"
    buy-now-background-color="#27ba9b"
    add-cart-background-color="#ffa868"
    :actived-style="{
      color: '#27ba9b',
      borderColor: '#27ba9b',
      backgroundColor: '#e9f8f5',
    }"
    @add-cart="addCart"
    @buy-now="buyNow"
  ></vk-data-goods-sku-popup>
</template>

5. 封装hook

goods.vue中与 SKU模块相关的代码提取成一个 hook

src/hooks/useSkuPopup.ts

ts
import type {
  SkuPopupInstanceType,
  SkuPopupLocaldata,
} from '@/components/vk-data-goods-sku-popup/vk-data-goods-sku-popup'
import { computed, ref } from 'vue'

export default function () {
  // 是否打开SKU弹窗
  const isShowSku = ref(false)
  // SKU弹窗模式
  const skuMode = ref<1 | 2 | 3>(1)
  // SKU展示的商品信息
  const localdata = ref({} as SkuPopupLocaldata)
  // SKU组件实例对象
  const skuPopupRef = ref<SkuPopupInstanceType>()

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

  // 打开SKU弹窗
  const openSkuPopup = async (mode: 1 | 2 | 3) => {
    // 重新获取商品数据
    // await getGoodsInfo()
    // 根据点击的按钮不同,展示不同的SKU模式 mode 1:都显示 2:只显示购物车 3:只显示立即购买
    skuMode.value = mode
    isShowSku.value = true
  }
  // 加入购物车按钮
  const addCart = () => {}
  // 立即购买
  const buyNow = () => {}

  return {
    isShowSku,
    skuMode,
    localdata,
    skuPopupRef,
    selectArrText,
    openSkuPopup,
    addCart,
    buyNow,
  }
}

goods.vue中引入使用

ts
import useSkuPopup from '@/hooks/useSkuPopup'

// 使用SKU弹窗组件hook
const {
  isShowSku,
  skuMode,
  localdata,
  skuPopupRef,
  selectArrText,
  openSkuPopup,
  addCart,
  buyNow,
} = useSkuPopup()

将不同功能的代码封装成独立的 hook可以使代码更加便于管理,可以在不同的组件中导入使用提高代码的复用率

十一、小兔鲜儿 - 购物车模块

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

1. 加入购物车

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

购物车

1.1 封装接口

接口详情

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426915

参考代码

src/services/cart.ts

ts
import type { AddCartParams } from '@/types/cart'
import { http } from '@/utils/http'

/**
 * 加入购物车的接口方法
 * @param data:{skuId:string,count:number}
 * @returns
 */
export const addCartAPI = (data: AddCartParams) => http.post('/member/cart', data)

1.2 实现步骤

  1. vk-data-goods-sku-popup组件绑定 @add-cart事件
  2. 在事件回调中判断用户是否登录,未登录跳转到登录页面
  3. 若用户已登录,调用API接口加入购物车
  4. 提示成功
  5. 关闭SKU弹窗

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

src/hooks/useSkuPopup.ts

js
// 加入购物车(selectShop:当前选择的sku数据)
const addCart = async (selectShop: SkuPopupEvent) => {
  // 判断用户是登录
  if (!profile.value?.token)
    // 跳转到登录页面
    return uni.navigateTo({
      url: '/pages/login/login',
    })
  // 调用加入购物车API
  await addCartAPI({
    skuId: selectShop._id,
    count: selectShop.buy_num,
  })
  // 提示加入购物车成功
  uni.showToast({
    title: '加入购物车成功',
    icon: 'none',
  })
  // 关闭SKU弹窗
  isShowSku.value = false
}

2. 渲染购物车列表

购物车列表需要登录后才能访问

购物车

2.1 静态结构

src/pages/cart/cart.vue

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>

2.2 登录状态

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

vue
<script setup lang="ts">
import { useMemberStore } from '@/stores'
import { storeToRefs } from 'pinia'

// 获取用户仓库中的用户信息
const { profile } = storeToRefs(useMemberStore())
</script>

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

2.3 列表渲染

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

接口信息

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426918

接口封装

src/services/cart.ts

ts
/**
 * 获取购物车商品列表的接口方法
 * @returns 购物车商品列表
 */
export const getCartListAPI = () => http.get<CartItem[]>('/member/cart')

类型声明

src/services/cart.d.ts

ts
/**购物车内单个商品类型 */
export interface CartItem {
  /**SPUID */
  id: string
  /**商品名称 */
  name: string
  /**图片 */
  picture: string
  /**加入时价格 */
  price: number
  /**数量 */
  count: number
  /**SKUID */
  skuId: string
  /**属性文字,例如“颜色:瓷白色 尺寸:8寸” */
  attrsText: string
  /**是否选中 */
  selected: boolean
  /**当前的价格 */
  nowPrice: number
  /**库存 */
  stock: number
  /**是否收藏 */
  isCollect: boolean
  /**折扣信息 */
  discount: number
  /**是否为有效商品 */
  isEffective: boolean
}

参考代码

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

vue
<script setup lang="ts">
......

// 获取用户仓库中的用户信息
const { profile } = storeToRefs(useMemberStore())
// 购物车列表
const cartList = ref<CartItem[]>([])

// 获取购物车商品数据
const getCartList = async () => {
  const res = await getCartListAPI()
  // 存储购物车商品数据
  cartList.value = res.result
}

// 页面展示
onShow(() => {
  // 判断用户是否登录
  if (!profile.value?.token) return
  // 获取商品数据
  getCartList()
})
</script>

3. 删除购物车商品

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

3.1 接口相关

接口详情

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426913

接口封装

ts
/**
 * 删除购物车中商品的接口方法
 * @param data {ids} SKUID集合
 */
export const deleteCartAPI = (data: { ids: string[] }) =>
  http.delete('/member/cart', data)

3.2 实现步骤

  1. 为滑动删除按钮绑定删除事件
  2. 调用删除商品API
  3. 成功提示
  4. 重新获取购物车商品数据
vue
<script setup lang="ts">
// 删除按钮的回调
const onDeleteCart = async (skuId: string) => {
  // 调用接口删除商品
  await deleteCartAPI({ ids: [skuId] })
  // 提示删除成功
  uni.showToast({
    title: '删除成功',
    icon: 'none',
  })
  // 重新获取商品数据
  getCartList()
}
</script>

<template>
  <!-- 右侧删除按钮 -->
  <template #right>
    <view class="cart-swipe-right">
+     <button class="button delete-button" @tap="onDeleteCart(item.skuId)"> // 传递当前商品的skuId
    </view>
  </template>
</template>

4. 修改商品信息

修改购买数量并动态计算商品的金额,修改选中状态

4.1 接口相关

接口详情

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426912

接口封装

ts
/**
 * 更新购物车商品的接口方法
 * @param skuId skuId
 * @param data {selected?:是否选中,count?:数量}
 */
export const updateCartAPI = (
  skuId: string,
  data: { selected?: boolean; count?: number },
) => http.put(`/member/cart/${skuId}`, data)

4.2 修改商品数量

需求:修改购买数量并动态计算商品的金额,并将修改后的数据同步到后台

方案一:

​ 复用 SKU 插件中的 步进器组件 vk-data-input-number-box 修改商品数量,补充类型声明文件让组件类型更安全

声明文件:

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

参考代码:

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

// 修改商品数量的回调(防抖优化:在停止更改数量1s后再执行回调)
const onChangeCount = debounce(async (event: InputNumberBoxEvent) => {
  // {index,value} = event  index:当前步进器的标识 value:当前步进器的值
  await updateCartAPI(event.index, { count: event.value })
}, 1000)
</script>

<template>
  <!-- 商品数量 -->
  <view class="count">
    <vk-data-input-number-box
      v-model="item.count" // 当前值
      :min="1" // 最小值
      :max="item.stock"  // 最大值
      :index="item.skuId"  // index: 步进器的唯一标识 可以在事件对象中获取
      @change="onChangeCount" // 步进器值改变时触发
    />
  </view>
</template>

方案二:

使用 uni-ui提供的 uni-number-box数字输入框组件

vue
<script>
    
// 修改商品数量的回调(防抖优化)
const onChangeCount = debounce(async (count: number, skuId: string) => {
  // 调用接口修改商品数量
  await updateCartAPI(skuId, { count })
}, 1000)
</script>
<template>
<!-- 商品价格 -->
<view class="price">{{
  (item.nowPrice * item.count).toFixed(2)
}}</view>
<!-- 商品数量 -->
<view class="count">
  <uni-number-box
    :min="0"  // 最小值
    :max="item.stock" // 最大值(库存量)
    :step="1" // 步长
    v-model="item.count" // 当前的值
    @change="onChangeCount(item.count, item.skuId)" // 数字改变时触发
  />
</view>
</template>

4.3 修改商品选中/全选

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

全选商品

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426917

接口封装

ts
/**
 * 更新商品是否全选的接口方法
 * @param data {selected:是否全选}
 */
export const updateAllSelectedAPI = (data: { selected: boolean }) =>
  http.put('/member/cart/selected', data)

实现步骤:

需要通过计算属性来计算出当前的商品是否处于全选的状态,当更新单个商品的选中状态时,要重新计算全部的选中状态

vue
<script setup lang="ts">
// 计算是否全选
let isAllSelected = computed(() => {
  return cartList.value.length && cartList.value.every((item) => item.selected)
})
// 更改单个商品选中状态的回调
const onChangeGoodsSelected = async (item: CartItem) => {
  // 修改前端数据
  // console.log(item) // Proxy{} 响应式数据修改会影响到cartList
  item.selected = !item.selected
  // 修改当前商品的选中状态(修改后端数据)
  await updateCartAPI(item.skuId, { selected: !item.selected })
  // (通过重新获取商品数据来实现状态更新,效率较低,网络不好时有明显延迟,采用直接修改本地数据)
  // getCartList()
}
// 全选/全不选的回调
const onChangeAllSelected = async () => {
  // 修改前端数据
  // 由于isAllSelected会在cartList被修改后重新进行计算,会变化所以将其原始值进行缓存
  let newAllSelect = !isAllSelected.value
  // 前端数据更新
  cartList.value.forEach((item) => {
    item.selected = newAllSelect
  })
  // 发请求更改当前全选的状态(修改后端数据)
  await updateAllSelectedAPI({ selected: newAllSelect })
  // (通过重新获取商品数据来实现状态更新,效率较低,网络不好时有明显延迟,采用直接修改本地数据)
  // getCartList()
}
</script>

<template>
  <!-- 商品信息 -->
  <view class="goods">
    <!-- 选中状态 -->
    <text @tap="onChangeGoodsSelected(item)" class="checkbox" :class="{ checked: item.selected }">
    </text>
  </view>
  <!-- 吸底工具栏 -->
  <view class="toolbar">
    <text @tap="onChangeAllSelected" class="all" :class="{ checked: isAllSelected }">全选</text>
  </view>
</template>

不能直接修改计算属性的值,通过修改其依赖计算的值,使计算属性重新计算

5. 底部结算信息

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

  1. 计算被选中的商品列表
  2. 计算选中商品的金额
  3. 计算选中商品总数
vue
<script setup lang="ts">
// 计算被选中的商品列表
const selectedGoodsList = computed(() => {
  // 过滤选中的商品,返回一个数组
  return cartList.value.filter((item) => item.selected)
})
// 计算选中商品的总金额
let selectedGoodsMoney = computed(() => {
  return selectedGoodsList.value
    .reduce((total, item) => total + item.nowPrice * item.count, 0)
    .toFixed(2)
})
// 计算选中商品的总数
let selectGoodsCount = computed(() => {
  return selectedGoodsList.value.reduce((total, item) => total + item.count, 0)
})
// 去结算按钮回调
const toPayment = () => {
  // 未选择任何商品的提示
  if (!selectGoodsCount.value)
    return uni.showToast({
      title: '请选择商品',
      icon: 'none',
    })
  // 跳转到结算页面
  uni.navigateTo({
    url: '/pagesOrder/create/create',
  })
}
</script>
<templte>
<!-- 吸底工具栏 -->
<view class="toolbar">
  <text
    class="all"
    :class="{ checked: isAllSelected }"
    @tap="onChangeAllSelected"
    >全选</text
  >
  <text class="text">合计:</text>
+  <text class="amount">{{ selectedGoodsMoney }}</text>
  <view class="button-grounp">
    <view
      class="button payment-button"
+      :class="{ disabled: !selectGoodsCount }"
+      @click="toPayment"
    >
+      去结算({{ selectGoodsCount }})
    </view>
  </view>
</templte>

6. 上拉加载更多+下拉刷新

将购物车页面根组件 scroll-view 修改为 view

pages.json 配置允许下拉刷新

json
{
      "path": "pages/cart/cart",
      "style": {
        "navigationBarTitleText": "购物车",
        "enablePullDownRefresh": true, // 允许下拉刷新
        "backgroundColor": "#00c6a2", // 下拉刷新框背景色
        "backgroundTextStyle": "light", // 刷新样式
        "navigationBarBackgroundColor": "#00c6a2", 
        "navigationBarTextStyle": "white"
      }
},

在购物车组件中添加 onReachBottomonPullDownRefresh事件监听函数

js
// 上拉加载更多(上拉触底事件)
onReachBottom(() => {
  onScrolltolower()
})
// 下拉刷新(下拉刷新事件)
onPullDownRefresh(async () => {
  // 重新获取购物车和猜你喜欢的数据
  await Promise.all([getCartList(), guessRef.value?.resetData()])
  // 关闭刷新框
  uni.stopPullDownRefresh()
})

7. 购物车徽标

image-20240802155120519image-20240802155125873

需求:在商品详情页面底部导航栏中展示当前购物车中通过 uni-ui提供的 uni-badge 数字角标(徽章)展示已选商品总数

  • 用户未登录:徽标为0
  • 用户登录后:徽标值为当前购物车中已选商品数量,当商品数量超过99显示99+,当前用户向购物车中添加商品成功后,更新徽标的值
vue
<script>
+// 获取用户仓库中用户数据
+const { profile } = useMemberStore()
+// 购物车商品数据
+const cartList = ref<CartItem[]>([])
+// 购物车角标
+let badge = computed(() => {
+  // 计算当前购物车中商品数量
+  const selectedCount = cartList.value.reduce((total, item) => {
+    if (item.selected) {
+     return total + item.count
+    } else {
+      return total
+    }
+ }, 0)
+  return selectedCount > 99 ? '99+' : selectedCount.toString()
+})
+// sku弹出框中点击加入购物车回调
+const onAddCart = async (selectShop: SkuPopupEvent) => {
+  addCart(selectShop)
+ // 重新获取购物车中的数据并计算徽标的数
+ const result = await getCartListAPI()
+ cartList.value = result.result
+}
// 页面加载
onLoad(async () => {
  .......
  // 如果用户登录获取购物车商品数量
+  if (profile?.token) {
+    const result = await getCartListAPI()
+    cartList.value = result.result
+  }
+})
</script>
<template>
<!-- uni-badge:数字角标/徽章 -->
<uni-badge
  class="uni-badge-left-margin"
+  :text="badge" // 角标显示的文字
+  absolute="rightTop" // 位置
+  size="small" // 大小
>
  <text class="icon-cart"></text>购物车
</uni-badge>
</template>

8. 带返回按钮的购物车

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

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

购物车页

目录结构如下:

sh
pages/cart
├── components
│   └── CartMain.vue ...................................... 购物车业务组件(组件复用)
├── cart2.vue ............................................. 普通页
└── cart.vue   ............................................ TabBar页

把原本的购物车业务独立封装成组件,在两个购物车页面分别引入即可。

注意:

  • 小程序 跳转到 tabBar 页面 时,会关闭其他所有非 tabBar 页面。
  • 小程序的 tabBar 页没有后退按钮,可通过 getCurrentPages() 验证,结果仅有一个页面,意味着历史记录被清空了。
  • 有历史记录的普通页才显示后退按钮。

9. 底部工具栏安全区域适配

对非tabbar页面底部工具栏进行安全区域适配

image-20240802164257847image-20240802164318124

思路:

​ 通过组件通信,cart2(非tabbar组件)为 CartMain组件传递数据 safeAreaInsets控制是否需要对底部工具栏进行安全区域适配

src/pages/cart/cart2.vue

vue
<template>
+  <CartMain :bottomSafeFit="true"></CartMain> // 需要对底部工具栏进行安全区域适配
</template>
<script setup lang="ts">
import CartMain from './components/CartMain.vue'
</script>

src/pages/cart/components/CartMain.vue

vue
<script>
+// 获取屏幕安全距离
+const { safeAreaInsets } = uni.getSystemInfoSync()
+// 接收父组件传递的数据
+defineProps<{
+  // 是否对底部工具栏进行安全适配
+  bottomSafeFit?: boolean
+}>()
</script>
<template>
<!-- 吸底工具栏 -->
<view
  class="toolbar"
+   :style="bottomSafeFit && `padding-bottom:${safeAreaInsets?.bottom}px`"
>
 ...
</view>
<!-- 底部占位空盒子 -->
<view
 class="toolbar-height"
+ :style="bottomSafeFit && `margin-top:${safeAreaInsets?.bottom}px`" // 防止遮挡页面数据
></view>
</template>