Skip to content

小程序慕尚花坊项目2

九、收货地址

  1. 收货地址列表
  2. 新增收货地址
  3. 编辑收货地址
  4. 删除收货地址

01. 定义新增参数以及封装接口 API

思路分析:

点击新建地址按钮,需要跳转到新增地址页面

因为新增和编辑收货地址页面是同一个页面,我们需要在这个页面处理新增和编辑功能,为了做区分处理。

我们在后续做进行编辑的时候传递 id 属性,值为 收货地址的 id 值。

参数名称参数说明是否必须
收货人nametrue
手机号phonetrue
provinceNametrue
省 编码provinceCodetrue
cityNametrue
市 编码cityCodetrue
districtNametrue
区 编码districtCodetrue
详细地址fullAddresstrue
设置默认地址isDefault (是否默认地址 → 0:否 1:是)false

实现步骤:

  1. 在新增收货地址页面 data 中声明所需要的字段
  2. 定义收货地址所需要的全部接口 API 函数

落地代码:

➡️ modules/settingModule/pages/address/add/index

js
Page{{
  // 页面的初始数据
  data: {
	  name: '', // 收货人
      phone: '', // 手机号
      provinceName: '', // 省
      provinceCode: '', // 省 编码
      cityName: '', // 市
      cityCode: '', // 市 编码
      districtName: '', // 区
      districtCode: '', // 区 编码
      address: '',  // 详细地址
      fullAddress: '', // 完整地址 (省 + 市 + 区 + 详细地址)
      isDefault: 0 // 设置默认地址,是否默认地址 → 0:否  1:是
  }
}}

➡️ /api/address

js
// 引入封装好的请求对象
import http from '../utils/http'
// 新增接口地址的接口方法
export const reqAddAddress = (data) => http.post('/userAddress/save', data)
// 获取收货地址列表的接口方法
export const reqAddressList = () => http.get('/userAddress/findUserAddress')
// 获取收货地址详情的接口方法
export const reqAddressDetail = (id) => http.get(`/userAddress/${id}`)
// 更新收货地址的接口方法
export const reqUpdateAddress = (data) => http.post('/userAddress/update', data)
// 删除收货地址的接口方法
export const reqDeleteAddress = (id) => http.get(`/userAddress/delete/${id}`)

02. 收集省市区数据

思路分析

image-20240714130305503

省市区的结构使用了小程序本身自带的 picker 组件,并将组件的 mode 属性设置为了 region,从而变成省市区选择器

如果想获取省市区的数据,需要给 picker 选择组件添加change 事件来监听属性值的改变,获取选中的省市区

html
<!-- 省市区 -->
<view class="row">
  <text class="title">所在地区</text>
  <!-- picker组件是小程序提供的内置组件,可以从底部展示选择器 -->
  <!-- mode="region"设置选择器为省、市、区选择器 -->
  <!-- value:[省,市,区]选中的省市区 -->
  <!-- bindchange:value改变时触发的事件,event.detail = {value, code, postcode}-->
  <picker
    mode="region"
    value="{{[provinceName,cityName,districtName]}}"
    bindchange="onAddressChange"
  >
    <view>
      <text class="placeholder" wx:if="{{!provinceName}}"
        >请选择收货人所在地区</text
      >
      <text wx:else
        >{{provinceName+' '+cityName+' '+districtName+' '}}</text
      >
    </view>
  </picker>
  <view class="location">
    <van-icon name="location-o" color="#777" />
    <text>定位</text>
  </view>
</view>

实现步骤

  1. picker 选择组件添加change 事件来监听属性值的改变,获取选中的省市区
  2. 将获取到省市区标识和编码赋值给 data中的字段

落地代码

➡️ modules/settingModule/pages/address/add/index.js

js
Page({
  // 页面的初始数据
  data: {
    // 微信小程序中的model:只能绑定非引用类型的数据
    name: '', //收货人
    phone: '', //手机号码
    provinceName: '', //省
    provinceCode: '', //省编码
    cityName: '', //市
    cityCode: '', //市编码
    districtName: '', //区
    districtCode: '', //市编码
    address: '', //详细地址
    fullAddress: '', //完整地址
    isDefault: 0 // 是否设置为默认地址 0:不设置 1:设置为默认地址
  },
  // 省市区选择
  onAddressChange(event) {
    const { code, value } = event.detail
    /*console.log(event.detail)
      code: (3) ["110000", "110100", "110101"]
      postcode: "100010"
      value: (3) ["北京市", "北京市", "东城区"]*/
    // 将用户选择的城市名和编码进行存储
    this.setData({
      provinceName: value[0],
      cityName: value[1],
      districtName: value[2],
      provinceCode: code[0],
      cityCode: code[1],
      districtCode: code[2]
    })
  },
  // 保存收货地址
  saveAddrssForm(event) {}
})

03. 收集新增地址其他请求参数

思路分析:

使用简易双向数据 model:value 绑定来收集新增地址表单数据。

在将数据收集以后,需要拼接完整的收货地址

通过 switch组件的 bindchange事件回调获取用户是否设置为默认地址

落地代码:

html
<!-- 设置默认地址 -->
<view class="row">
  <text class="title">设置默认地址</text>
  <!-- checked:是否选中 -->
  <!-- bindchange:点击开关时会触发的事件,event.detail={ value}	-->
  <switch
    color="#f3514f"
    class="checkbox"
    checked="{{isDefault == 1}}"
    bindchange="switchChange"
  />

➡️ modules/settingModule/pages/address/add/index.js

js
Page({
   .....
  // 点击开关的事件回调
  switchChange(event) {
    this.setData({
      isDefault: event.detail.value ? 1 : 0
    })
  },
  // 保存收货地址
  saveAddrssForm() {
    const {
      address,
      districtName,
      cityName,
      provinceName
    } = this.data
    // 1. 拼接完整的地址
    const fullAddress = provinceName + cityName + districtName + address
    // 2. 合并接口请求参数
    const params = {
      ...this.data,
      fullAddress
    }
   // 3.发请求
})

04. 地理定位功能介绍

地理定位介绍:

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

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

申请开通:

暂时只对部分类目的小程序开放,需要先通过类目审核,然后在小程序管理后台,「开发」-「开发管理」-「接口设置」中自助开通该接口权限。

使用方法:

  1. 在调用 wx.getLocation() 时需要在 app.json 中配置 requiredPrivateInfos 进行声明启用,同时配置 permission 使用 scope.userLocation 声明收集用户选择的位置信息的目的
  2. 在调用wx.chooseLocation() 接口时只需要在 requiredPrivateInfos 进行声明启用
  3. 在配置好以后,调用 wx.getLocation()wx.chooseLocation() 接口(支持Promise风格调用)

参考文档:

  1. 地理位置接口新增与相关流程调整
  2. permission 字段说明

app.json 中进行配置

json
{
  "requiredPrivateInfos": ["getLocation", "chooseLocation"],
  "permission": {
    "scope.userLocation": {
      "desc": "获取用户位置信息用于填写收货地址" //收集用户选择的位置信息的目的
    }
  }
}

getLocation 使用:

js
// 地理定位
async onLocation() {
  // 获取 纬度、经度
  const { latitude, longitude } = await wx.getLocation()
}

chooseLocation 使用:

js
// 地理定位
async onLocation() {
  // 打开地图选择位置,获取 纬度、经度
  const { latitude, longitude }  = await wx.chooseLocation()
}

05. wx.getLocation()拒绝授权后的解决方案

在调用 wx.getLocation() 获取用地理位置时,如果用户选择拒绝授权,代码会直接抛出错误,且下次调用 wx.getLocation() 时,就不会在弹窗询问用户是否允许授权。

接下来,就需要优化授权的流程:

  1. wx.getSetting():获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限
  2. wx.openSetting(): 调起客户端小程序设置界面,返回用户设置的操作结果

📌 注意事项:

  1. 如果希望用户再次授权,就需要让用户在小程序权限设置页面进行 手动开启授权
  2. wx.openSetting() 必须用户发生点击行为后,才可以跳转到设置页进行授权信息管理
js
async onLocation() {
    // 1.获取用户所有的授权信息
    const { authSetting } = await wx.getSetting()
    // 2.判断用户是否已经拒绝了授权 返回 true:同意 false:拒绝 undefined:尚未发起授权
    if (authSetting['scope.userLocation'] === false) {
      // 5.用户第一次拒绝了授权又一次发起了请求授权
      // 6.弹窗询问是否授权 返回 true/false
      const modalRes = await modal('授权提示', '需要获取地理位置信息,请允许')
      // 7.如果用户点击取消,拒绝授权
      if (!modalRes) {
        toast('取消授权')
      } else {
        // 8.用户点击了确定,允许授权
        // wx.openSetting() 打开微信客户端小程序授权页面,返回用户授权信息
        const { authSetting } = await wx.openSetting()
        // 9.判断用户在设置页面是否进行了授权
        if (authSetting['scope.userLocation']) {
          // 获取地址位置
          try {
            // 获取用户地理位置信息
            const locationRes = await wx.getLocation()
            console.log(locationRes)
          } catch (error) {
            toast('获取位置信息失败')
          }
        } else {
          toast('请允许使用位置信息')
        }
      }
    } else {
      // 3.用户已经授权或第是一次授权
      try {
        // 4.获取用户地理位置信息,点击取消会抛出错误信息
        const locationRes = await wx.getLocation()
        // 点击运行获取用户地理位置信息
        console.log(locationRes)
      } catch (error) {
        toast('获取位置信息失败')
      }
    }
  }

拓展:

使用按钮组件打开微信小程序客户端授权页面

为按钮组件添加 open-type="openSetting" 属性

html
<view class="box">
    <!-- open-type="openSetting":点击按钮会打开微信小程序客户端授权页面 -->
    <button type="primary" open-type="openSetting">打开授权页面</button>
</view>

06. 开通腾讯位置服务

腾讯位置服务简介:

使用wx.chooseLocation()wx.getLocation() 能够很方便的让用户来选择地理位置,但返回的数据只含有经度和维度并没有包含省市区、省市区编码数据,因此需要对返回的地址信息进行解析

这时候我们可以使用 腾讯位置服务 将返回的经度、纬度进行逆地址解析,转换成详细地址

腾讯位置服务专为小程序开发提供了 JavaScript SDK,方便开发者在小程序中可以使用腾讯地图服务

使用腾讯位置服务可以很方便的让开发者实现地址解析、逆地址解析等功能

使用步骤:

  1. 申请开发者密钥(key):申请密钥
  2. 开通 webserviceAPI 服务:控制台 → 应用管理→我的应用 → 添加 key →勾选 WebServiceAPI →保存
  3. 下载微信小程序 JavaScriptSDK,微信小程序JavaScriptSDK v1.1 JavaScriptSDK v1.2
  4. 安全域名设置

详细步骤:

  1. 申请密钥:密钥申请,微信扫码进行登录,选择绑定已有账号、或者注册新账号 (需要绑定手机、验证邮箱)

  2. 控制台 → 应用管理→我的应用 → 创建应用 → 添加 key → 创建完成

  3. 下载微信小程序 JavaScriptSDK v1.2,下载将 .js 文件放到小程序的 libs 目录下

  4. 进行安全域名设置,或者点击微信开发者工具中的暂时不校验域名

07. LBS 逆地址解析

使用步骤:

  1. 在项目中引入 SDK 核心类
  2. onLoad 中实例化 API 核心类,同时配置创建的 key
  3. 使用实例方法 reverseGeocoder 方法进行逆地址解析,将提供的坐标转换为详细的地址位置信息

官方文档-基础示例:Hello World

官方文档-逆地址解析:reverseGeocoder

注意:需要在腾讯位置服务中配置逆地址解析接口的调用额度

image-20240714174132413

落地代码:

  1. 引入 SDK 核心类

    js
    import QQMapWX from '../../../../../libs/qqmap-wx-jssdk.min'
  2. 实例化 API 核心类

    js
    // 引入SDK核心类,js文件根据自己业务,位置可自行放置
    import QQMapWX from '../../../../../libs/qqmap-wx-jssdk.min'
    
    Page({
      onLoad: function () {
        // 实例化API核心类
        this.qqmapsdk = new QQMapWX({
          key: '申请的key'
        })
      }
      // coding...   
    }
  3. 使用 reverseGeocoder 方法进行逆地址解析,将提供的坐标转换为所在位置的文字描述的转换

    js
    // 点击定位图标的回调,使用wx.chooseLocation()获取用户地理信息
    async onLocation() {
      // 获取用户选择的精度,维度和选择地址的名字
      const { longitude, latitude, name } = await wx.chooseLocation()
      // 使用腾讯位置提供的功能进行逆地址解析
      this.qqmapsdk.reverseGeocoder({
        location: { // 传入经度和维度
          longitude,
          latitude
        },
        success: (res) => {
          // 获取省市区编码以及省、市、区
          const { adcode, province, city, district } = res.result.ad_info
          // 获取街道和门牌号
          const { street, street_number } = res.result.address_component
          // 获取标准地址  
          const {
            standard_address
          } = res.result.formatted_addresses.standard_address
          // 对获取的数据进行格式化,组织,存储到data中
          this.setData({
            provinceName: province,
            // 省编码 adcode取前两位后面为0
            provinceCode: adcode.substring(0, 2) + '0000',
            cityName: city,
            // 市编码 adcode取前四位后面为0
            cityCode: adcode.substring(0, 4) + '00',
            districtName: district,
            // 东莞市,中山市,儋州市,嘉峪关市 其下无区县级
            districtCode: adcode & '',
            // 详细地址
            address: street + street_number + name,
            // 完整地址
            fullAddress: standard_address + name
          })
        }
      })
    },

08. async-validator 基本使用

知识点

async-validator是一个基于 JavaScript/TypeScript 的表单验证库,支持异步验证规则和自定义验证规则

主流的 UI 组件库 Ant-designElement中的表单验证都是基于 async-validator

使用 async-validator 可以方便地构建表单验证逻辑,使得错误提示信息更加友好和灵活

使用步骤:

  1. 安装并在项目中导入 async-validator
  2. 创建验证规则
  3. 创建表单验证实例,将验证规则传递给构造函数,产生实例
  4. 调用实例方法 validate 对数据进行验证
    • 第一个参数:需要验证的数据
    • 第二个参数:回调函数,回调函数有两个参数 errors, fields
      • errors:如果验证成功,返回 null,验证错误,返回包含错误信息、验证字段、验证值象的数据
      • fields:需要验证的字段,属性值错误数组

落地代码:

  1. 安装 async-validator

    shell
    npm i async-validator
  2. 开发者工具,点击构建 npm,对 async-validator 进行构建

  3. 在 js 文件中导入 async-validator

    js
    // 从async-validator中引入构造函数
    import Schema from 'async-validator'
    Page({
      data: {
        name: '张'
      },
      // 对数据进行验证
      onValidator() {
        // 定义验证规则
        const rules = {
          // name:要验证的字段  required:必填 type:类型 min:最少位数 max:最大位数 pattren:正则  validator:自定义验证规则 message:错误提示信息
          name: [
            { required: true, message: 'name不能为空' },
            { type: 'string', message: '类型必须为字符串' },
            { min: 2, max: 3, message: '名字在2-3字符之间' }
            // { pattren: '', message: '正则校验不通过' }
            // { validator: () => {} }
          ]
        }
        // 对构造函数实例化并传入验证规则
        const validator = new Schema(rules)
        // 调用实例方法对数据进行验证 validator.validate(需要验证的数据(对象),回调函数)
        // 验证规则中的key要与data中的数据名相同
        validator.validate(this.data, (errors, fields) => {
          // 验证成功 errors:null
          // 验证失败 errors:[] 数组每一项为错误信息
          // fields:需要验证的属性,属性值为一个数组,数组中包含错误信息
          if (errors) {
            console.log('验证失败')
            console.log(errors)
            console.log(fields)
          } else {
            console.log('验证成功')
          }
        })
      }
    })

09. 新增收货地址表单验证

思路分析:

在点击新增收货地址的时候,我们需要对用户输入的值进行验证。产品需求如下:

  1. 收货人不能为空,且不能输入特殊字符
  2. 手机号不能为空,且输入的手机号必须合法
  3. 省市区不能为空
  4. 详细地址不能为空

正则:

js
// 验证收货人,是否只包含大小写字母、数字和中文字符
const nameRegExp = '^[a-zA-Z\\d\\u4e00-\\u9fa5]+$'
// 验证手机号,是否符合中国大陆手机号码的格式
const phoneReg = '^1(?:3\\d|4[4-9]|5[0-35-9]|6[67]|7[0-8]|8\\d|9\\d)\\d{8}$'

实现步骤:

  1. 创建 validateForm 方法,使用 async-validator 对表单进行验证
  2. 在新增收货地址之前,调用 validateForm 方法,如果验证成功执行新增收货地址的逻辑

落地代码:

➡️ /modules/settingModule/pages/address/add/index

js
// 导入async-validator进行表单校验
import Schema from 'async-validator'

Page({
.....
// 保存收货地址
  async saveAddrssForm() {
    const { address, districtName, cityName, provinceName } = this.data
    // 1. 拼接完整的地址
    const fullAddress = provinceName + cityName + districtName + address
    // 2. 合并接口请求参数
    const params = {
      ...this.data,
      fullAddress
    }
    // 3.表单校验  验证成功才会继续向下执行
    await this.validatorAddress(params) // 返回一个promise
    // 4.发请求
  },
+  // 用于对收集到的数据进行校验
+  validatorAddress(params) {
+    // 创建验证规则
+    const rules = {
+      name: [
+        { required: true, message: '请输入收货人姓名' },
+        {
+          pattern: '^[a-zA-Z\\d\\u4e00-\\u9fa5]+$',
+          message: '收货人姓名不合法'
+        }
+      ],
+      phone: [
+        { required: true, message: '请输入收货人手机号' },
+        {
+          pattern: '^1(?:3\\d|4[4-9]|5[0-35-9]|6[67]|7[0-8]|8\\d|9\\d)\\d{8}$',
+          message: '收货人手机号不合法'
+        }
+      ],
+      provinceName: [{ required: true, message: '请选择收货地址' }],
+      address: [{ required: true, message: '请输入详细地址' }]
+    }
+    // 实例化并传入验证规则
+    const validator = new Schema(rules)
+    // 调用实例方法对请求参数验证
+    // 注意:将验证结果通过Promise返回给函数调用者
+    return new Promise((resolve, reject) => {
+      // 调用实例方法进行验证
+      validator.validate(params, (errors) => {
+        if (errors) {
+          // 验证失败
+          toast(errors[0].message)
+          // 返回失败状态阻塞代码继续执行
+          reject(new Error('error'))  // 使用await接受 reject失败状态会阻塞代码执行
+        } else {
+          // 返回成功状态 可以使用await进行接收
+          resolve('ok')
+        }
+      })
+    })
+  },
  // coding...
})

10. 实现新增收货地址

思路分析:

在实现了新增收货地址的数据收集、表单验证以后,我们需要实现新增收货地址的功能,将用户的收货地址到服务器。我们直接根据接口文档,封装接口 API,然后在表单验证以后,进行收货地址的添加即可。

实现步骤:

  1. 在对新增收货地址请求参数验证以后,将封装好的新增收货地址的 API 函数调用

  2. 在新增收货地址成功以后,跳转到收货地址详情页面。

落地代码:

➡️ /pages/address/add/index.js

js
 // 点击保存按钮
  async saveAddrssForm() {
    const { address, districtName, cityName, provinceName } = this.data
    // 1. 拼接完整的地址
    const fullAddress = provinceName + cityName + districtName + address
    // 2. 合并接口请求参数
    const params = {
      ...this.data,
      fullAddress
    }
    // 3.表单校验  验证成功才会继续向下执行
    await this.validatorAddress(params)
    // 4.发请求
    const res = await reqAddAddress(params)
    if (res.code === 200) {
      // 5.跳转到收货地址详情页面 等待页面跳转成功后再显示提示
      await wx.navigateBack() // wx.navigateBack()异步API支持Promise调用
      toast('新增收货地址成功')
    } else {
      toast('新增收货地址失败')
    }
  },

Tips:

wx.showTast()wx.navigateBack()都是异步 API,且支持 Promise 风格调用

11. 收货地址列表渲染

思路分析:

渲染收货地址需要收货地址的数据,需要调用接口获取收货地址数据,使用返回的数据进行结构的渲染。

在熟悉了接口文档以后,根据接口文档封装接口 API 函数,然后在页面调用 API 函数获取收货地址的数据,在获取到数据以后,使用后端返回的数据对页面进行渲染。

实现步骤:

  1. onShow 钩子函数中调用reqAddressList方法

  2. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ /modules/settingModule/pages/address/list/index.js

js
import { reqAddressList } from '../../../../../api/address'
Page({
  data: {
+    addressList: []
  },
  // 去编辑页面
  toEdit() {
    wx.navigateTo({
      url: '/modules/settingModule/pages/address/add/index'
    })
  },
  // 获取地址列表的数据
+  async getAddressList() {
+    const result = await reqAddressList()
+    if (result.code === 200) {
+      this.setData({
+        addressList: result.data
+      })
+    }
+  },
  // 当页面展示时获取地址列表数据
  // 此处不使用onLoad原因:当使用wx.navigateTo()跳转时不会销毁当前页面下次进入页面不会触发onload钩子函数
+  onShow() {
+    this.getAddressList()
+  }
})

➡️ /modules/settingModule/pages/address/list/index.wxml

html
<view class="list-warpper" wx:if="{{ addressList.length }}">
  <view wx:for="{{ addressList }}" wx:key="id" class="list-item">
    <van-swipe-cell right-width="{{ 65 }}">
      <view class="list-item-box">
        <view class="info">
          <view class="user-info">
+             <text>{{ item.name }}</text>
+             <text>{{ item.phone }}</text>
+             <text wx:if="{{ item.isDefault }}" class="default-tag">默认</text>
          </view>

+           <view class="address-info"> {{ item.fullAddress }} </view>
        </view>
        <view class="editBtn">
          <van-icon bindtap="toEdit" name="edit" size="22px" color="#999" />
        </view>
      </view>
      <!-- <van-icon name="delete" size="22px" color="#999" /> -->
      <view slot="right" class="van-swipe-cell__right">
        <text>删除</text>
      </view>
    </van-swipe-cell>
  </view>
</view>

Tips:

onLoadonshow 两个钩子函数的区别:

onLoad在页面加载时触发,当页面被销毁时才会重新加载,如执行了 wx.redirectTo(),点击返回箭头返回上一页

onShow在页面展示时触发,页面没有被销毁,如执行了 wx.navigatorTo()跳转到其他页面,当前页面没有被销毁

12. 实现更新收货地址

思路分析:

新增和编辑收货地址页面是同一个页面,我们需要在这个页面处理新增和编辑功能

在收货地址列表页面,点击更新按钮时,需要跳转到新增/更新页面,同时需要将更新这一项的 id 传递给新增/更新页面。

onLoad 中获取 id,并且使用 id 区分用户是进行新增还是编辑的操作。

如果存在 id,在获取需要更新的收货地址的数据,并进行页面的回显用户的收货地址,并且需要更新导航栏标题

因为我们之前直接是将数据放到 data 中的,所以我们直接将数据使用 setData 赋值即可

实现步骤:

  1. 在从收货地址列表页面跳转到更新页面的时候,需要携带 id
  2. onLoad 中判断是否存在 id,如果存在 id,在获取数据进行回显

落地代码:

➡️ /modules/settingModule/pages/address/list/index.wxml

html
<!-- 编辑、删除按钮 -->
<van-icon bindtap="toEdit" data-id="{{ item.id }}" />

➡️ /modules/settingModule/pages/address/list/index.js

js
// 去编辑页面
  toEdit(event) {
    // 获取当前点击收货地址的id
+    const { id } = event.currentTarget.dataset
+    // 在路由跳转时将id传递给编辑页面
+    wx.navigateTo({
+      url: `/modules/settingModule/pages/address/add/index?id=${id}`
+    })
  },

➡️ /modules/settingModule/pages/address/add/index.js

js
// 点击保存按钮的回调
  async saveAddrssForm() {
    const { address, districtName, cityName, provinceName } = this.data
    // 1. 拼接完整的地址
    const fullAddress = provinceName + cityName + districtName + address
    // 2. 合并接口请求参数
    const params = {
      ...this.data,
      fullAddress
    }
    // 3.表单校验  验证成功才会继续向下执行
    await this.validatorAddress(params)
    // 4.发请求
+    // 判断新增还是更新
+    const res = await (this.addressId ? reqUpdateAddress(params) : reqAddAddress(params))
    if (res.code === 200) {
      // 5.跳转到收货地址详情页面
+      await wx.navigateBack() // wx.navigateBack()异步API支持Promise调用
+      toast(this.addressId ? '更新收货地址成功' : '新增收货地址成功')
    } else {
+      toast(this.addressId ? '更新收货地址失败' : '新增收货地址失败')
    }
  },
  ...
+  // 用于处理更新相关的逻辑
+  async updateAddressInfo(id) {
+    // 修改页面标题为更新收货地址
+    wx.setNavigationBarTitle({
+      title: '更新收货地址'
+    })
+    // 获取当前id对应地址详情
+    const result = await reqAddressDetail(id)
+    if (result.code === 200) {
+      // 获取服务器返回的当前地址的详细信息
+      const { data } = result
+      // 对当前地址信息进行更新
+      this.setData(data)
+    }
+  },
  onLoad: function (options) {
    // 实例化API核心类
    this.qqmapsdk = new QQMapWX({
      key: 'L6TBZ-GY3CT-KLGXO-VUSJI-INQS3-ZOBCQ'
    })
+    // 接收列表页面传递的地址id,并存储到this对象上便于在多个函数中进行调用
+    this.addressId = options.id
+    if (options.id) {
+      // 如果有id则时更新地址,否则为新增地址
+      this.updateAddressInfo(options.id)
+    }
  }

注意:

event.currentTarget.datasetevent.target.dataset的区别:

event.currentTarget.dataset:获取的是事件绑定者传递的数据

event.target.dataset:获取的是事件触发者传递的数据

13. 实现删除收货地址

思路分析:

点击删除按钮的时候,需要将对应的地址进行删除

当点击删除按钮的时候,调用封装的接口 API 函数 ,同时传递需要删除的收货地址 id 即可

实现步骤:

  1. 给删除按钮绑定点击事件 deleteAddress,同时通过 data-id 传递需要删除的商品 id
  2. delAddress 事件处理程序后面,调用 API 函数 reqDelAddress,并传递 id
  3. 在删除收货地址成功以后,给用户提示

落地代码:

➡️ /modules/settingModule/pages/address/list/index.wxml

js
<van-icon
+   bindtap="deleteAddress"
+   data-id="{{ item.id }}" 
  name="delete"
  size="22px"
  color="#999"
/>

➡️ /modules/settingModule/pages/address/list/index.js

js
// 删除收货地址
async deleteAddress(event) {
  const { id } = event.target.dataset
  const res = await modal('提示', '您确定删除该收货地址吗?')
  if (res) {
    await reqDeleteAddress(id)
    toast('删除成功')
    // 重新获取地址列表数据
    this.getAddressList()
  } else {
    toast('取消删除')
  }
},

优化:SwipeCell 自动收起删除滑块

目前我们已经实现了滑块删除收货地址的功能,

但是我们会发现点击页面空白区域或者点击其他收货地址时,删除的滑块不会自动收起。

如果想实现点击空白区域自动收起滑块功能,需要在 点击空白区域 以及 其他收货地址时,获取要收起的滑块实例。

调用对应滑块的实例方法 close 即可。

实现思路:

  1. 给滑块绑定 动态id,每个id可以标识唯一的滑块 id="swip-cell-&#123;&#123;item.id&#125;&#125;"(重要)
  2. 在打开滑块时,通过事件对象获取 id 再通过 selectComponent 获取当前滑块的实例进行存储
  3. 给页面最外层的 view 和滑块区域绑定点击事件,在事件回调中通过组件实例调用 close 方法关掉滑块
  4. 将关掉的逻辑抽取成 behavior 文件,方便在其他文件中进行复用。

落地代码:

➡️ /behavior/swipeCellBahavior.js

js
export default Behavior({
  data: {
    // 当前打开的滑块的实例对象
    swipCellInstance: {}
  },
  methods: {
    // 用户打开滑块触发的事件回调
    swipCellOpen(event) {
      // 获取当前滑动的滑块的id 每个滑块的id互不同
      const { id } = event.target
      // 获取当前滑动滑块的实例对象
      const instance = this.selectComponent(`#${id}`)
      // 存储当前滑动滑块的实例对象
      this.setData({
        swipCellInstance: instance
      })
    },
    // 用户点击空白区域或点击地址滑块时关闭已经打开的滑块
    closeSwipe() {
      // 调用滑块实例的close方法关闭打开的滑块
      this.data.swipCellInstance.close()
    }
  }
})

➡️ /modules/settingModule/pages/address/list/index.wxml

html
+ <view class="container address-list" bind:tap="closeSwipe">
   <view class="list-warpper" wx:if="{{ addressList.length }}">
    <view wx:for="{{ addressList }}" wx:key="index" class="list-item">
      <van-swipe-cell
+        id="swip-cell-{{item.id}}"
+        bind:open="swipCellOpen"
         right-width="{{ 65 }}"
+        bind:click="closeSwipe"
      >
 .....

➡️ /modules/settingModule/pages/address/list/index.js

js
...
// 导入用于自动收起滑块的behavior
import swipeCellBehavior from '../../../../../behaviors/swipeCell'
Page({
  // 使用封装好的用于自动收起滑块的behavior
  behaviors: [swipeCellBehavior],
...

Tips:

behavior中的属性和方法会自动注入到页面/组件中的属性和方法中

behavior可以在组件、页面中使用

十、商品管理

01. 配置商品管理分包

思路分析:

随着项目功能的增加,项目体积也随着增大,从而影响小程序的加载速度,影响用户的体验。

因此我们需要将 商品列表商品详情 功能配置成一个分包,

当用户在访问分类页面时,还预先加载 商品列表商品详情 所在的分包

在分包后,通过查看代码依赖查看是否分包完成

📌 注意事项

  1. 在配置好 商品列表商品详情 的分包以后,需要更改页面中的跳转路径 ,在原本的路径前加上 分包路径/modules/goodModule
  2. PS:可以利用项目全局搜索的功能,进行批量更改

实现步骤:

  1. modules 目录下创建 goodModule 文件夹,用来存放商品管理分包
  2. app.jsonsubpackages 进行商品管理分包配置
  3. app.jsonpreloadRule 进行商品管理分包配置

落地代码:

➡️ app.json

json
{
  "subPackages": [
    {
      "root": "modules/settingModule",
      "name": "settingModule",
      "pages": [
        "pages/address/add/index",
        "pages/address/list/index",
        "pages/profile/profile"
      ]
    },
+     {
+       "root": "modules/goodModule",
+       "name": "goodModule",
+       "pages": ["pages/goods/list/list", "pages/goods/detail/detail"]
    }
  ],
  "preloadRule": {
    "pages/settings/settings": {
      "network": "all",
      "packages": ["settingModule"]
    },
+     "pages/category/category": {
+       "network": "all",
+       "packages": ["goodModule"]
+     }
  }
}

02. 封装商品模块接口 API

思路分析:

为了方便后续进行商品管理模块的开发,我们在这一节将商品管理所有的接口封装成接口 API 函数

落地代码:

➡️ api/goods.js

js
import http from '../utils/http'
// 获取商品列表的接口方法
export const reqGoodsList = ({ page, limit, ...data }) =>
  // 接收一个对象作为参数,使用剩余参数将可选参数变为一个对象
  http.get(`/goods/list/${page}/${limit}`, data)
// get请求携带query可以通过地址拼接的方式,也可以通过参数形式携带
// 获取商品详情的接口方法
export const reqGoodsDetail = (goodsId) => http.get(`/goods/${goodsId}`)

03. 商品列表-准备列表请求参数

思路分析:

当用户点击了商品分类以后,需要获取对应分类的商品列表信息,因此我们需要先获取到该分类的 id,只要获取到 id 以后,才能向服务器获取对应分类的商品列表信息。同时我们需要查看接口文档,查看是否需要使用其他参数,我们提前将参数准备好。

参数名称参数说明是否必须
limit每页记录数true
page当前页码true
category1Id一级分类的 Id (从首页导航分类区域点击进入)false
category2Id二级分类的 Id (从分类页面点击进入二级分类、点击首页活动列表进入)false

实现步骤:

  1. 在商品列表的 data 字段中,根据接口文档,定义商品列表接口需要使用的字段
  2. 在跳转到商品列表页面时通过地址query参数的形式将一级和二级分类的id传递到商品列表页面在 onLoad 钩子函数中接收请求的参数,并将请求参数进行合并

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
Page({
  // 页面的初始数据
  data: {
    goodsList: [], // 商品列表数据
+     // 接口请求参数
+     requestData: {
+       page: 1, // 页码
+       limit: 10, // 每页请求多少条数据
+       category1Id: '', // 一级分类 id
+       category2Id: '' // 二级分类 id
+     }
  },
+     // 生命周期函数--监听页面加载
+  onLoad(event) {
+    // 获取其他页面传递的路径参数并存储
+    // const { category1Id, category2Id } = event
+    Object.assign(this.data.requestData, event) // 可以修改数据但不会触发页面更新
+  }
})

04. 商品列表-获取商品列表数据并渲染

思路分析:

在准备商品列表的请求参数以后,

在页面调用 API 函数获取商品列表的数据,在获取到数据以后,使用后端返回的数据对页面进行渲染。

实现步骤:

  1. /pages/goods/list/list.js 中导入封装好的获取商品列表的 API 函数
  2. 页面数据在页面加载的时候进行调用,在 onLoad 钩子函数中调用 reqGoodsList 方法
  3. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ /modules/goodsModules/pages/list/list.js

js
+import { reqGoodsList } from '../../../../../api/goods'
Page({
  data: {
+    goodsList: [], // 商品列表数据
+    total: 0, // 商品总条数
+    isFinish: false, // 判断数据是否全部显示完毕
    // 请求参数
    requestData: {
      page: 1, // 页码
      limit: 10, // 条数
      category1Id: '', // 一级分类id
      category2Id: '' // 二级分类id
    }
  },
  // 获取商品列表数据
+  async getGoodsList() {
+    // 开启加载
+    wx.showLoading({
+      title: '数据加载中...'
+    })
+    const result = await reqGoodsList(this.data.requestData)
+    // 已经在响应拦截器中处理过code不等于200的情况
+    // 存储返回的数据
+    this.setData({
+      goodsList: result.data.records,
+      total: result.data.total
+    })
+    // 关闭加载
+    wx.hideLoading()
  },
  onLoad(event) {
    // 获取其他页面传递的路径参数并存储
    // const { category1Id, category2Id } = event
    Object.assign(this.data.requestData, event)
    // 发请求获取商品列表数据
+    this.getGoodsList()
  }
})

➡️ /modules/goodsModule/pages/list/list.wxml

html
<view class="container">
  <!-- 商品列表功能 -->
+  <view wx:if="{{ goodsList.length }}" class="goods-list">
+    <block wx:for="{{goodsList}}" wx:key="id">
+      <goods-card goodItem="{{item}}"></goods-card>
+    </block>
+    <!-- 数据是否加载完毕 -->
+    <view class="finish" wx:if="{{ isFinish }}">没有更多商品了~~~</view>
+  </view>
  <!-- 商品为空的时候展示的结构 -->
+  <van-empty wx:else description="该分类下暂无商品,去看看其他商品吧~">
    <van-button round type="danger" class="bottom-button" bindtap="gotoBack">
      查看其他商品
    </van-button>
  </van-empty>
</view>

05. 商品列表-实现上拉加载更多功能

思路分析:

当用户从下向上滑动屏幕时,需要加载更多的商品数据。

首先需要在 .js 文件中声明 onReachBottom 方法监听用户是否进行了上拉

当用户上拉时,需要对 page 页码进行加 1,代表要请求下一页的数据

当参数发生改变后,需要重新发送请求,拿最新的 page 向服务器发送请求获取数据。

在下一页的商品数据返回以后,将最新的数据和之前的数据进行合并

实现步骤:

  1. list.js 文件中声明 onReachBottom 事件处理函数,监听用户的上拉行为
  2. onReachBottom 函数中加 page 进行加 1 的操作,同时发送请求获取下一页数据
  3. getGoodsList 函数中,实现参数的合并

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
import { reqGoodsList } from '../../../../../api/goods'
Page({
  data: {
    goodsList: [], // 商品列表数据
    total: 0, // 商品总条数
    isFinish: false, // 判断数据是否全部显示完毕
    // 请求参数
    requestData: {
      page: 1, // 页码
      limit: 10, // 条数
      category1Id: '', // 一级分类id
      category2Id: '' // 二级分类id
    }
  },
  // 获取商品列表数据
  async getGoodsList() {
    // 开启加载
    wx.showLoading({
      title: '数据加载中...'
    })
    const result = await reqGoodsList(this.data.requestData)
    // 已经在响应拦截器中处理过code不等于200的情况
    // 存储返回的数据
    this.setData({
+      // 将返回的数据进行合并
+      goodsList: this.data.goodsList.concat(result.data.records),
      // goodsList: [...result.data.records, ...result.data.records],
      total: result.data.total
    })
    // 关闭加载
    wx.hideLoading()
  },
  // 监听页面上拉触底
 + onReachBottom() {
 +   // 对页码加一
 +   const { page } = this.data.requestData
 +   this.setData({
 +     'requestData.page': page + 1
 +   })
 +   // 获取商品列表数据
 +   this.getGoodsList()
  },
  onLoad(event) {
    // 获取其他页面传递的路径参数并存储
    // const { category1Id, category2Id } = event
    Object.assign(this.data.requestData, event)
    // 发请求获取商品列表数据
    this.getGoodsList()
  }
})

06. 商品列表-判断数据是否加载完毕

思路分析:

我们需要相关的优化:判断数据是否已经加载完,如果加载已经加载完毕,需要给用户进行提示。

使用后端返回的 totalgoodsList.length 进行对比,如果 total 大于 goodsList.length ,说明商品列表数据没有加载完,可以继续上拉加载更多。

在模板中,我们通过 totalgoodsList.length 进行对比,决定是否展示对应的提示

实现步骤:

  1. 在数据返回以后,将数据中的 total 赋值给 data 中的变量 total
  2. onReachBottom 中进行 totalgoodsList 进行对比
  3. 模板中使用 totalgoodsList 进行对比

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
import { reqGoodsList } from '../../../../../api/goods'
Page({
  data: {
    goodsList: [], // 商品列表数据
    total: 0, // 商品总条数
+    isFinish: false, // 判断数据是否全部显示完毕
    // 请求参数
    requestData: {
      page: 1, // 页码
      limit: 10, // 条数
      category1Id: '', // 一级分类id
      category2Id: '' // 二级分类id
    }
  },
  // 获取商品列表数据
  async getGoodsList() {
    // 开启加载
    wx.showLoading({
      title: '数据加载中...'
    })
    const result = await reqGoodsList(this.data.requestData)
    // 已经在响应拦截器中处理过code不等于200的情况
    // 存储返回的数据
    this.setData({
      goodsList: this.data.goodsList.concat(result.data.records),
      // goodsList: [...result.data.records, ...result.data.records],
      total: result.data.total
    })
+    // 判断数据是否已经全部展示完毕
+    const { goodsList, total } = this.data
+    this.setData({
+      isFinish: goodsList.length === total
+    })
    // 关闭加载提示
    wx.hideLoading()
  },
  // 监听页面上拉触底
  onReachBottom() {
+    // 如果数据全部获取完毕则不再获取数据
+    if (this.data.isFinish) return
    // 若数据尚未获取完毕则继续上拉获取数据
    // 对页码加一
    const { page } = this.data.requestData
    this.setData({
      'requestData.page': page + 1
    })
    // 获取商品列表数据
    this.getGoodsList()
  },
  onLoad(event) {
    // 获取其他页面传递的路径参数并存储
    // const { category1Id, category2Id } = event
    Object.assign(this.data.requestData, event)
    // 发请求获取商品列表数据
    this.getGoodsList()
  }
})

07. 商品列表-节流阀进行列表节流

在用户网速很慢的情况下,如果用户在距离底部来回的进行多次滑动,可能会发送一些无意义的请求、造成请求浪费的情况,因此需要给上拉加载添加节流功能。

我们使用节流阀来给商品列表添加节流功能。

data 中定义节流阀状态 isLoading,默认值是 false

在请求发送发出后,将 isLoading 设置为 true,表示请求正在发送。

在请求结束以后,将 isLoading 设置为 false,表示请求已经完成。

onReachBottom 事件监听函数中,对 isLoading 进行判断,如果为 true 则本次获取数据的请求不再发出

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
import { reqGoodsList } from '../../../../../api/goods'
Page({
  data: {
    goodsList: [], // 商品列表数据
    total: 0, // 商品总条数
    isFinish: false, // 判断数据是否全部显示完毕
+    isLoading: false, // 是否正在上拉加载数据(true:正在加载数据,再次上拉将不再发请求获取数据)
    // 请求参数
    requestData: {
      page: 1, // 页码
      limit: 10, // 条数
      category1Id: '', // 一级分类id
      category2Id: '' // 二级分类id
    }
  },
  // 获取商品列表数据
  async getGoodsList() {
    // 将isLoading设置为true:正在上拉获取数据
+    this.setData({
+     // 响应式更改,也可使用非响应式更改 this.data.isLoading = true
+     isLoading: true
+    })
    // 开启加载
    wx.showLoading({
      title: '数据加载中...'
    })
    const result = await reqGoodsList(this.data.requestData)
    // 已经在响应拦截器中处理过code不等于200的情况
    // 存储返回的数据
    this.setData({
      goodsList: this.data.goodsList.concat(result.data.records),
      // goodsList: [...result.data.records, ...result.data.records],
      total: result.data.total
    })
    // 判断数据是否已经全部展示完毕
    const { goodsList, total } = this.data
    this.setData({
      isFinish: goodsList.length === total
    })
    // 关闭加载
    wx.hideLoading()
+    // 上拉加载数据获取完毕将isLoading设置为false
+    this.setData({
+      isLoading: false
+    })
  },
  // 监听页面上拉触底
  onReachBottom() {
    // 如果数据全部获取完毕或正在获取上拉加载的数据中则不再获取数据
+    if (this.data.isFinish || this.data.isLoading) return
    // 若数据尚未获取完毕则继续上拉获取数据
    // 对页码加一
    const { page } = this.data.requestData
    this.setData({
      'requestData.page': page + 1
    })
    // 获取商品列表数据
    this.getGoodsList()
  },
  onLoad(event) {
    // 获取其他页面传递的路径参数并存储
    // const { category1Id, category2Id } = event
    Object.assign(this.data.requestData, event)
    // 发请求获取商品列表数据
    this.getGoodsList()
  }
})

08. 商品列表-实现下拉刷新功能

下拉刷新是小程序中常见的一种刷新方式,当用户下拉页面时,页面会自动刷新,以便用户获取最新的内容。

小程序中实现上拉加载更多的方式:

  1. 页面.json 中开启允许下拉,同时可以配置 窗口、loading 样式等

  2. 页面.js 中定义 onPullDownRefresh 事件监听用户下拉刷新

落地代码:

➡️ /modules/goodsModule/pages/list/list.json

json
{
  "usingComponents": {
    "goods-card": "/components/goods-card/goods-card"
  },

  "navigationBarTitleText": "商品列表",
+  "enablePullDownRefresh": true, // 允许下拉刷新
+  "backgroundColor": "#f7f4f8", // 下拉刷新窗口背景色
+  "backgroundTextStyle": "dark" // 下拉刷新加载样式 dark/light
}

➡️ /modules/goodsModule/pages/list/list.js

js
// 监听下拉刷新
async onPullDownRefresh() {
  // 对数据进行重置并重新获取第一页的数据
  this.setData({
    goodsList: [],
    'requestData.page': 1
  })
  // 获取数据
  await this.getGoodsList()
  // 关闭Loading样式,避免其未自动关闭
  wx.stopPullDownRefresh()
},

注意:

有时下拉刷新后 Loading 效果可能不会消失,需要手动调用 wx.stopPullDownRefersh()进行关闭

09. 商品详情-获取并渲染商品详情

思路分析:

点击首页轮播图以及点击商品列表商品的时候,需要跳转到商品详情页面

在跳转时将商品的id 传递到了商品详情页面,只需要使用 id 向后端服务器请求数据,获取对应商品的详情数据

在获取到数据以后,使用后端返回的数据对页面进行渲染

实现步骤:

  1. /pages/goods/detail/detail.js 中导入封装好的获取商品列表的 API 函数
  2. 页面数据在页面加载的时候进行调用,在 onLoad 钩子函数中调用 reqGoodsInfo 方法
  3. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ /modules/goodsModule/pages/detail/detail.js

js
import { reqGoodsDetail } from '../../../../../api/goods'
Page({
  // 页面的初始数据
  data: {
+    goodsId: '',
+    goodsInfo: {}, // 商品详情
    show: false, // 控制加入购物车和立即购买弹框的显示
    count: 1, // 商品购买数量,默认是 1
    blessing: '' // 祝福语
  },
+  // 获取商品详情
+  async getGoodsInfo() {
+    const result = await reqGoodsDetail(this.data.goodsId)
+    // 存储商品详情数据
+    this.setData({
+      goodsInfo: result.data
+    })
  },
  // 加入购物车
  handleAddcart() {
    this.setData({
      show: true
    })
  },
  // 立即购买
  handeGotoBuy() {
    this.setData({
      show: true
    })
  },
  // 点击关闭弹框时触发的回调
  onClose() {
    this.setData({ show: false })
  },
  // 监听是否更改了购买数量
  onChangeGoodsCount(event) {
    this.setData({
      count: event.detail
    })
  },
+  onLoad(option) {
+    // 获取传递的商品id
+    this.setData({
+      goodsId: option.goodsId
+    })
+    // 获取商品详情数据
+    this.getGoodsInfo()
+  }
})

➡️ /modules/goodsModule/pages/detail/detail.html

html
<view class="container goods-detail">
  <!-- 商品大图 -->
  <view class="banner-img">
+     <image class="img" src="" data-missing="{{ goodsInfo.imageUrl }}" />
  </view>
  <!-- 商品的基本信息 -->
  <view class="content">
    <view class="price">
+       <view class="price-num">¥{{ goodsInfo.price }}</view>
+       <view class="price-origin-num">¥{{ goodsInfo.marketPrice }}</view>
    </view>
+     <view class="title">{{ goodsInfo.name }}</view>
+     <view class="desc">{{ goodsInfo.material }}</view>
  </view>
  <!-- 商品的详细信息 -->
  <view class="detail">
    <image
+       wx:for="{{ goodsInfo.detailList}}"
+       wx:key="index"
+       src="" data-missing="{{ item }}"
      class="img"
      mode="widthFix"
    />
  </view>
  <!-- 商品的底部商品导航 -->
  <van-goods-action>
    <!-- 代码略... -->
  </van-goods-action>
  <!-- 加入购物车、立即购买弹框 -->
  <!-- show 控制弹框的隐藏和展示 -->
  <!-- bind:close 点击关闭弹框时触发的回调 -->
  <van-action-sheet show="{{ show }}" bind:close="onClose">
    <view class="sheet-wrapper">
      <view class="goods-item">
        <!-- 需要购买的商品图片 -->
        <view class="mid">
+           <image class="img" src="" data-missing="{{ goodsInfo.imageUrl }}" />
        </view>
        <!-- 商品基本信息 -->
        <view class="right">
          <!-- 商品名字 -->
+           <view class="title"> {{ goodsInfo.name }} </view>
          <!-- 商品价格 -->
          <view class="buy">
            <view class="price">
              <view class="symbol">¥</view>
+               <view class="num">{{ goodsInfo.price }}</view>
            </view>
            <!-- 购买数量弹框 -->
            <view class="buy-btn">
              <!-- Stepper 步进器,由增加按钮、减少按钮和输入框组成,控制购买数量 -->
              <van-stepper value="{{ count }}" bind:change="onChangeGoodsCount" />
            </view>
          </view>
        </view>
      </view>
      <!-- 祝福语输入框 -->
      <view class="time-wraper">
        <!-- 代码略... -->
      </view>
      <!-- 取消、确定弹框 -->
      <view class="sheet-footer-btn">
        <van-button block type="primary" round> 确定 </van-button>
      </view>
    </view>
  </van-action-sheet>
</view>

10. 商品详情-详情图片预览功能

思路分析:

当点击商品的图片时,需要将图片进行全屏预览

如果想实现该功能,需要使用小程序提供的 APIwx.previewImage(),用来在新页面中全屏预览图片。预览的过程中用户可以进行保存图片、发送给朋友等操作。语法如下:支持Promise风格调用

js
wx.previewImage({
  current: '', // 当前显示图片的 http 链接
  urls: [], // 需要预览的图片 http 链接列表
  showmenu: true // 是否显示长按菜单
})

实现步骤:

  1. 给展示大图的 image 组件绑定点击事件,同时通过自定义属性的方式,传递当前需要显示的图片http 链接
  2. 同时商品详情的数组数据传递给 urls 数组即可

落地代码:

➡️ /pages/goods/detail/detail.html

html
<!-- 商品大图 -->
<view class="banner-img">
  <image
     class="img"
     src="" data-missing="{{ goodsInfo.imageUrl }}"
+     bindtap="previewImg"
  />
</view>

➡️ /pages/goods/detail/detail.js

js
// 点击商品大图全屏预览
previewImg() {
  wx.previewImage({
    urls: [this.data.goodsInfo.imageUrl], // 预览的图片url列表
    current: this.data.goodsInfo.imageUrl, // 当前预览图片的url
    showmenu: true // 显示长按菜单
  })
},

优化:配置 @ 路径别名优化访问路径

在对小程序进行分包时,如果访问小程序根目录下的文件,那么访问的路径就会很长。

在 Vue 中,可以使用 @ 符号指向源码目录,简化路径,小程序也给提供了配置的方式。

在小程序中可以在 app.json 中使用 resolveAlias 配置项用来自定义模块路径的映射规则。

官方文档: resolveAlias

json
// app.json
{
  .....
  "resolveAlias": {
    "@/*": "/*"
  }
}

📌:注意事项:

  1. resolveAlias 进行的是路径匹配,其中的 key 和 value 须以 /* 结尾
  2. 如果在 project.config.json 中指定了 miniprogramRoot,则 /* 指代的根目录是 miniprogramRoot 对应的路径,而不是开发者工具项目的根目录
  3. @ 只可以在 js文件中使用

十一、购物车

01. 购物车-封装购物车接口 API

思路分析:

为了方便后续进行购物车模块的开发,我们将购物车所有的接口封装成接口 API 函数

落地代码:

/api/cart.js

js
import http from '@/utils/http'
/* 加入购物车的接口方法
使用场景:在商品详情页面加入购物车、购物车更新商品数量
goodsId:商品id count:购买数据量 blessing:祝福语 */
export const reqAddCart = ({ goodsId, count, ...blessing }) =>
  http.get(`/cart/addToCart/${goodsId}/${count}`, blessing)
// 获取购物车列表的接口方法
export const reqCartList = () => http.get('/cart/getCartList')
/* 更新商品选中状态的接口方法
goodsId:商品id isChecked:是否勾选 */
export const reqUpdateChecked = (goodsId, isChecked) =>
  http.get(`/cart/checkCart/${goodsId}/${isChecked}`)
/* 全选与全不选的接口方法 
isChecked: 0:取消全选 1:全选*/
export const reqCheckAllStatus = (isChecked) =>
  http.get(`/cart/checkAllCart/${isChecked}`)
/* 删除购物车商品的接口方法
goodsId: 商品id */
export const reqDeleteGoods = (goodsId) =>
  http.get(`/cart/delete/${goodsId}`)

02. 加入购物车-模板分析和渲染

业务介绍

点击加入购物车和立即购买的时候,展示购物弹框,在弹框中需要用户选择购买数量和祝福语

点击加入购物车和立即购买,触发的是同一个弹框,因此点击弹框中的确定按钮时,我们需要区分当前是加入购物车操作还是立即购买操作

定义一个状态 buyNow 做区分,buyNow 等于 1 代表是立即购买,否则是加入购物车

产品需求:

  1. 如果点击的是加入购物车,需要将当前商品加入到购物车

  2. 如果点击的是立即购买,需要跳转到结算支付页面,立即购买该商品

  3. 如果是立即购买,不支持一次购买同意品牌多个商品

结构分析

点击立即购买和加入购物车的时候,通过 show 属性,控制弹框的隐藏和展示

html
<!-- 商品的底部商品导航 -->
<van-goods-action>
  <!-- coding... -->
+   <van-goods-action-button text="加入购物车" type="warning" bindtap="handleAddcart" />
+   <van-goods-action-button text="立即购买" bindtap="handeGotoBuy" />
</van-goods-action>
<!-- 加入购物车、立即购买弹框 -->
<!-- show 控制弹框的隐藏和展示 -->
<!-- bind:close 点击关闭弹框时触发的回调 -->
<van-action-sheet show="{{ show }}" bind:close="onClose">
  <view class="sheet-wrapper">
    <!-- 代码略... -->
    <!-- 购买数量弹框 -->
+     <view class="buy-btn" hidden="{{buyNow}}">
      <!-- Stepper 步进器,由增加按钮、减少按钮和输入框组成,控制购买数量 -->
      <van-stepper value="{{ count }}" bind:change="onChangeGoodsCount" />
    </view>
    <!-- 代码略... -->
  </view>
</van-action-sheet>

点击立即购买和加入购物车的时候,通过 buyNow 属性,来区分是进行的某种操作

js
Page({
  data: {
    goodsInfo: {}, // 商品详情
    show: false, // 加入购物车和立即购买时显示的弹框
    count: 1, // 商品购买数量,默认是 1
    blessing: '', // 祝福语
+   buyNow: 0 // 控制是加入购物车(0)还是立即购买(1)
  },
  

  // 加入购物车
  handleAddcart() {
    this.setData({
      show: true,
+     buyNow: 0
    })
  },

  // 立即购买
  handeGotoBuy() {
    this.setData({
      show: true,
+     buyNow: 1
    })
  },
  // 代码略...
})

03.加入购物车-权限校验判断用户是否登录

思路分析:

当用户点击加入购物车 或者 立即购买时,需要判断用户是否进行了登录,如果用户没有登录过,需要先跳转到登录页面进行登录

我们需要使用 Token 进行判断,因此需要让页面和 Store 对象建立关联

这时候可以使用 BehaviorWithStore 让页面 和 Store 对象建立关联

落地代码:

➡️ /behaviors/userStoreBehavior.js

js
// 导入 BehaviorWithStore 让页面和 Store 对象建立关联
import { BehaviorWithStore } from 'mobx-miniprogram-bindings'
// 导入用户 Store
import { userStore } from '@/stores/userstore'
export default BehaviorWithStore({
  storeBindings: {
    store: userStore, // 绑定的store
    fields: ['token'] // 要使用的属性
  }
})

➡️ /behaviors/userBehavior.js

js
+ import userStoreBehavior from '@/behaviors/userStoreBehavior'
Page({
+  // 声明使用userStoreBehavior获取userStore仓库中的数据
+  behaviors: [userStoreBehavior],
  // 代码略...
+ // 点击确定按钮的回调
+  save() {
+    // 判断用户是否登录
+    if (this.data.token) {
+      // 已经登录
+      // 表单验证
+      // 判断是加入购物车还是立即购买
+      if (this.data.buyNow) {
+        // 立即购买
+      } else {
+        // 加入购物车
+      }
+    } else {
+      // 未登录,跳转到登录页面
+      wx.navigateTo({
+        url: '/pages/login/login'
+      })
+    }
+  },
})

04. 加入购物车和立即购买区分处理

思路分析:

如果点击的是 加入购物车,我们只需要调用 加入购物车 接口即可 (需要获取商品的 ID 、购买数量、祝福语)

如果点击的是 立即购买,我们需要携带参数跳转到商品结算页面 (传递商品的ID 以及祝福语跳转到结算页面)

购买数量的限制有 4 个限制,这 4 个限制直接使用 Vant 组件提供的属性进行限制即可:

  1. 必须是正整数,最小是1,最大是200
  2. 若输入小于1,则重置为1
  3. 若输入大于200,则重置为200
  4. 若输入的是其他值,则重置为1

实现步骤:

  1. Stepper 步进器组件,通过value设置输入值,同时绑定change事件,并将值同步到 data
  2. 根据接口文档,导入封装的购物车的接口 API
  3. 点击弹框按钮的时候,判断点击的加入购物车还是立即购买,执行不同的操作

落地代码:

➡️ /modules/goodsModule/pages/detail/detail.html

html
<van-stepper
  value="{{ count }}"
+  integer
+  min="1"
+  max="200"
  bind:change="onChangeGoodsCount"
/>

➡️ /modules/goodsModule/pages/detail/detail.js

js
// 监听是否更改了购买数量
+  onChangeGoodsCount(event) {
+   // 将最新的购买数量同步到 data
+   this.setData({
+     count: Number(event.detail)
+   })
+ },

+ // 点击确定按钮的回调
+ async save() {
 + const { token, blessing, buyNow, goodsId, count } = this.data
 + // 判断用户是否登录
 + if (token) {
    // 已经登录
    // 表单校验,祝福语不能为空
 +   if (!blessing) {
 +     toast('祝福语不能为空!')
 +     return
 +   }
    // 判断是加入购物车还是立即购买
    if (buyNow) {
+      // 立即购买,跳转到订单详情页面
+      wx.navigateTo({
        url: `/pages/order/detail/detail?goodsId=${goodsId}&blessing=${blessing}`
+      })
    } else {
      // 加入购物车
+      await reqAddCart({ goodsId, count, blessing })
+      toast('加入购物车成功!', 'success')
+      this.setData({
+        // 关闭弹窗
+        show: false
+      })
    }
  } else {
    // 未登录,跳转到登录页面
    wx.navigateTo({
      url: '/pages/login/login'
    })
    return
  }
},

05. 加入购物车-展示购物车购买数量

思路分析

判断用户是否进行了登录

如果没有登录过,则不展示购物车商品的数量,当用户登录后在 onshow钩子函数中获取商品数量进行展示

如果用户登录过,则需要展示购物车商品的数量,则获取购物车列表数据,通过累加计算得出商品购买数量

实现步骤

  1. 进入商品详情,调用方法,在方法中判断token是否存在
  2. 如果存在,则获取购物车列表数据,通过累加计算得出商品购买数量,展示购买的数量
  3. 不存在,不执行任何逻辑,

落地代码

➡️ /modules/goodsModule/pages/detail/detail.wxml

html
<van-goods-action-icon
  icon="cart-o"
  text="购物车"
+  info="{{ allCount }}" // vant组件内置属性用于展示徽标
/>

➡️ /modules/goodsModule/pages/detail/detail.js

javascript
Page({
  data: {
    // coding...
+    allCount: '0' // 购物车内全部的商品数量 因为要展示99+因此使用字符串类型
  },
  .....
+  // 计算购物车商品数量
+  async getCartCount() {
+    // 判断用户是否登录,若未登录则购物车数量默认为0
+    if (!this.data.token) return
+    // 获取商品列表数据用于计算购物车中商品的总数
+    const result = await reqCartList()
+    // 用于临时存储商品总数
+    let allCount = 0
+    result.data.forEach((item) => {
+      allCount += item.count
+    })
+    // 如果商品总数大于99 则显示 99+
+    this.setData({
+      allCount: allCount > 99 ? '99+' : allCount.toString()
+    })
+  },
  // 点击确定按钮的回调
  async save() {
    const { token, blessing, buyNow, goodsId, count } = this.data
    // 判断用户是否登录
    if (token) {
      // 已经登录
      // 表单校验,祝福语不能为空
      if (!blessing) {
        toast('祝福语不能为空!')
        return
      }
      // 判断是加入购物车还是立即购买
      if (buyNow) {
        // 立即购买,跳转到订单详情页面
        wx.navigateTo({
          url: `/pages/order/detail/detail?goodsId=${goodsId}&blessing=${blessing}`
        })
      } else {
        // 加入购物车 发请求
        await reqAddCart({ goodsId, count, blessing })
        toast('加入购物车成功!', 'success')
        this.setData({
          // 关闭弹窗
          show: false
        })
+        // 重新计算购物车内商品总数
+        this.getCartCount()
      }
    } else {
      // 未登录,跳转到登录页面
      wx.navigateTo({
        url: '/pages/login/login'
      })
      return
    }
  },
  onLoad(option) {
    // 获取传递的商品id
    this.setData({
      goodsId: option.goodsId
    })
    // 获取商品详情数据
    this.getGoodsInfo()
    // 计算用户当前购物车中全部商品数量用于徽标展示
    this.getCartCount()
  },
+  // 当登录之后返回商品详情页面时更新徽标
+  onShow() {
+    // 获取当前购物车中商品总数
+    this.getCartCount()
+  }
})

06. 购物车-权限校验判断用户是否登录

思路分析:

当用户进入购物车页面时时,需要判断用户是否进行了登录来控制页面的展示效果

这时候我们就需要使用 Token 进行判断,因此需要让页面和 Store 对象建立关联。

因为购物车页面采用的 Component 方法进行构建

这时候可以使用 ComponentWithStore 让页面 和 Store 对象建立关联。

落地代码:

➡️/pages/cart/components/cart.js

js
+ import { ComponentWithStore } from 'mobx-miniprogram-bindings'
+ import { userStore } from '@/stores/userStore'
// 该页面使用Component进行构造可以使用ComponentWithStore与store关联,也可以使用behavior的方式
+ ComponentWithStore({
+  // 绑定仓库并获取仓库中的数据
+  storeBindings: {
+    store: userStore,
+    fields: ['token']
+  },
  data: {
    cartList: [1, 2, 3, 4],
    emptyDes: '还没有添加商品,快去添加吧~'
  },
  // 组件的方法列表
  methods: {
+   // 由于使用的是Component构造的页面因此所有方法必须放在methods中
+   // 页面的生命周期也需要在methods中进行声明
+    onShow() {
+      // 判断用户是否登录
+      if (this.data.token) return
+      // 跳转登录页面
+      wx.navigateTo({
+        url: '/pages/login/login'
+      })
+    }
  }
})

07. 购物车-获取并渲染购物车列表

思路分析:

  1. 如果没有进行登录,购物车页面需要展示:您尚未登录,点击登录获取更多权益

  2. 如果用户进行登录,获取购物车列表数据

    • 购物车没有商品,展示: 还没有添加商品,快去添加吧~

    • 购物车列表有数据,需要使用数据对页面进行渲染

实现步骤:

  1. 导入封装好的获取列表数据的 API 函数
  2. onShow 钩子中,根据产品的需求,处理页面的提示
  3. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️/pages/cart/cart.js

js
import { ComponentWithStore } from 'mobx-miniprogram-bindings'
import { userStore } from '@/stores/userStore'
+ import { reqCartList } from '@/api/cart'
// 该页面使用Component进行构造可以使用ComponentWithStore与store关联,也可以使用behavior的方式
ComponentWithStore({
  // 绑定仓库并获取仓库中的数据
  storeBindings: {
    store: userStore,
    fields: ['token']
  },
  data: {
+    cartList: [], // 购物车商品列表
+    emptyDes: '还没有添加商品,快去添加吧~'
  },
  // 组件的方法列表
  methods: {
    // 由于使用的是Component构造的页面因此所有方法必须放在methods中
    // 获取购物车列表数据
+    async getCartList() {
+      const result = await reqCartList()
+      // 对购物车列表商品进行存储,修改空状态文字
+      this.setData({
+        cartList: result.data,
+        emptyDes:
+          result.data.lenght === 0 && '还没有添加商品,快去添加吧~'
+      })
+    },
    // 页面的生命周期也需要在methods中进行声明
    // 此处使用onShow而不使用onLoad原因:在页面切换时需要重新获取商品列表的数据(tabbar页面切换时不会进行销毁)
    onShow() {
      // 判断用户是否登录
      if (this.data.token) {
+        // 获取商品列表数据
+        this.getCartList()
+      } else {
+        // 设置空状态提示文字
+        this.setData({
+          emptyDes: '您尚未登录,点击登录获取更多权益'
+        })
      }
    }
  }
})

➡️/pages/cart/components/cart.wxml

html
<view>
  <view
    wx:if="{{ token && cartList.length }}"
    class="container goods-wrap"
    bindtap="onSwipeCellPageTap"
  >
    <view class="cart-wrap">
+       <view class="goods-item" wx:for="{{ cartList }}" wx:key="id">
        <van-swipe-cell class="goods-swipe" right-width="{{ 65 }}">
          <view class="goods-info">
            <view class="left">
              <van-checkbox
                checked-color="#FA4126"
+                 value="{{ item.checked }}"
              ></van-checkbox>
            </view>
            <view class="mid">
+               <image class="img" src="" data-missing="{{ item.imageUrl }}" />
            </view>
            <view class="right">
+               <view class="title"> {{ item.name }} </view>
              <view class="buy">
                <view class="price">
                  <view class="symbol">¥</view>
+                   <view class="num">{{ item.price }}</view>
                </view>
                <view class="buy-btn">
+                   <van-stepper value="{{ item.count }}" />
                </view>
              </view>
            </view>
          </view>
          <view slot="right" class="van-swipe-cell__right">删除</view>
        </van-swipe-cell>
      </view>
    </view>

    <!-- 底部工具栏 -->
    <van-submit-bar price="{{ 3050 }}" button-text="去结算" tip="{{ true }}">
      <van-checkbox value="{{ true }}" checked-color="#FA4126"> 全选 </van-checkbox>
    </van-submit-bar>
  </view>
  <van-empty wx:else description="{{ emptyDes }}">
+     <navigator url="/pages/index/index" wx:if="{{ token }}" open-type="switchTab">
+       <van-button round type="danger" class="bottom-button">去购物</van-button>
+     </navigator>
+     <navigator url="/pages/login/login" wx:else>
+       <van-button round type="danger" class="bottom-button">去登录</van-button>
+     </navigator>
  </van-empty>
</view>

08. 购物车-更新商品的购买状态

思路分析:

点击商品的复选框时,更新商品的购买状态。

  1. 获取商品最新的购买状态,将最新的状态同步到服务器(需要调用封装的接口 API 函数,0 不购买,1 购买)
  2. 在服务器更新状态更新成功以后,将本地的数据一并改变。

实现步骤:

  1. 导入封装好的获取列表数据的 API 函数
  2. 当点击切换切换商品状态的时候,调用 reqUpdateGoodStatus,并传参
  3. 在更新成功,将本地的数据一并改变。

落地代码:

➡️ /pages/cart/cart.wxml

html
<van-checkbox
  checked-color="#FA4126"
+   value="{{ item.isChecked }}"
+   bind:change="updateChecked" // 当复选框选中状态变化时触发
+   data-id="{{ item.goodsId }}" // 传递当前商品ID
+   data-index="{{ index }}" // 传递当前商品下标
></van-checkbox>

➡️ /pages/cart/cart.js

js
import { ComponentWithStore } from 'mobx-miniprogram-bindings'
import { userStore } from '@/stores/userstore'
+ import { reqCartList, reqUpdateChecked } from '@/api/cart'
Component({
    .....
  // 组件的方法列表
  methods: {
+    // 更新商品购买状态
+    async updateChecked(event) {
+      // 获取最新的购买状态 event.detail
+      let isChecked = event.detail ? 1 : 0 // true/false
+      // 获取事件传递的参数 商品id,当前商品的下标
+      const { goodsid, index } = event.target.dataset
+      // 更新服务器商品购买状态
+      await reqUpdateChecked(goodsid, isChecked)
+      // 更新本地商品购买状态(修改本地数据/重新获取服务器中的数据)
+      this.data.cartList[index].isChecked = isChecked // 更新数据不触发视图更新
+      this.setData({
+        cartList: this.data.cartList //触发视图更新
         // [`cartList[${index}].isChecked`]:isChecked // 同时进行更新
+      })
    },
  ....
})

09. 购物车-控制全选按钮的选中状态

思路分析:

购物车列表中每个商品的状态 isCheckd 都是 1,说明每个商品都需要进行购买。

这时候就需要控制底部工具栏全选按钮的选中效果。

基于购物车列表中已有的数据,产生一个新的数据,来控制全选按钮的选中效果,可以使用 计算属性 来实现。

安装并构建框架拓展 computed

cmd
npm i miniprogram-computed

实现步骤:

  1. cart 组件中引入 miniprogram-computed ,然后再 behaviors 中进行注册
  2. 新建 computed 配置项,新增 selectAllStatus 计算属性用来判断是否是全选

落地代码:

➡️ /pages/cart/cart.js

js
import { ComponentWithStore } from 'mobx-miniprogram-bindings'
import { userStore } from '@/stores/userstore'
import { reqCartList, reqUpdateChecked } from '@/api/cart'
+ const computedBehavior = require('miniprogram-computed').behavior

ComponentWithStore({
    
+   // 注册计算属性
+   behaviors: [computedBehavior],
.....
+   computed: {
+     // 判断是否全选
+     // computed 函数中不能访问 this ,只有 data 对象可供访问
+     // 这个函数的返回值会被设置到 this.data.selectAllStatus 字段中
+     selectAllStatus(data) {
+       return (
+         data.cartList.length !== 0 && data.cartList.every((item) => item.isChecked === 1)
+       )
+     }
+   }
......
})

➡️ /pages/cart/cart.wxml

html
<!-- 底部工具栏 -->
<van-submit-bar price="{{ 3050 }}" button-text="去结算" tip="{{ true }}">
+   <van-checkbox value="{{ selectAllStatus }}" checked-color="#FA4126">
    全选
  </van-checkbox>
</van-submit-bar>

10. 购物车-实现全选和全不选功能

思路分析:

点击全选,控制所有商品的选中与全不选效果

  1. 点击全选按钮,获取全选按钮的选中状态(true, false),同时控制所有商品的选中与全不选效果
  2. 在获取到全选按钮状态以后,同时需要将状态同步给服务器 (1 是全选,0 是全不选)
  3. 在服务器更新成功以后,需要将本地的购物车商品选中状态也进行改变

实现步骤:

  1. 导入封装好的全选的 API 函数
  2. 当点击全选和全不选按钮的时候,调用 reqCheckAll,并传参
  3. 在更新成功,将本地的数据一并改变。
  4. 计算属性不可直接进行修改,需要通过修改依赖计算的数据,使计算属性重新计算

落地代码:

➡️ /pages/cart/cart.wxml

html
<!-- 底部工具栏 -->
<van-submit-bar price="{{ 3050 }}" button-text="去结算" tip="{{ true }}">
  <van-checkbox
    value="{{ selectAllStatus }}"
    checked-color="#FA4126"
    bind:change="updateSelectAllStatus"
  >
    全选
  </van-checkbox>
</van-submit-bar>

➡️ /pages/cart/cart.js

js
.....
// 引入计算属性和监视器
const computedBehavior = require('miniprogram-computed').behavior
ComponentWithStore({
  // 声明并使用计算属性behavior
  behaviors: [computedBehavior],
  // coding...
  // 计算属性 会被挂载到data上
  computed: {
    // 计算商品是否全选
    // 计算属性不能使用this访问data中的数据,需要使用参数data进行访问
    selectAllStatus(data) {
      if (data.cartList.lenght === 0) return false
      // every() 方法测试一个数组内的所有元素是否都能通过指定函数的测试,返回一个布尔值
      return data.cartList.every((item) => item.isChecked === 1)
    }
  },
+  // 监视器
+  watch: {
+    // 当全选状态发生变化时,向服务器发请求更新全选状态
+    selectAllStatus: async function (newVal) {
+      await reqCheckAllStatus(newVal ? 1 : 0) // 该接口接收 0/1
+    }
+  },  
  methods: {
    // coding...
+   // 更新商品的全选状态
+    async updateSelectAllStatus() {
+      // selectAllStatus为计算属性,不能直接进行修改,需要通过修改依赖计算的数据实现修改
+      // map() 方法创建一个新数组,这个新数组由原数组中的每个元素都调用一次提供的函数后的返回值组成
+      this.data.selectAllStatus
+        ? this.data.cartList.map((item) => (item.isChecked = 0))
+        : this.data.cartList.map((item) => (item.isChecked = 1))
+      // 响应式更新数据
+      this.setData({
+        cartList: this.data.cartList
+      })
+    },
    // coding...
  }
})

11. 购物车-更新商品购买数量思路分析

思路分析:

在输入框中输入购买的数量,并不是直接将输入的数量同步给服务器,而是需要计算差值,服务器端进行处理

差值的计算公式:

js
差值 = 新值 - 旧值
例如:
1. 原来是 1,用户输入 11, 差值是:11 - 1 = 10,传递给服务器的是:10,服务器接收到 10 + 1 = 11 
2. 原来是 11,用户输入 5, 差值是:5 - 11 = -6,传递给服务器的是:-6,服务器接收到 -6 + 11 = 5

📌 注意事项:

更新购买数量 和 加入购物车,使用的是同一个接口,为什么加入购物车没有计算差值,

这是因为在加入购物车以后,服务器对商品购买数量直接进行了累加。

例如:之前购物车添加了某个商品,购买数量是 1 个,商品详情又加入 1 个, 直接累加,在购物车显示购买 2 个

12. 购物车-更新商品的购买数量

思路分析:

  1. 必须是正整数,最小是1,最大是200
  2. 如果输入的值大于200,输入框购买数量需要重置为200
  3. 输入的值不合法或者小于1,还原为之前的购买数量

使用 vantUI 组件 van-stepperinteger、min、max属性进行限制或使用正则

实现步骤:

  1. 给输入框绑定监听值是否改变的事件,同时传递商品的 ID id 和 商品的购买之前的购买数量 num
  2. 在事件处理程序中获取到最新的数据,然后进行差值的运算
  3. 发送请求更新服务器端的数据
  4. 更新本地的数据
  5. 将该商品变为选中状态

落地代码:

➡️ /pages/cart/cart.wxml

html
<van-stepper
+   integer  // 整数
+   min="1"  // 最小值
+   max="200" // 最大值
  value="{{ item.count }}"
+   data-id="{{ item.goodsId }}"
+   data-oldbuynum="{{ item.count }}"
+   data-index="{{ index }}"
+   bindchange="changeBuyNum"
/>

➡️ /pages/cart/cart.js

js
+// 更新商品购买数量
+async changeByNum(event) {
+  // 获取传递的事件参数 商品id、下标、之前购买数量
+  const { goodsid, index, oldbuynum } = event.target.dataset
+  // 获取最新的购买数量
+  let newBuyNum = event.detail
+  // 计算购买数量的差值
+  let disCount = newBuyNum - oldbuynum
+  // 如果购买数量没发生变化则无需进行更改
+  if (disCount === 0) return
+  // 发请求更新服务器端的购买数量(需要传递购买数量的差值)
+  const result = await reqAddCart({
+    goodsId: goodsid,
+    count: disCount
+  })
+  // 将本地数据中的购买数量进行更新
+  if (result.code === 200) {
+    this.setData({
      [`cartList[${index}].count`]: newBuyNum
+     // 将商品变为选中状态
      [`cartList[${index}].isChecked`]: 1
+    })
+  }
+},

拓展:

​ 正则的使用:

js
let num = 99
const reg = /^([1-9]|[1-9]\d|1\d{2}|200)$/ // 验证 1-200 之间的整数
const result = reg.test(num) // 验证成功返回 true 失败返回 false

13. 购物车-更新商品购买数量防抖

思路分析:

每次改变购物车购买数量的时候,都会触发 changeBuyNum 事件处理程序,这会频繁的向后端发送请求,给服务器造成压力

我们希望用户在输入最终的购买数量,或者停止频繁点击加、减的以后再发送请求,再将购买数量同步到服务器

这时候就需要使用 函数防抖 来进行代码优化

Licia 是实用 JavaScript 工具库,该库目前拥有超过 400 个模块,同时支持浏览器、node 及小程序运行环境。可以极大地提高开发效率

licia 官网

licia 中文使用文档 debounce函数防抖:在指定时间内多次调用函数,只执行最后一次的调用

使用步骤:

  1. 安装 npm i miniprogram-licia --save
  2. 构建 npm
  3. miniprogram-licia 导入防抖函数 import { debounce } from 'miniprogram-licia'
  4. 使用 debounce() 包裹需要防抖的函数,并设置延迟毫秒数

落地代码:

➡️ /pages/cart/cart.js

js
// 从 miniprogram-licia 导入防抖函数
import { debounce } from 'miniprogram-licia'

// 更新购买的数量
+ changeBuyNum: debounce(async function (event) {
+   // 代码略...
+ }, 500)
// 在500ms每多次频繁调用changeBuyNum函数则只执行最后一次调用

14. 购物车-购物车商品合计

思路分析:

在订单提交栏位置,展示要购买商品的总金额

需要判断购物车中哪些商品被勾选,然后将勾选商品的价格进行累加

当用户更新了商品的状态,或者更新了商品的购买数量,我们都需要重新计算订单总金额

我们需要基于购物车列表的数据,产生订单总金额,在这里我们使用依然使用 computed 来实现商品合计的功能也可以使用 watch来实现

计算属性:依赖计算的数据发生变化,会重新进行计算(在商品状态和购买数量发生变化时修改了本地的数据,因此会触发计算属性重新计算)

实现步骤:

  1. computed 配置项,新增 totalPrice 函数用来计算商品价格总和

落地代码:

➡️ /pages/cart/cart.wxml

html
<!-- 底部工具栏 -->
<van-submit-bar
  wx:if="{{ cartList.length }}"
+  price="{{ totalPrice }}"
  button-text="去结算"
  tip="{{ true }}"
>
  <van-checkbox
    value="{{ selectAllStatus }}"
    checked-color="#FA4126"
    bindchange="selectAllStatus"
  >
    全选
  </van-checkbox>
</van-submit-bar>

➡️ /pages/cart/cart.js

js
ComponentWithStore({
  // coding...
  // 定义计算属性
  computed: {
    // coding...
    // 计算商品价格总和
    // 计算订单总金额
+    totalPrice(data) {
+      // reduce() 方法对数组中的每个元素按序执行一个提供的 reducer 函数,每一次运行 reducer 会将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。
+      return data.cartList.reduce((allPrice, item) => {
+        return item.isChecked
+          ? (allPrice += item.price * item.count)
+          : allPrice
+      }, 0)
+    }
+  },
  // coding...
})

15. 购物车-删除购物车中的商品

思路分析:

点击删除按钮的时候,需要将对应的购物车商品进行删除

实现步骤:

  1. 导入封装的接口 API 函数,同时导入处理删除自动关闭效果的 behaviors 并进行注册
  2. 在点击删除以后,调用 API 函数,在删除购物车商品成功以后,给用户提示

落地代码:

➡️ /pages/cart/components/cart.wxml

html
+  <view bindtap="closeSwipe">
    
  <!-- 代码略 -->
    
  <van-swipe-cell
    class="goods-swipe"
    right-width="{{ 65 }}"
+     id="swipe-cell-{{ item.goodsId }}"
+     bind:open="swipeCellOpen"
+     bind:click="closeSwipe"
  >
    <van-cell-group border="{{ false }}">
	......
    </van-cell-group>
    <view
      slot="right"
      class="van-swipe-cell__right"
+       bindtap="deleteGoods"
+       data-goodsId="{{ item.goodsId }}"
    >
      删除
    </view>
  </van-swipe-cell>
 .....
</view>

➡️ /pages/cart/components/cart.wxml

js
// 导入接口 API 函数
import {
  reqCartList,
  reqUpdateChecked,
  reqCheckAllStatus,
  reqAddCart,
+   reqDelCartGoods
} from '@/api/cart'
+ // 导入封装好的 swiperCellBehavior 用于处理滑块自动弹回
+ import swiperCell from '@/behaviors/swipeCell'

ComponentWithStore({
+  // 声明并使用计算属性behavior,和用于处理滑块自动弹回的behavior
+  behaviors: [computedBehavior, swiperCell],
    
  // 组件的方法列表
  methods: {
    // coding...
    // 删除购物车中商品的方法
+    async deleteGoods(event) {
+      // 获取事件函数传递的商品id
+      const { goodsid } = event.target.dataset
+      // 模态框提示用户是否删除
+      const modalRes = await modal('提示', '你确定要删除该商品吗?') //true/false
+      // 点击取消
+      if (!modalRes) return
+      // 点击确定
+      const result = await reqDeleteGoods(goodsid)
+      if (result.code === 200) {
+        toast('删除成功~')
+        // 重新获取购物车列表数据
+        this.getCartList()
+      }
+    },
+    // 购物车页面隐藏时需要将滑块弹回
+    onHide() {
+      // 调用 swiperCell behavior中的closeSwipe方法进行关闭
+      this.closeSwipe()
+    }
})

behaviors/swipeCell.js 用于处理滑块自动回弹

js
export default Behavior({
  data: {
    // 当前打开的滑块的实例对象
    swipCellInstance: {}
  },
  methods: {
    // 用户打开滑块触发的事件回调
    swipCellOpen(event) {
      // 获取当前滑动的滑块的id 每个滑块的id互不同
      const { id } = event.target
      // 获取当前滑动滑块的实例对象
      const instance = this.selectComponent(`#${id}`)
      // 存储当前滑动滑块的实例对象
      this.setData({
        swipCellInstance: instance
      })
    },
    // 用户点击空白区域或点击地址滑块时关闭已经打开的滑块
    closeSwipe() {
      // 如果没有获取到组件实例对象则不调用close方法
      if (this.data.swipCellInstance.close) {
        // 调用滑块实例的close方法关闭s打开的滑块
        this.data.swipCellInstance.close()
      }
    }
  }
})

十二、结算支付

01. 配置分包并跳转到结算页面

思路分析:

随着项目功能的增加,项目体积也随着增大,从而影响小程序的加载速度,影响用户的体验。

因此我们需要将 订单结算支付 功能配置成一个分包,

当用户在访问购物车或我的页面时,还预先加载 订单结算支付 所在的分包

落地代码:

➡️ app.json

json
"subPackages": [
  {
    "root": "modules/settingModule",
    "name": "settingModule",
    "pages": [
      "pages/address/add/index",
      "pages/address/list/index",
      "pages/profile/profile"
    ]
  },
  {
    "root": "modules/goodModule",
    "name": "goodModule",
    "pages": ["pages/goods/list/list", "pages/goods/detail/detail"]
  },
+   {
+     "root": "modules/orderPayModule", // 分包路径
+     "name": "orderPayModule", // 分包别名
+     "pages": [ // 包含的页面
+       "pages/order/detail/detail",
+       "pages/order/list/list"
+     ]
+   }
],
"preloadRule": {  // 配置分包预下载
  "pages/settings/settings": {
    "network": "all",
    "packages": ["settingModule"]
  },
  "pages/category/category": {
    "network": "all",
    "packages": ["goodModule"]
  },
+   "pages/cart/cart": { // 在访问购物车页面时进行预下载
+     "network": "all", // 所有网络下
+     "packages": ["orderPayModule"] // 分包名称
+   },
+    "pages/my/my": {
+      "network": "all",
+      "packages": ["orderPayModule"]
+    }
}

修改访问路径

使用全局搜索替换功能image-20240718095819706

/pages/order/list/list/pages/order/detail/detail替换为: /modules/orderPayModule/pages/order/detail/detail/modules/orderPayModule/pages/order/list/list

即在原本的访问路径前加上分包路径 /modules/orderPayModule

➡️ pages/cart/cart.js

js
// 点击去结算按钮回调
toOrder() {
  // 判断用户是否选择了购买商品 总金额大于零
  if (this.data.totalPrice === 0) {
    toast('请选择要购买的商品')
    return
  }
  // 跳转到结算页面
  wx.navigateTo({
    url: '/modules/orderPayModule/pages/order/detail/detail'
  })
},

➡️ pages/cart/cart.wxml

html
<van-submit-bar
  wx:if="{{ cartList.length }}"
  price="{{ totalPrice * 100 }}"
  button-text="去结算"
  tip="{{ true }}"
+  bindsubmit="toOrder"  // 去结算按钮点击事件回调
>
  <van-checkbox
    value="{{ selectAllStatus }}"
    checked-color="#FA4126"
    bindchange="selectAllStatus"
  >
    全选
  </van-checkbox>
</van-submit-bar>

02. 封装结算支付的接口 API

思路分析:

为了方便后续进行结算支付模块的开发,我们将结算支付所有的接口封装成接口 API 函数

落地代码:

➡️ /api/orderpay.js

js
import http from '@/utils/http'

// 获取订单详情的接口方法
export const reqOrderInfo = () => http.get('/order/trade')

// 获取订单(默认收货)地址的接口方法
export const reqOrderAddress = () =>
  http.get('/userAddress/getOrderAddress')

/* 获取立即购买商品详情的接口方法(在商品详情页面点击立即购买) 
goodsId:商品Id(必填) blessing:祝福语(选填) */
export const reqBuyNowGoods = ({ goodsId, ...blessing }) =>
  http.get(`/order/buy/${goodsId}`,blessing)

/* 提交订单的接口方法
data:订单详情信息(对象) */
export const reqSubmitOrder = (data) =>
  http.post('/order/submitOrder', data)

/* 获取微信预支付信息
orderNo:提交订单成功服务器返回的订单号*/
export const reqPrePayInfo = (orderNo) =>
  http.get(`/webChat/createJsapi/${orderNo}`)

/* 微信支付状态查询
orderNo:提交订单成功服务器返回的订单号*/
export const reqPayStatus = (orderNo) =>
  http.get(`/webChat/queryPayStatus/${orderNo}`)

03. 商品结算-获取收货地址

思路分析:

进入结算支付页面后,需要获取收货地址信息,在获取到收货地址以后,需要进行判断,

如果没有获取到默认收货地址,需要展示添加收货地址的结构,

如果获取到了默认收货地址,需要渲染收货地址

实现步骤:

  1. 在进入结算页面的时候,调用接口 API 函数,获取数据
  2. 然后根据数据并渲染结构

落地代码:

➡️ /pages/order/detail/index.js

js
import { getTradeAddress } from '../../../api/order'

Page({
  data: {
    // coding...
+     orderAddress: {} // 收货地址
  },
+  // 获取订单收货地址
+  async getOrderAddress() {
+    const result = await reqOrderAddress()
+    this.setData({
+      orderAddress: result.data
+    })
+  },
+  // 页面展示时触发不能使用onLoad(只执行一次)
+  onShow() {
+    this.getOrderAddress()
+  }
})

➡️ /pages/order/detail/index.wxml

html
<!--pages/order/index.wxml-->
<view class="container order">
  <view class="address-card">
    <!-- 添加收货地址 -->
+    <view wx:if="{{ !tradeAddress.id }}" class="add-address"  bindtap="toAddress">
      <van-icon size="22px" name="add" />
      <view>添加收货地址</view>
    </view>
    <!-- 展示收货地址 -->
+    <view wx:else class="order-address flex">
      <view class="address-content">
        <view class="title">{{ tradeAddress.fullAddress }}</view>
        <view class="info flex">
          <text>{{ tradeAddress.name }}</text>
          <text>{{ tradeAddress.phone }}</text>
        </view>
      </view>
	.....
</view>

04. 商品结算-更新收货地址功能

思路分析:

当用户需要更改收货地址时,我们需要跳转到收货地址页面,重新选择收货地址

当用户点击了某个地址以后,我们需要将该地址显示到商品结算页面中。

更新收货地址功能,采用 getApp() 全局共享数据的方式来实现(或存储仓库/发布订阅)。

实现步骤:

  1. app.js 中定义全局共享的数据 globalData.address
  2. 点击箭头,携带参数跳转到收货地址页面,标识是从订单结算页面进入
  3. 在选择收货地址成功以后,将数据存储到 globalData.address中,然后返回到订单结算页面。
  4. 在订单结算页面判断 globalData.address 是否存在收货地址数据,如果存在则渲染

落地代码:

➡️ app.js

js
App({
+ // 配置全局共享数据
  // 在点击收货地址时,需要将点击的收货地址赋值给address
  // 在订单结算页面需要判断 address是否存在数据,如果存在数据就展示,没有数据就从接口获取数据进行渲染
+   globalData: {
+    address: {}
+  }
  // coding...
})

➡️ /pages/address/list/index.html

html
<!-- 每一个收货地址 -->
<view
  class="info"
+   bindtap="changeAddress"
+   data-address="{{ item }}" // 传递收货地址
>
  <view class="user-info">
    <text>{{ item.name }}</text>
    <text>{{ item.phone }}</text>
    <text wx:if="{{ item.isDefault === 1 }}" class="default-tag">默认</text>
  </view>
</view>

➡️ /pages/address/list/index.js

js
// 导入接口 API 函数
import { reqAddressList, reqDelAddress } from '@/api/address'
import { swipeCellBehavior } from '@/behaviors/swipeCell'

+ // 获取全局的应用实例
+ const app = getApp()
	
Page({
.....
+  // 点击收货地址的事件回调 当路径参数中存在 from=detail 更新App组件中的全局共享数据globalData.address 存储当前点击的收货地址
+  changeAddress(event) {
+    // 判断是否是从结算支付页面跳转到收货地址列表页面
+    if (this.from != 'payDetail') return
+    // 获取点击的收货地址
+    const { address } = event.target.dataset
+    // 收货地址不为空
+    if (address) {
+      // 将点击收货地址id存储到 globalData.address
+      app.globalData.address = address
+      // 返回结算页面
+      wx.navigateBack()
+    }
+  },

+  // 获取路径传递的参数
+  onLoad(options) {
+    // 将传递的参数挂载到this上便于进行访问
+    this.from = options.from
+  }
})

➡️ /pages/order/detail/index.wxml

html
<view class="select-address">
  <!-- 添加query参数from是为了标识是从商品详情页面跳转到收货地址列表页面,在点击收货地址时,需要给全局共享的addres进行赋值 -->
  <navigator
    class="navigator"
+     url="/modules/settingModule/pages/address/list/index?from=payDetail"
  >
    <van-icon color="#bbb" name="arrow" size="22px" />
  </navigator>
</view>

➡️ /pages/order/detail/index.js

js
  // 获取订单收货地址
  async getOrderAddress() {
+    // 判断 app.globalData.address 中是否存储有数据
+    if (app.globalData.address.id) {
+      // 存在收货地址,使用该地址作为收货地址
+      this.setData({
+        orderAddress: app.globalData.address
+      })
+      return
+    }
    // 不存在收货地址,则发请求获取默认收货地址作为收货地址
    const result = await reqOrderAddress()
    this.setData({
      orderAddress: result.data
    })
  },
+  onUnload() {
+    // 在当前页面销毁后
+    // 清空全局共享数据中的收货地址,下次购买商品依然使用默认地址作为收货地址
+    app.globalData.address = {}
+  }

Tips:

将需要全局共享的数据存储到 App.js 中,在任意页面/组件中通过 getApp()获取全局应用实例,即可访问修改全局共享数据(组件通信的一种方式,但数据不是响应式的,每次的需要重新获取)

注意:

空对象 {}、空数组 []true 因此判断数组非空一般使用 array.length 判断对象使用 object.属性名

空字符串 ''undefinedfalse

05. 商品结算-获取订单详情数据

思路分析:

商品结算页面数据获取收货地址以及商品订单信息

实现步骤:

  1. 导入封装的接口 API 函数
  2. 在进入结算页面的时候,调用接口 API 函数,获取数据,然后根据数据并渲染结构即可

落地代码:

➡️ /pages/order/detail/index.js

js
+ import { reqOrderAddress, reqOrderInfo } from '@/api/orderpay'

Page({
  data: {
    // coding...
    orderAddress: {}, // 收货地址
+   orderInfo: {}, // 订单商品详情
  },
     
+ // 获取订单详情数据
+  async getOrderInfo() {
+    // 解构并重命名获取到的数据
+    const { data: orderInfo } = await reqOrderInfo()
+    // 获取祝福语,若购买多个商品获取第一个填写了祝福语的商品进行赋值
+    // find()方法返回数组中满足提供的测试函数的第一个元素的值否则返回 undefined
+    const { blessing } =
+      orderInfo.cartVoList.find((item) => item.blessing) || ''
+    // 存储订单数据
+    this.setData({
+      orderInfo: orderInfo,
+      blessing: blessing
+    })
+  },

  // 在页面展示的时候进行触发
  onShow() {
    // 获取收货地址
    this.getAddress()
+   // 获取订单结算页面的商品信息
+   this.getOrderInfo()
  },
})

➡️ /pages/order/detail/index.wxml

html
...
  <view class="goods-wraper">
    <!-- 商品清单 -->
    <view class="goods-list">
+       <view class="goods-item flex" wx:for="{{ tradeInfo.cartVoList }}" wx:key="goodsId">
        <view class="img">
+           <image src="" data-missing="{{ item.imageUrl }}" />
        </view>
        <view class="content">
+           <view class="goods-title">{{ item.name }}</view>
          <view class="goods-price">
+             <view class="price"> ¥ {{ item.price }}</view>
+             <view>x {{ item.count }}</view>
          </view>
        </view>
      </view>
    </view>
  </view>
.....
  <!-- 支付区域 -->
  <view class="footer flex">
+     <view class="left"> ¥ {{ tradeInfo.totalAmount }} </view>
    <viwe class="right">结算</viwe>
  </view>
....
</view>

06. 商品结算-获取立即购买数据

思路分析:

当用户从商品详情点击立即购买进入商品结算页面的时候,我们需要在商品结算页面展示立即购买商品的基本信息。

在跳转到商品结算页面的时候,我们已经携带了商品的 id祝福语

在结算页面,只需要获取到传递的参数,然后根据传递的参数调用接口即可。

实现步骤:

  1. 在页面打开的时候,onLoad 中接受传递的参数,并赋值给 data 中的状态
  2. 在获取订单详情getOrderInfo 函数中,判断立即购买商品的 id 是否存在,如果存在调用立即购买的接口,不存在则调用获取全部订单接口的方法
  3. 获取数据后,然后根据数据并渲染结构即可

落地代码:

➡️ /pages/order/detail/index.js

js
import {
  reqOrderAddress,
  reqOrderInfo,
+   reqBuyNowGoods
} from '@/api/orderpay'

Page({
  data:{
    ....
+   goodsId:'', // 立即购买商品的id
  }
   
  // 获取订单详情数据
  async getOrderInfo() {
+    // 判断是否存在 goodsId,若存在获取立即购买商品的数据,不存在获取订单详情
+    const { goodsId, blessing } = this.data
+    // 解构并重命名获取到的数据
+    const { data: orderInfo } = goodsId
+      ? await reqBuyNowGoods({ goodsId, blessing })
+      : await reqOrderInfo()
    // 获取祝福语,若购买多个商品获取第一个填写了祝福语的商品进行赋值
    // find()方法返回数组中满足提供的测试函数的第一个元素的值否则返回 undefined
+    const { blessing: orderBlessing } = orderInfo.cartVoList.find(
+      (item) => item.blessing
+    ) || { blessing: '' }
    // 存储订单数据
    this.setData({
      orderInfo: orderInfo,
      blessing: orderBlessing
    })
  },
    
  // 页面展示时触发不能使用onLoad(只执行一次)
  onShow() {
    // 获取默认收货地址
    this.getOrderAddress()
    // 获取订单数据
    this.getOrderInfo()
  },
+  // 获取从商品详情页面传递的参数
+  onLoad(options) {
+    // 获取从商品详情页面传递的商品id和祝福语
+    if (!options.goodsId) return
+    const { goodsId, blessing } = options
+    // 存储商品id和祝福语
+    this.setData({
+      goodsId,
+      blessing
+    })
+  },
})

07. 商品结算-收集送达时间

思路分析:

当选择送达日期的时候,需要选择收货的时间,我们希望获取到的收货的时间格式是:年月日

但是我们使用的是小程序提供的 vant 组件,组件返回的时候并不是真正的时分秒,而是时间戳

这时候可以调用小程序项目初始化时,小程序封装的时间格式化工具

实现步骤:

  1. 在商品结算页面导入封装好的格式化时间的方法 formatTime,项目初始化时小程序自动生成的,也可以使用第三方库 moment.js/day.js/Licia

  2. 调用 formatTime ,传入需要格式化的时间戳

    落地代码:

➡️ /pages/order/detail/index.js

js
import { formatTime } from '../../../utils/formatTime.js'

Page({
  data:{
    .....
+    deliveryDate: '', // 期望送达日期
+    minDate: new Date().getTime() // 当前日期时间戳
  }

+  // 期望送达日期确定按钮
+  onConfirmTimerPicker(event) {
+    // 使用Vant组件提供的时间选择组件,获取到选择的时间戳
+    // 需要将时间戳转换成 年月日 格式
+    // 可以调用小程序提供的格式化方法对时间进行转换
+    // formatTime方法接收JS的日期对象作为参数,因此需要将时间戳转换为JS的日期对象
+    const timeRes = formatTime(new Date(event.detail))
+    this.setData({
+      show: false,
+      deliveryDate: timeRes
+    })
+  },
 .....
}

08. 商品结算-表单数据验证

思路分析:

使用 async-validator 对代码进行验证

  1. 收货地址不能为空
  2. 订购人姓名不能为空,且不能输入特殊字符
  3. 订购人手机号不能为空,且输入的手机号必须合法
  4. 送达日期不能为空

落地代码:

js
import {
  reqOrderAddress,
  reqOrderInfo,
  reqBuyNowGoods,
  reqSubmitOrder
} from '@/api/orderPay'
// 导入格式化时间的方法
import { formatTime } from '@/utils/formatTime'
// 引入async-validator用于表单校验
+import Schema from 'async-validator'
import { toast } from '@/utils/extendApi'
// 获取全局的应用实例
const app = getApp()
Page({
  data: {
    orderAddress: {}, // 默认收货地址
    orderInfo: {}, // 订单详情
    goodsId: '', // 立即购买商品的id
    buyName: '', // 订购人姓名
    buyPhone: '', // 订购人手机号
    deliveryDate: '', // 期望送达日期
    blessing: '', // 祝福语
    show: false, // 期望送达日期弹框
    minDate: new Date().getTime() // 当前日期时间戳
  },
  // 选择期望送达日期
  onShowDateTimerPopUp() {
    this.setData({
      show: true
    })
  },
  // 期望送达日期确定按钮
  onConfirmTimerPicker(event) {
    // 使用Vant组件提供的事件选择组件,获取到选择的时间戳
    // 需要将时间戳转换成 年月日 格式
    // 可以调用小程序提供的格式化方法对时间进行转换
    // formatTime方法接收JS的日期对象作为参数,因此需要将时间戳转换为JS的日期对象
    const timeRes = formatTime(new Date(event.detail))
    this.setData({
      show: false,
      deliveryDate: timeRes
    })
  },
  // 期望送达日期取消按钮 以及 关闭弹框时触发
  onCancelTimePicker() {
    this.setData({
      show: false,
      minDate: new Date().getTime(),
      currentDate: new Date().getTime()
    })
  },
  // 跳转到收货地址
  toAddress() {
    wx.navigateTo({
      url: '/modules/settingModule/pages/address/list/index'
    })
  },
  // 获取订单收货地址
  async getOrderAddress() {
    // 判断 app.globalData.address 中是否存储有数据
    if (app.globalData.address.id) {
      // 存在收货地址,使用该地址作为收货地址
      this.setData({
        orderAddress: app.globalData.address
      })
      return
    }
    // 不存在收货地址,则发请求获取默认收货地址作为收货地址
    const result = await reqOrderAddress()
    this.setData({
      orderAddress: result.data
    })
  },
  // 获取订单详情数据
  async getOrderInfo() {
    // 判断是否存在 goodsId,若存在获取立即购买商品的数据,不存在获取订单详情
    const { goodsId, blessing } = this.data
    // 解构并重命名获取到的数据
    const { data: orderInfo } = goodsId
      ? await reqBuyNowGoods({ goodsId, blessing })
      : await reqOrderInfo()
    // 获取祝福语,若购买多个商品获取第一个填写了祝福语的商品进行赋值
    // find()方法返回数组中满足提供的测试函数的第一个元素的值否则返回 undefined
    const { blessing: orderBlessing } = orderInfo.cartVoList.find(
      (item) => item.blessing
    ) || { blessing: '' }
    // 存储订单数据
    this.setData({
      orderInfo: orderInfo,
      blessing: orderBlessing
    })
  },
  // 表单验证 验证收货人订购人信息
+  validatorOrder(params) {
+    // 校验规则
+    const rules = {
+      buyName: [
+        {
+          required: true,
+          message: '请输入订购人姓名'
+        },
+        {
+          pattern: '^[a-zA-Z\\d\\u4e00-\\u9fa5]+$',
+          message: '订购人姓名不合法'
+        }
+      ],
+      buyPhone: [
+        { required: true, message: '请输入订购人手机号' },
+        {
+          pattern:
+            '^1(?:3\\d|4[4-9]|5[0-35-9]|6[67]|7[0-8]|8\\d|9\\d)\\d{8}$',
+          message: '订购人手机号不合法'
+        }
+      ],
+      deliveryDate: [{ required: true, message: '请选择送达日期' }],
+      blessing: [{ required: true, message: '请输入备注' }],
+      cartList: [{ required: true, message: '订单不能为空' }],
+      userAddressId: [{ required: true, message: '请选择收货地址' }]
+    }
+    // 实例化校验对象 并传递校验规则
+    const validator = new Schema(rules)
+    // 调用实例方法对请求参数验证 (返回Promise)
+    return new Promise((reolve, reject) => {
+      validator.validate(params, (error) => {
+        if (error) {
+          // 验证失败提示错误信息
+          toast(error[0].message)
+          reject(error) // 返回失败状态注入失败信息
+        } else {
+          // 验证成功
+          return reolve('ok') // 返回成功状态
+        }
+      })
+    })
+  },
  // 提交订单
+  async submitOrder() {
+    // 从data中解构数据
+    const {
+      orderInfo,
+      blessing,
+      deliveryDate,
+      buyPhone,
+      buyName,
+      orderAddress
+    } = this.data
+    // 整理请求参数
+    const params = {
+      buyName,
+      buyPhone,
+      deliveryDate,
+      blessing,
+      cartList: orderInfo.cartVoList,
+      userAddressId: orderAddress.id
+    }
+    // 对请求参数进行验证
+    await this.validatorOrder(params)
+    // 验证成功后发请求提交订单
+  },
  // 页面展示时触发不能使用onLoad(只执行一次)
  onShow() {
    // 获取收货地址
    this.getOrderAddress()
    // 获取订单数据
    this.getOrderInfo()
  },
  // 获取从商品详情页面传递的参数
  onLoad(options) {
    // 获取从商品详情页面传递的商品id和祝福语
    if (!options.goodsId) return
    const { goodsId, blessing } = options
    // 存储商品id和祝福语
    this.setData({
      goodsId,
      blessing
    })
  },
  onUnload() {
    // 在当前页面销毁后
    // 清空全局共享数据中的收货地址,下次购买商品依然使用默认地址作为收货地址
    app.globalData.address = {}
  }
})

09. 小程序支付-小程序支付流程

小程序支付图示:

image-20240718213605416

image-20240718213506856

前端需要做的事情:

  1. 生成平台订单:前端调用接口,向后端传递需要购买的商品信息、收货人信息,[后端生成平台订单,返回订单编号]

  2. 获取预付单信息:将订单编号发送给后端后, [后端向微信服务器获取预付单信息,后端会将微信服务器返回的预付单信息进行加密,然后将加密以后的预付单信息返回给前端]

  3. 发起微信支付:前端调用 wx.requestPayment() 发起微信支付

  4. 查询支付状态:调用接口查询支付状态

10. 小程序支付-创建平台订单

思路分析:

用户在完成选购流程,确认商品信息、订购人、收货人等信息无误后,

用户需要点击提交订单按钮,开始进行下单支付,这时候需要先创建平台订单。

实现步骤:

  1. 在提交订单的事件处理函数中调用封装的接口 API 函数
  2. 在接口调用成功以后,将服务器响应的订单编码进行存储

落地代码:

➡️ /pages/order/detail/index.js

js
import {
  reqOrderAddress,
  reqOrderInfo,
  reqBuyNowGoods,
+   reqSubmitOrder
} from '@/api/orderpay'

Page({
+  data:{
    .....
+   orderNo:'' // 订单编号
+  }
......
  // 提交订单
  async submitOrder() {
    // 从data中解构数据
    const {
      orderInfo,
      blessing,
      deliveryDate,
      buyPhone,
      buyName,
      orderAddress
    } = this.data
    // 整理请求参数
    const params = {
      buyName,
      buyPhone,
      deliveryDate,
      blessing,
      cartList: orderInfo.cartVoList,
      userAddressId: orderAddress.id
    }
    // 对请求参数进行验证 await右侧的Promise返回成功才会继续向下执行
    await this.validatorOrder(params)
    // 验证成功后发请求创建平台订单
+    const result = await reqSubmitOrder(params)
+    if (result.code === 200) {
+      // 存储服务器返回的订单编号
+      this.setData({
+        orderNo: result.data
+      })
+    }
  },
})

11. 小程序支付-获取预付单信息

思路分析:

将订单编号发送给公司的后端,公司的后端会从数据库找到对应订单的信息。

然后调用微信服务器的 下单接口 进行创建订单,订单创建成功以后,微信服务器会给公司后端返回预付单信息

公司后端对返回的预付单信息进行加密,返回给小程序客户端

这一步,前端需要做的就是:订单编号发送给公司的后端,其他逻辑是后端来完成的

📌:注意事项:

​ 小程序支付后面的代码在实现的时会出现异常

​ 这是因为没有小程序的开发权限,以后在实际开发中,只需要参考当前流程进行开发即可

落地代码:

➡️ /pages/order/detail/index.js

js
Page({
  // 提交订单
  async submitOrder() {
    // 从data中解构数据
    const {
      orderInfo,
      blessing,
      deliveryDate,
      buyPhone,
      buyName,
      orderAddress
    } = this.data
    // 整理请求参数
    const params = {
      buyName,
      buyPhone,
      deliveryDate,
      blessing,
      cartList: orderInfo.cartVoList,
      userAddressId: orderAddress.id
    }
    // 对请求参数进行验证 await右侧的Promise返回成功才会继续向下执行
    await this.validatorOrder(params)
    // 验证成功后发请求创建平台订单
    const result = await reqSubmitOrder(params)
    if (result.code === 200) {
      // 存储服务器返回的订单编号
      this.setData({
        orderNo: result.data
      })
+      // 获取预付单信息(获取支付参数)
+      this.advancePay()
    }
  },
+  // 获取预付单信息(获取支付参数)
+  async advancePay() {
+    const payParams = await reqPrePayInfo(this.data.orderNo)
+    if (payParams.code === 200) {
+      console.log(payParams.data)
+    }
+  },
})

12. 小程序支付-发起微信支付

知识点:

小程序客户端在接收支付参数后,调用 wx.requestPayment() 发起微信支付

唤醒支付弹窗,用户输入支付密码或者进行指纹等操作,微信服务器会进行验证,如果验证成功,就会发起支付。

然后会将支付结果返回给公司后端,也会返回给 wx.requestPayment() ,并且会微信通知用户支付结果

落地代码:

➡️ /pages/order/detail/index.js

js
// 获取预付单信息(获取支付参数) 并调用接口进行微信支付
async advancePay() {
+  try {  // 可能会发生错误使用 try...catch进行包裹
    const payParams = await reqPrePayInfo(this.data.orderNo)
    if (payParams.code === 200) {
       // payParams.data: 支付参数
+      // 调用wx.requestPayment进行微信支付
+      const payInfo = await wx.requestPayment(payParams.data)
+      console.log(payInfo) // {errMsg:'requestPayment:ok'} 支付成功
+    }
+  } catch (error) {
+    // 接口请求出错
+   toast('支付失败', 'error')
+  }
},

13. 小程序支付-支付状态查询

思路分析:

通过调用后端接口获取支付状态,如果支付成功,需要给用户提示,同时跳转到订单列表页面

公司后端开始向微信服务器发送请求,查询支付结果

公司服务器会将微信服务器返回的支付结果,返回到客户端

客户端根据查询结果跳转到订单列表页面

落地代码:

➡️ /pages/order/detail/index.js

js
  // 获取预付单信息(获取支付参数) 调用接口进行微信支付 调用接口查看支付状态
  async advancePay() {
    try {
      const payParams = await reqPrePayInfo(this.data.orderNo)
      if (payParams.code === 200) {
        // payParams.data: 支付参数
        // 调用wx.requestPayment进行微信支付
        const payInfo = await wx.requestPayment(payParams.data)
        // 获取支付的结果  {errMsg:'requestPayment:ok'} 支付成功
        if (payInfo.errMsg === 'requestPayment:ok') {
+          // 调用接口查看支付的状态
+          const payStatus = await reqPayStatus(this.data.orderNo)
+          if (payStatus.code === 200) {
+            // 支付成功,跳转到订单列表页面
+            await wx.redirectTo({
+              url: '/modules/orderPayModule/pages/order/list/list'
+            })
+            toast('支付成功', 'success')
+          }
+        }
      }
    } catch (error) {
      // 接口请求出错
      toast('支付失败', 'error')
    }
  },

十三、订单列表

01. 封装订单列表接口 API

思路分析:

为了方便后续进行商品管理模块的开发,我们在这一节将商品管理所有的接口封装成接口 API 函数

落地代码:

➡️ api/orderpay.js

js
import http from '@/utils/http'
// 获取订单列表的接口方法
export const reqOrderList = (page, limit) =>
  http.get(`/order/order/${page}/${limit}`)

02. 获取订单列表数据并渲染

思路分析:

当用户从个人中心页面点击进入订单中心的时候,就需要获取到订单中心的数据。

在页面调用 API 函数获取订单列表的数据,

在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
+ // 导入封装的接口 API 函数
+ import { reqOrderList } from '@/api/orderpay'

Page({
  // 页面的初始数据
  data: {
    orderList: [1, 2, 3], // 订单列表
+     page: 1, // 页码
+     limit: 10, // 每页展示的条数
+     total: 0 // 订单列表总条数
  },

+   // 获取订单列表
+   async getOrderList() {
+     // 解构获取数据
+     const { page, limit } = this.data
+     // 调用接口获取订单列表数据
+     const res = await reqOrderList(page, limit)
+ 
+     if (res.code === 200) {
+       this.setData({
+         orderList: res.data.records,
+         total: res.data.total
+       })
+     }
+   },

+   // 生命周期函数--监听页面加载
+   onLoad() {
+     this.getOrderList()
+   }

})

➡️ modules/orderPayModule/pages/order/list/list.wxml

html
<!--pages/order/list/index.wxml-->
<view class="order-container container">
+   <view class="order-list" wx:if="{{ orderList.length > 0 }}">
+     <view class="order-item" wx:for="{{ orderList }}" wx:key="index">
+       <view class="order-item-header list-flex">
        <view class="orderno">订单号<text class="no">{{ orderList.orderNo }}</text></view>
+         <view class="order-status {{ item.orderStatus === 1 ? 'order-active' : '' }}">
+           {{ item.orderStatus === 1 ? '已支付' : '未支付'}}
+         </view>
      </view>
      <view
        class="goods-item list-flex"
+         wx:for="{{ item.orderDetailList }}"
+         wx:key="id"
+         wx:for-item="goods"
+         wx:for-index="goodsIndex"
      >
        <view class="left">
+           <image src="" data-missing="{{ goods.imageUrl }}" mode="widthFix" class="img" />
        </view>
        <view class="mid">
+           <view class="goods-name">{{ goods.name }}</view>
+           <view class="goods-blessing">{{ goods.blessing }}</view>
        </view>
        <view class="right">
+           <view class="goods-price">¥{{ goods.price }}</view>
+           <view class="goods-count">x{{ goods.count }}</view>
        </view>
      </view>
      <view class="order-item-footer">
        <view class="total-amount list-flex">
          <text class="text">实付</text>
+           <text class="price"><text>¥</text>{{ item.totalAmount }}</text>
        </view>
      </view>
    </view>
  </view>
  <van-empty wx:else description="还没有购买商品,快去购买吧~" />
</view>

03. 订单列表上拉加载更多

思路分析:

当用户进行了上拉操作时,需要在 .js 文件中声明 onReachBottom 方法,用来监听页面的上拉触底行为

当用户上拉时,需要对 page 参数进行加 1 即可,

当参数发生改变后,需要重新发送请求,拿最新的 page 向服务器要数据

在下一页的商品数据返回以后,需要将下一页的数据和之前的数据进行合并

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
// 导入封装的接口 API 函数
import { reqOrderList } from '@/api/orderpay'

Page({
  // 页面的初始数据
  data: {
    orderList: [1, 2, 3], // 订单列表
    page: 1, // 页码
    limit: 10, // 每页展示的条数
    total: 0 // 订单列表总条数
  },

  // 获取订单列表
  async getOrderList() {
    // 解构获取数据
    const { page, limit } = this.data
    // 调用接口获取订单列表数据
    const res = await reqOrderList(page, limit)

    if (res.code === 200) {
      this.setData({
+         orderList: [...this.data.orderList, ...res.data.records],
        total: res.data.total
      })
    }
  },

+   // 页面上拉触底事件的处理函数
+   onReachBottom() {
+     // 解构数据
+     const { page } = this.data
+ 
+     // 更新 page
+     this.setData({
+       page: page + 1
+     })
+ 
+     // 重新发送请求
+     this.getOrderList()
+   },

  // 生命周期函数--监听页面加载
  onLoad() {
    this.getOrderList()
  }
})

04. 判断数据是否加载完毕

思路分析:

如何判断数据是否加载完成 ❓

可以使用后端返回的 totalgoodsList 进行对比,如果 total 大于 goodsList ,说明订单中心数据没有加载完,可以继续上拉加载更多。

目前还没有接收 total,需要先将后台返回的 total 进行赋值到 data 中,然后使用 onReachBottom 中进行判断

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
// 页面上拉触底事件的处理函数
onReachBottom() {
+   // 解构数据
+   const { page, total, orderList } = this.data
+ 
+   // 数据总条数 和 订单列表长度进行对比
+   if (total === orderList.length) {
+     return wx.toast({ title: '数据加载完毕' })
+   }

  // 更新 page
  this.setData({
    page: page + 1
  })

  // 重新发送请求
  this.getOrderList()
}

05. 节流阀进行列表节流

在用户网速很慢的情况下,如果用户在距离底部来回的进行多次滑动,可能会发送一些无意义的请求、造成请求浪费的情况,因此需要给上拉加载添加节流功能。

我们使用节流阀来给订单列表添加节流功能。

data 中定义节流阀状态 isLoading,默认值是 false

在请求发送之前,将 isLoading 设置为 true,表示请求正在发送。

在请求结束以后,将 isLoading 设置为 false,表示请求已经完成。

onReachBottom 事件监听函数中,对 isLoading 进行判断,如果数据正在请求中,不请求下一页的数据。

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
// 导入封装的接口 API 函数
import { reqOrderList } from '@/api/orderpay'

Page({
  // 页面的初始数据
  data: {
    orderList: [1, 2, 3], // 订单列表
    page: 1, // 页码
    limit: 10, // 每页展示的条数
    total: 0, // 订单列表总条数
+     isLoading: false // 判断数据是否记载完毕
  },

  // 获取订单列表
  async getOrderList() {
    // 解构获取数据
    const { page, limit } = this.data

+     // 数据正在请求中
+     this.data.isLoading = true

    // 调用接口获取订单列表数据
    const res = await reqOrderList(page, limit)

+     // 数据加载完毕
+     this.data.isLoading = false

    if (res.code === 200) {
      this.setData({
        orderList: [...this.data.orderList, ...res.data.records],
        total: res.data.total
      })
    }
  },

  // 页面上拉触底事件的处理函数
  onReachBottom() {
+     // 解构数据
+     const { page, total, orderList, isLoading } = this.data

+     // 判断是否加载完毕,如果 isLoading 等于 true
+     // 说明数据还没有加载完毕,不加载下一页数据
+     if (isLoading) return

    // 数据总条数 和 订单列表长度进行对比
    if (total === orderList.length) {
      return wx.toast({ title: '数据加载完毕' })
    }

    // 更新 page
    this.setData({
      page: page + 1
    })

    // 重新发送请求
    this.getOrderList()
  },

  // 生命周期函数--监听页面加载
  onLoad() {
    this.getOrderList()
  }
})

06. 下拉刷新

配置允许下拉刷新

➡️ modules/orderPayModule/pages/order/list/list.json

json
{
  "usingComponents": {},
   "navigationBarTitleText": "订单列表",
+  "enablePullDownRefresh": true, // 允许下拉刷新
+  "backgroundTextStyle": "light", // 加载图标样式
+  "backgroundColor": "#f7f4f8" // 下拉刷新窗口颜色
}

在事件函数中获取数据

➡️ modules/orderPayModule/pages/order/list/list.js

js
  // 监听下拉刷新
  async onPullDownRefresh() {
    // 节流设置
    if (this.data.isLoading) return
    // 将page设置为1 并清空订单列表中的数据
    this.setData({
      page: 1,
      orderList: []
    })
    // 重新获取订单列表数据
    await this.getOrderList()
    // 手动关闭加载框
    wx.stopPullDownRefresh()
  },

十四、代码优化

1. 分享功能

思路分析:

目前小程序页面都没有配置分享功能,需要给小程序页面设置分享功能。

但是并不是所有页面都需要设置分享功能,

具体哪些页面需要设置分享功能,可以和产品经理进行协商。

  1. 首页
  2. 商品列表
  3. 商品详情

落地代码:

js
// 转发功能
onShareAppMessage() {
  return {
    title: '所有的怦然心动,都是你', // 标题
    path: '/pages/index/index', // 他人点击链接后跳转到小程序的哪个页面
    imageUrl: '../../assets/images/love.jpg' // 显示的图片 相对路径
  }
},
// 转发到朋友圈功能
onShareTimeline() {}

注意:

分享功能需要进行微信认证

image-20240805175827763

2. 优化-分包调整

思路分析:

  1. 将 [设置页面] 配置到 [设置模块分包],在访问个人中心页面时,提前预下载 [设置模块分包]

  2. 进入订单结算页面时,提前预下载 [设置模块分包]

落地代码:

➡️ app.json

json
  "subPackages": [
    {
      "root": "modules/settingModule",
      "name": "settingModule",
      "pages": [
        "pages/address/add/index",
        "pages/address/list/index",
        "pages/profile/profile",
        "pages/settings/settings"
      ]
    },
    {
      "root": "modules/goodModule",
      "name": "goodModule",
      "pages": ["pages/goods/list/list", "pages/goods/detail/detail"]
    },
    {
      "root": "modules/orderPayModule",
      "name": "orderPayModule",
      "pages": ["pages/order/detail/detail", "pages/order/list/list"]
    }
  ],
  "preloadRule": {
    "pages/category/category": {
      "network": "all",
      "packages": ["goodModule"]
    },
    "pages/cart/cart": {
      "network": "all",
      "packages": ["orderPayModule"]
    },
    "pages/my/my": {
      "network": "all",
      "packages": ["orderPayModule", "settingModule"]
    },
    "modules/orderPayModule/pages/order/detail/detail": {
      "network": "all",
      "packages": ["settingModule"]
    }
  },

3. 优化-关键按钮添加防抖函数

思路分析:

为了防止用户频繁点击按钮而导致的重复提交或者多次请求的问题,

我们需要给关键按钮添加防抖函数,这里可以使用 licia 提供的防抖函数

js
import { debounce } from 'miniprogram-licia'
  1. 登录按钮的回调函数
  2. 提交订单按钮的回调函数

落地代码:

js
import { debounce } from 'miniprogram-licia'

debounce(function () {
    
  // coding...
    
}, 500)

4. 优化-代码质量检测

如何使用微信开发者工具进行代码质量检测

代码质量检测标准:

5.防抖,节流

防抖和节流是前端开发中常用的两种性能优化技术,主要用于控制事件处理函数的执行频率,优化高频触发事件的处理

防抖:(动态搜索输入框,下拉刷新)

防抖(debounce)的核心思想是在一定时间间隔内,无论事件被触发多少次,都只执行最后一次。换句话说,如果事件持续触发,那么事件处理函数将不会执行,只有在事件触发暂停后才会执行一次。

实现方式:

​ 防抖通常利用定时器来实现。当事件被触发时,清除之前的定时器并重新设定一个新的定时器。定时器结束后,实际的事件处理函数才会被调用。如果事件持续触发,则定时器不断重置,事件处理函数始终不会执行。

适用场景:

​ 适用于需要在事件触发停止后进行响应的场景,如搜索框输入、步进器组件、更新商品购买数量、窗口大小调整等

示例:

  • 当用户使用步进器增加购买数量时使用防抖技术,可以在用户最终确定购买数量时再向服务器发送请求,而不是用户每点一次'加号'或'减号'就发一次请求

  • 当用户输入搜索词时,如果每敲一个字母都发送请求,会频繁调用服务器,不仅影响性能,还可能加重服务器负担。此时,使用防抖技术可以让用户停止敲击键盘一段时间后再发起请求,有效减少不必要的调用。

节流:(登录按钮,上拉加载更多)

节流(throttle)是指在一定时间间隔内,无论事件被触发多少次,都只执行第一次。也就是说,如果在这段间隔内再次触发事件,事件处理函数将不会被执行

示例:

  • 在上拉加载更多时使用节流技术,可以确保在第一次获取数据成功后用户再次上拉时再发请求获取数据,而不是用户每上拉一次就发请求获取一次数据
  • 在滚动事件中使用节流技术,可以确保事件处理函数不会因为连续快速的滚动动作而被频繁调用,从而减轻浏览器的负担。

适用场景:

​ 适用于需要固定时间间隔响应一次的场景,如滚动事件快速点击

实现方式:

​ 可以通过定时器实现。在事件首次触发时开启一个指定时长的定时器,在这段时间内后续的事件触发会被忽略,直到定时器到达指定时长后,才能再次触发事件处理函数。也可以通过设置标识位来实现

使用防抖还是节流,取决于回调函数的调用时机

防抖:在一定时间内回调函数只执行最后一次触发,如果定时器期间该事件回调又被触则重置定时器,等待定时器结束且不再触发该函数是再调用

节流:在一定时间内回调函数只在第一次触发时执行,如果在定时器期间该事件回调又被触发则不会执行,等待定时器结束后才可继续触发