Skip to content

uniapp小程序3

十二、小兔鲜儿 - 订单模块

订单模块页面较多,使用新的分包文件夹独立管理订单模块页面:填写订单页,支付订单页,订单详情页,订单列表页

1. 填写订单页

小兔鲜儿项目有三种方式可以生成订单信息,分别是:购物车结算、立即购买、再次购买。

填写订单

1.1 静态结构(分包)

创建文件夹 src/pagesOrder --> 创建(分包)页面 src/pagesOrder/create

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

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 订单备注
const buyerMessage = ref('')
// 配送时间
const deliveryList = ref([
  { type: 1, text: '时间不限 (周一至周日)' },
  { type: 2, text: '工作日送 (周一至周五)' },
  { type: 3, text: '周末配送 (周六至周日)' },
])
// 当前配送时间下标
const activeIndex = ref(0)
// 当前配送时间
const activeDelivery = computed(() => deliveryList.value[activeIndex.value])
// 修改配送时间
const onChangeDelivery: UniHelper.SelectorPickerOnChange = (ev) => {
  activeIndex.value = ev.detail.value
}
</script>

<template>
  <scroll-view scroll-y class="viewport">
    <!-- 收货地址 -->
    <navigator
      v-if="false"
      class="shipment"
      hover-class="none"
      url="/pagesMember/address/address?from=order"
    >
      <view class="user"> 张三 13333333333 </view>
      <view class="address"> 广东省 广州市 天河区 黑马程序员3 </view>
      <text class="icon icon-right"></text>
    </navigator>
    <navigator
      v-else
      class="shipment"
      hover-class="none"
      url="/pagesMember/address/address?from=order"
    >
      <view class="address"> 请选择收货地址 </view>
      <text class="icon icon-right"></text>
    </navigator>

    <!-- 商品信息 -->
    <view class="goods">
      <navigator
        v-for="item in 2"
        :key="item"
        :url="`/pages/goods/goods?id=1`"
        class="item"
        hover-class="none"
      >
        <image
          class="picture"
          src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg"
        />
        <view class="meta">
          <view class="name ellipsis"> ins风小碎花泡泡袖衬110-160cm </view>
          <view class="attrs">藏青小花 130</view>
          <view class="prices">
            <view class="pay-price symbol">99.00</view>
            <view class="price symbol">99.00</view>
          </view>
          <view class="count">x5</view>
        </view>
      </navigator>
    </view>

    <!-- 配送及支付方式 -->
    <view class="related">
      <view class="item">
        <text class="text">配送时间</text>
        <picker :range="deliveryList" range-key="text" @change="onChangeDelivery">
          <view class="icon-fonts picker">{{ activeDelivery.text }}</view>
        </picker>
      </view>
      <view class="item">
        <text class="text">订单备注</text>
        <input
          class="input"
          :cursor-spacing="30"
          placeholder="选题,建议留言前先与商家沟通确认"
          v-model="buyerMessage"
        />
      </view>
    </view>

    <!-- 支付金额 -->
    <view class="settlement">
      <view class="item">
        <text class="text">商品总价: </text>
        <text class="number symbol">495.00</text>
      </view>
      <view class="item">
        <text class="text">运费: </text>
        <text class="number symbol">5.00</text>
      </view>
    </view>
  </scroll-view>

  <!-- 吸底工具栏 -->
  <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
    <view class="total-pay symbol">
      <text class="number">99.00</text>
    </view>
    <view class="button" :class="{ disabled: true }"> 提交订单 </view>
  </view>
</template>

<style lang="scss">
page {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background-color: #f4f4f4;
}
.symbol::before {
  content: '¥';
  font-size: 80%;
  margin-right: 5rpx;
}
.shipment {
  margin: 20rpx;
  padding: 30rpx 30rpx 30rpx 84rpx;
  font-size: 26rpx;
  border-radius: 10rpx;
  background: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png)
    20rpx center / 50rpx no-repeat #fff;
  position: relative;
  .icon {
    font-size: 36rpx;
    color: #333;
    transform: translateY(-50%);
    position: absolute;
    top: 50%;
    right: 20rpx;
  }
  .user {
    color: #333;
    margin-bottom: 5rpx;
  }
  .address {
    color: #666;
  }
}
.goods {
  margin: 20rpx;
  padding: 0 20rpx;
  border-radius: 10rpx;
  background-color: #fff;
  .item {
    display: flex;
    padding: 30rpx 0;
    border-top: 1rpx solid #eee;
    &:first-child {
      border-top: none;
    }
    .picture {
      width: 170rpx;
      height: 170rpx;
      border-radius: 10rpx;
      margin-right: 20rpx;
    }
    .meta {
      flex: 1;
      display: flex;
      flex-direction: column;
      justify-content: center;
      position: relative;
    }
    .name {
      height: 80rpx;
      font-size: 26rpx;
      color: #444;
    }
    .attrs {
      line-height: 1.8;
      padding: 0 15rpx;
      margin-top: 6rpx;
      font-size: 24rpx;
      align-self: flex-start;
      border-radius: 4rpx;
      color: #888;
      background-color: #f7f7f8;
    }
    .prices {
      display: flex;
      align-items: baseline;
      margin-top: 6rpx;
      font-size: 28rpx;
      .pay-price {
        margin-right: 10rpx;
        color: #cf4444;
      }
      .price {
        font-size: 24rpx;
        color: #999;
        text-decoration: line-through;
      }
    }
    .count {
      position: absolute;
      bottom: 0;
      right: 0;
      font-size: 26rpx;
      color: #444;
    }
  }
}
.related {
  margin: 20rpx;
  padding: 0 20rpx;
  border-radius: 10rpx;
  background-color: #fff;
  .item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    min-height: 80rpx;
    font-size: 26rpx;
    color: #333;
  }
  .input {
    flex: 1;
    text-align: right;
    margin: 20rpx 0;
    padding-right: 20rpx;
    font-size: 26rpx;
    color: #999;
  }
  .item .text {
    width: 125rpx;
  }
  .picker {
    color: #666;
  }
  .picker::after {
    content: '\e6c2';
  }
}
/* 结算清单 */
.settlement {
  margin: 20rpx;
  padding: 0 20rpx;
  border-radius: 10rpx;
  background-color: #fff;
  .item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    height: 80rpx;
    font-size: 26rpx;
    color: #333;
  }
  .danger {
    color: #cf4444;
  }
}
/* 吸底工具栏 */
.toolbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(var(--window-bottom));
  z-index: 1;
  background-color: #fff;
  height: 100rpx;
  padding: 0 20rpx;
  border-top: 1rpx solid #eaeaea;
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-sizing: content-box;
  .total-pay {
    font-size: 40rpx;
    color: #cf4444;
    .decimal {
      font-size: 75%;
    }
  }
  .button {
    width: 220rpx;
    text-align: center;
    line-height: 72rpx;
    font-size: 26rpx;
    color: #fff;
    border-radius: 72rpx;
    background-color: #27ba9b;
  }
  .disabled {
    opacity: 0.6;
  }
}
</style>

1.2 购物车-去结算

在购物车点击去结算后,进入填写订单页,用户可以选择订单的收货地址或补充订单信息

接口详情

获取预付订单:https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426937

接口封装

src/services/order.ts

ts
import type { OrderInfo } from '@/types/order'
import { http } from '@/utils/http'
/**
 * 获取预付订单信息(购物车中选中商品信息)的接口方法
 */
export const getOrderPreInfoAPI = () => http.get<OrderInfo>('/member/order/pre')

类型声明

src/services/order.d.ts

ts
import type { AddressItem } from './global'

/**每个订单商品信息 */
export interface OrderGoodsItem {
  /** id*/
  id: string
  /**商品名称 */
  name: string
  /**图片 */
  picture: string
  /**数量 */
  count: number
  /**SKUID */
  skuId: string
  /**属性文字,例如“颜色:瓷白色 尺寸:8寸” */
  attrsText: string
  /**原单价 */
  price: number
  /**实付单价 */
  payPrice: number
  /**小计总价 */
  totalPrice: number
  /**实付价格小计 */
  totalPayPrice: number
}
/**订单信息的类型 */
export interface OrderInfo {
  /**用户地址列表 */
  userAddresses: AddressItem[]
  /**商品集合 */
  goods: OrderGoodsItem[]
  /**结算信息 */
  summary: {
    /**结算件数 */
    goodsCount: number
    /**价格总计 */
    totalPrice: number
    /**应付总计 */
    totalPayPrice: number
    /**邮费 */
    postFee: number
    /**折扣总计 */
    discountPrice: number
  }
}

获取数据:

js
// 预付订单信息(购物车中已选商品数据)
const orderPreInfo = ref<OrderInfo>()

// 获取预付订单信息(购物车中选中商品的数据)
const getOrderPreInfo = async () => {
  const result = await getOrderPreInfoAPI()
  // 存储订单信息
  orderPreInfo.value = result.result
}

onLoad(() => {
  getOrderPreInfo()
})

渲染页面:

1.3 商品详情-立即购买

从商品详情页的 SKU 组件直接点击【立即购买按钮】跳转到填写订单页,需要传递页面参数

接口详情

获取立即购买订单:https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/api-43426943

接口封装

ts
/**获取立即购买订单信息的接口参数类型 */
export interface GetOrderBuyNowParams {
  /**商品SKUID */
  skuId: string
  /**购买数量 */
  count: number
  /**下单时已经选择好的地址id */
  addressId?: string
}
/**
 * 获取立即购买订单信息的接口方法
 * @param query{skuID,count,addressId?}
 */
export const getOrderBuyNowInfoAPI = (query: GetOrderBuyNowParams) =>
  http.get<OrderInfo>('/member/order/pre/now', query)

传递页面传参

  • 在SKU组件的弹窗中点击立即购买按钮在事件会调中跳转到填写订单页面并传递query参数

  • 两个必要参数(skuId,count购买数量)一个可选参数(addressId 已选地址id)

src\hooks\useSkuPopup.ts

js
  // 立即购买(selectShop:当前选择的sku数据 addressId 绑定事件时传递的地址ID)
  const buyNow = (selectShop: SkuPopupEvent, addressId: string) => {
    // 判断用户是登录
    if (!profile.value?.token)
      // 跳转到登录页面
      return uni.navigateTo({
        url: '/pages/login/login',
      })
    // 携带参数跳转到填写订单页面
    uni.navigateTo({
      url: `/pagesOrder/create/create?skuId=${selectShop._id}&count=${selectShop.buy_num}&addressId=${addressId}`,
    })
  }

获取数据

vue
<script setup lang="ts">
+// 获取页面参数
+const query = defineProps<{
+  /**商品SKUID */
+  skuId: string
+  /**购买数量 */
+  count: number
+  /**下单时已经选择好的地址id */
+  addressId?: string
+}>()

// 获取预付订单信息(购物车中选中商品的数据)
const getOrderPreInfo = async () => {
  const result = await getOrderPreInfoAPI()
  // 存储订单信息
  orderPreInfo.value = result.result
}
+// 获取立即购买商品数据
+const getOrderBuyNowInfo = async () => {
+  const result = await getOrderBuyNowInfoAPI({
+    skuId: query.skuId,
+    count: query.count,
+    addressId: query.addressId,
+  })
+  // 存储立即购买订单数据
+  orderPreInfo.value = result.result
+}

onLoad(() => {
+  // 判断是点击立即购买跳转还是购物车中点击结算跳转到到的填写订单页面
+  if (query.skuId) {
+    // 获取立即购买商品数据
+    getOrderBuyNowInfo()
+  } else {
+    // 获取购物车中选中商品数据
+    getOrderPreInfo()
+  }
})
</script>

1.4 选择收货地址

需求:

在填写订单页面默认展示默认收货地址,当用户点击收货地址时可以跳转到地址管理页面,可以新增收货地址,也可以点击已有的收货地址进行选择,选择后返回填写订单页面,展示用户选择的地址

实现方式:

方式一:将用户在地址列表页中选中的地址对象存储到 store仓库中,在填写订单页面中获取

  1. 创建用户选中的收货地址仓库:src/store/address.ts
js
import type { AddressItem } from '@/types/global'
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 用户选中的收货地址仓库
export const useAddressStore = defineStore('address', () => {
  // 选中的收货地址
  let selectedAddress = ref<AddressItem>()
  return { selectedAddress }
})
  1. 在地址列表页获取用户选择地地址项并存储到仓库中,注意<navigator> 组件需要阻止事件冒泡

    src\pagesMember\address\address.vue

vue
<script>
// 获取路由参数
const query = defineProps<{
  // 来源页面
  from?: string
}>()
// 获取存储选择地址的仓库
const { selectedAddress } = storeToRefs(useAddressStore())

// 点击地址项的回调
const onSelectAddress = (item: AddressItem) => {
  // 判断是否从填写订单页面跳转到该页面
  if (!(query.from === 'order')) return
  // 将当前选中的地址存储到仓库中
  selectedAddress.value = item
  // 返回填写订单页面
  uni.navigateBack()
}
</script>
<template>
+ <view class="item-content" @tap="onSelectAddress(item)">
    <navigator
       class="edit"
       hover-class="none"
       :url="`/pagesMember/address-form/address-form?id=${item.id}`"
+      @tap.stop="() => {}" // 阻止事件冒泡避免触发父组件的tap事件
 >
</template>
  1. 在填写订单页面中获取仓库中的数据,并通过计算属性计算出收货地址(如果仓库中有值则使用仓库中的值,反之使用默认地址)

    src\pagesOrder\create\create.vue

vue
<script>
// 获取存储选择地址的仓库
const { selectedAddress } = storeToRefs(useAddressStore())
// 计算收货地址
const selectAddress = computed(() => {
  // 如果用户在地址列表页面中选中,则展示选中的地址,否则使用默认收货地址
  return selectedAddress.value
    ? selectedAddress.value
    : orderPreInfo.value?.userAddresses.find((item) => item.isDefault === 1)
})
</script>
<template>
<!-- 收货地址 -->
<navigator
  v-if="selectAddress"
  class="shipment"
  hover-class="none"
  url="/pagesMember/address/address?from=order"
>
  <view class="user">
    {{ selectAddress.receiver }} {{ selectAddress.contact }}</view
  >
  <view class="address">
    {{ selectAddress.fullLocation }} {{ selectAddress.address }}</view
  >
  <text class="icon icon-right"></text>
</navigator>
<navigator
  v-else
  class="shipment"
  hover-class="none"
  url="/pagesMember/address/address?from=order"
>
  <view class="address"> 请选择收货地址 </view>
  <text class="icon icon-right"></text>
</navigator>
</template>

方式二:使用路由传参的方式实现地址的选择

  1. 为地址列表页面的地址项绑定点击事件
  2. 在事件回调中判断是否是从填写订单页面跳转来的
  3. 如果是跳转到订单填写页面并传递当前选中地址的id
  4. 在订单填写页面接收页面参数,通过 find方法查找到选中的地址
  5. 通过计算属性计算当前选中的地址(若页面参数有值则使用用户选中的地址,使用默认地址)

代码略

1.5 提交订单

收集填写订单页的数据,点击页面底部的提交订单按钮,创建一个新的订单

接口详情

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

接口封装

ts
/**
 * 提交订单
 * @param data 请求参数
 */
export const postMemberOrderAPI = (data: OrderCreateParams) => {
  return http<{ id: string }>({
    method: 'POST',
    url: '/member/order',
    data,
  })
}

类型声明

ts
/** 提交订单 请求参数 */
export type OrderCreateParams = {
  /** 所选地址Id */
  addressId: string
  /** 配送时间类型,1为不限,2为工作日,3为双休或假日 */
  deliveryTimeType: number
  /** 订单备注 */
  buyerMessage: string
  /** 商品集合[ 商品信息 ] */
  goods: {
    /** 数量 */
    count: number
    /** skuId */
    skuId: string
  }[]
  /** 支付渠道:支付渠道,1支付宝、2微信--支付方式为在线支付时,传值,为货到付款时,不传值 */
  payChannel: 1 | 2
  /** 支付方式,1为在线支付,2为货到付款 */
  payType: 1 | 2
}

/** 提交订单 返回信息 */
export type OrderCreateResult = {
  /** 订单Id */
  id: string
}

点击提交订单按钮

  • 收集参数
  • 发送请求,获取订单 id
  • 关闭当前页面跳转订单详情页,跳转到订单详情页面并传递订单 id
vue
<script setup lang="ts">
// 提交订单
const submitOrder = async () => {
  // 未选择收货地址的提示
  if (!selectAddress.value?.id)
    return uni.showToast({
      title: '请选择收货地址',
      icon: 'none',
    })
  // 收集参数
  const goods = orderPreInfo.value?.goods.map((item) => {
    return {
      skuId: item.skuId,
      count: item.count,
    }
  })
  // 发送请求
  const result = await postOrderAPI({
    goods: goods!,
    addressId: selectAddress.value!.id as string,
    deliveryTimeType: activeDelivery.value.type as 1 | 2 | 3,
    buyerMessage: buyerMessage.value,
    payType: 1,
    payChannel: 2,
  })
  // 关闭当前页面跳转到商品详情页面,并传递订单id
  uni.redirectTo({
    url: `/pagesOrder/detail/detail?id=${result.result.id}`,
  })
}
</script>
<template>
  <view class="button" :class="{ disabled: !selecteAddress?.id }" @tap="submitOrder">
    提交订单
  </view>
</template>

2. 订单详情页

需要展示多种订单状态 并实现不同订单状态对应的业务。

2.1 静态结构

已完成通过页面参数获取到订单 id 等基础业务。

vue
<script setup lang="ts">
import { ref } from 'vue'
import useCustomGuess from '@/hooks/useCustomGuess'

// 获取页面参数
const query = defineProps<{
  id: string
}>()
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 猜你喜欢
const { guessRef, onScrolltolower } = useCustomGuess()
// 弹出层组件
const popup = ref<UniHelper.UniPopupInstance>()
// 取消原因列表
const reasonList = ref([
  '商品无货',
  '不想要了',
  '商品信息填错了',
  '地址信息填写错误',
  '商品降价',
  '其它',
])
// 订单取消原因
const reason = ref('')
// 复制内容
const onCopy = (id: string) => {
  // 设置系统剪贴板的内容
  uni.setClipboardData({ data: id })
}
</script>

<template>
  <!-- 自定义导航栏: 默认透明不可见, scroll-view 滚动到 50 时展示 -->
  <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
    <view class="wrap">
      <navigator
        v-if="true"
        open-type="navigateBack"
        class="back icon-left"
      ></navigator>
      <navigator
        v-else
        url="/pages/index/index"
        open-type="switchTab"
        class="back icon-home"
      >
      </navigator>
      <view class="title">订单详情</view>
    </view>
  </view>
  <scroll-view
    scroll-y
    class="viewport"
    id="scroller"
    @scrolltolower="onScrolltolower"
  >
    <template v-if="true">
      <!-- 订单状态 -->
      <view
        class="overview"
        :style="{ paddingTop: safeAreaInsets!.top + 20 + 'px' }"
      >
        <!-- 待付款状态:展示去支付按钮和倒计时 -->
        <template v-if="true">
          <view class="status icon-clock">等待付款</view>
          <view class="tips">
            <text class="money">应付金额: ¥ 99.00</text>
            <text class="time">支付剩余</text>
            00 时 29 分 59 秒
          </view>
          <view class="button">去支付</view>
        </template>
        <!-- 其他订单状态:展示再次购买按钮 -->
        <template v-else>
          <!-- 订单状态文字 -->
          <view class="status"> 待付款 </view>
          <view class="button-group">
            <navigator
              class="button"
              :url="`/pagesOrder/create/create?orderId=${query.id}`"
              hover-class="none"
            >
              再次购买
            </navigator>
            <!-- 待发货状态:模拟发货,开发期间使用,用于修改订单状态为已发货 -->
            <view v-if="false" class="button"> 模拟发货 </view>
          </view>
        </template>
      </view>
      <!-- 配送状态 -->
      <view class="shipment">
        <!-- 订单物流信息 -->
        <view v-for="item in 1" :key="item" class="item">
          <view class="message">
            您已在广州市天河区黑马程序员完成取件,感谢使用菜鸟驿站,期待再次为您服务。
          </view>
          <view class="date"> 2023-04-14 13:14:20 </view>
        </view>
        <!-- 用户收货地址 -->
        <view class="locate">
          <view class="user"> 张三 13333333333 </view>
          <view class="address"> 广东省 广州市 天河区 黑马程序员 </view>
        </view>
      </view>

      <!-- 商品信息 -->
      <view class="goods">
        <view class="item">
          <navigator
            class="navigator"
            v-for="item in 2"
            :key="item"
            :url="`/pages/goods/goods?id=${item}`"
            hover-class="none"
          >
            <image
              class="cover"
              src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg"
            ></image>
            <view class="meta">
              <view class="name ellipsis">ins风小碎花泡泡袖衬110-160cm</view>
              <view class="type">藏青小花, 130</view>
              <view class="price">
                <view class="actual">
                  <text class="symbol">¥</text>
                  <text>99.00</text>
                </view>
              </view>
              <view class="quantity">x1</view>
            </view>
          </navigator>
          <!-- 待评价状态:展示按钮 -->
          <view class="action" v-if="true">
            <view class="button primary">申请售后</view>
            <navigator url="" class="button"> 去评价 </navigator>
          </view>
        </view>
        <!-- 合计 -->
        <view class="total">
          <view class="row">
            <view class="text">商品总价: </view>
            <view class="symbol">99.00</view>
          </view>
          <view class="row">
            <view class="text">运费: </view>
            <view class="symbol">10.00</view>
          </view>
          <view class="row">
            <view class="text">应付金额: </view>
            <view class="symbol primary">109.00</view>
          </view>
        </view>
      </view>

      <!-- 订单信息 -->
      <view class="detail">
        <view class="title">订单信息</view>
        <view class="row">
          <view class="item">
            订单编号: {{ query.id }}
            <text class="copy" @tap="onCopy(query.id)">复制</text>
          </view>
          <view class="item">下单时间: 2023-04-14 13:14:20</view>
        </view>
      </view>

      <!-- 猜你喜欢 -->
      <XtxGuess ref="guessRef" />

      <!-- 底部操作栏 -->
      <view
        class="toolbar-height"
        :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
      ></view>
      <view
        class="toolbar"
        :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
      >
        <!-- 待付款状态:展示支付按钮 -->
        <template v-if="true">
          <view class="button primary"> 去支付 </view>
          <view class="button" @tap="popup?.open?.()"> 取消订单 </view>
        </template>
        <!-- 其他订单状态:按需展示按钮 -->
        <template v-else>
          <navigator
            class="button secondary"
            :url="`/pagesOrder/create/create?orderId=${query.id}`"
            hover-class="none"
          >
            再次购买
          </navigator>
          <!-- 待收货状态: 展示确认收货 -->
          <view class="button primary"> 确认收货 </view>
          <!-- 待评价状态: 展示去评价 -->
          <view class="button"> 去评价 </view>
          <!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
          <view class="button delete"> 删除订单 </view>
        </template>
      </view>
    </template>
    <template v-else>
      <!-- 骨架屏组件 -->
      <PageSkeleton />
    </template>
  </scroll-view>
  <!-- 取消订单弹窗 -->
  <uni-popup ref="popup" type="bottom" background-color="#fff">
    <view class="popup-root">
      <view class="title">订单取消</view>
      <view class="description">
        <view class="tips">请选择取消订单的原因:</view>
        <view
          class="cell"
          v-for="item in reasonList"
          :key="item"
          @tap="reason = item"
        >
          <text class="text">{{ item }}</text>
          <text class="icon" :class="{ checked: item === reason }"></text>
        </view>
      </view>
      <view class="footer">
        <view class="button" @tap="popup?.close?.()">取消</view>
        <view class="button primary">确认</view>
      </view>
    </view>
  </uni-popup>
</template>

<style lang="scss">
page {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
}
.navbar {
  width: 750rpx;
  color: #000;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9;
  /* background-color: #f8f8f8; */
  background-color: transparent;
  .wrap {
    position: relative;
    .title {
      height: 44px;
      display: flex;
      justify-content: center;
      align-items: center;
      font-size: 32rpx;
      /* color: #000; */
      color: transparent;
    }
    .back {
      position: absolute;
      left: 0;
      height: 44px;
      width: 44px;
      font-size: 44rpx;
      display: flex;
      align-items: center;
      justify-content: center;
      /* color: #000; */
      color: #fff;
    }
  }
}
.viewport {
  background-color: #f7f7f8;
}
.overview {
  display: flex;
  flex-direction: column;
  align-items: center;
  line-height: 1;
  padding-bottom: 30rpx;
  color: #fff;
  background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/order_bg.png);
  background-size: cover;
  .status {
    font-size: 36rpx;
  }
  .status::before {
    margin-right: 6rpx;
    font-weight: 500;
  }
  .tips {
    margin: 30rpx 0;
    display: flex;
    font-size: 14px;
    align-items: center;
    .money {
      margin-right: 30rpx;
    }
  }
  .button-group {
    margin-top: 30rpx;
    display: flex;
    justify-content: center;
    align-items: center;
  }
  .button {
    width: 260rpx;
    height: 64rpx;
    margin: 0 10rpx;
    text-align: center;
    line-height: 64rpx;
    font-size: 28rpx;
    color: #27ba9b;
    border-radius: 68rpx;
    background-color: #fff;
  }
}
.shipment {
  line-height: 1.4;
  padding: 0 20rpx;
  margin: 20rpx 20rpx 0;
  border-radius: 10rpx;
  background-color: #fff;
  .locate,
  .item {
    min-height: 120rpx;
    padding: 30rpx 30rpx 25rpx 75rpx;
    background-size: 50rpx;
    background-repeat: no-repeat;
    background-position: 6rpx center;
  }
  .locate {
    background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png);
    .user {
      font-size: 26rpx;
      color: #444;
    }
    .address {
      font-size: 24rpx;
      color: #666;
    }
  }
  .item {
    background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/car.png);
    border-bottom: 1rpx solid #eee;
    position: relative;
    .message {
      font-size: 26rpx;
      color: #444;
    }
    .date {
      font-size: 24rpx;
      color: #666;
    }
  }
}
.goods {
  margin: 20rpx 20rpx 0;
  padding: 0 20rpx;
  border-radius: 10rpx;
  background-color: #fff;
  .item {
    padding: 30rpx 0;
    border-bottom: 1rpx solid #eee;
    .navigator {
      display: flex;
      margin: 20rpx 0;
    }
    .cover {
      width: 170rpx;
      height: 170rpx;
      border-radius: 10rpx;
      margin-right: 20rpx;
    }
    .meta {
      flex: 1;
      display: flex;
      flex-direction: column;
      justify-content: center;
      position: relative;
    }
    .name {
      height: 80rpx;
      font-size: 26rpx;
      color: #444;
    }
    .type {
      line-height: 1.8;
      padding: 0 15rpx;
      margin-top: 6rpx;
      font-size: 24rpx;
      align-self: flex-start;
      border-radius: 4rpx;
      color: #888;
      background-color: #f7f7f8;
    }
    .price {
      display: flex;
      margin-top: 6rpx;
      font-size: 24rpx;
    }
    .symbol {
      font-size: 20rpx;
    }
    .original {
      color: #999;
      text-decoration: line-through;
    }
    .actual {
      margin-left: 10rpx;
      color: #444;
    }
    .text {
      font-size: 22rpx;
    }
    .quantity {
      position: absolute;
      bottom: 0;
      right: 0;
      font-size: 24rpx;
      color: #444;
    }
    .action {
      display: flex;
      flex-direction: row-reverse;
      justify-content: flex-start;
      padding: 30rpx 0 0;
      .button {
        width: 200rpx;
        height: 60rpx;
        text-align: center;
        justify-content: center;
        line-height: 60rpx;
        margin-left: 20rpx;
        border-radius: 60rpx;
        border: 1rpx solid #ccc;
        font-size: 26rpx;
        color: #444;
      }
      .primary {
        color: #27ba9b;
        border-color: #27ba9b;
      }
    }
  }
  .total {
    line-height: 1;
    font-size: 26rpx;
    padding: 20rpx 0;
    color: #666;
    .row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 10rpx 0;
    }
    .symbol::before {
      content: '¥';
      font-size: 80%;
      margin-right: 3rpx;
    }
    .primary {
      color: #cf4444;
      font-size: 36rpx;
    }
  }
}
.detail {
  line-height: 1;
  padding: 30rpx 20rpx 0;
  margin: 20rpx 20rpx 0;
  font-size: 26rpx;
  color: #666;
  border-radius: 10rpx;
  background-color: #fff;
  .title {
    font-size: 30rpx;
    color: #444;
  }
  .row {
    padding: 20rpx 0;
    .item {
      padding: 10rpx 0;
      display: flex;
      align-items: center;
    }
    .copy {
      border-radius: 20rpx;
      font-size: 20rpx;
      border: 1px solid #ccc;
      padding: 5rpx 10rpx;
      margin-left: 10rpx;
    }
  }
}
.toolbar-height {
  height: 100rpx;
  box-sizing: content-box;
}
.toolbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(var(--window-bottom));
  z-index: 1;
  height: 100rpx;
  padding: 0 20rpx;
  display: flex;
  align-items: center;
  flex-direction: row-reverse;
  border-top: 1rpx solid #ededed;
  border-bottom: 1rpx solid #ededed;
  background-color: #fff;
  box-sizing: content-box;
  .button {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 200rpx;
    height: 72rpx;
    margin-left: 15rpx;
    font-size: 26rpx;
    border-radius: 72rpx;
    border: 1rpx solid #ccc;
    color: #444;
  }
  .delete {
    order: 4;
  }
  .button {
    order: 3;
  }
  .secondary {
    order: 2;
    color: #27ba9b;
    border-color: #27ba9b;
  }
  .primary {
    order: 1;
    color: #fff;
    background-color: #27ba9b;
  }
}
.popup-root {
  padding: 30rpx 30rpx 0;
  border-radius: 10rpx 10rpx 0 0;
  overflow: hidden;
  .title {
    font-size: 30rpx;
    text-align: center;
    margin-bottom: 30rpx;
  }
  .description {
    font-size: 28rpx;
    padding: 0 20rpx;
    .tips {
      color: #444;
      margin-bottom: 12rpx;
    }
    .cell {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 15rpx 0;
      color: #666;
    }
    .icon::before {
      content: '\e6cd';
      font-family: 'erabbit' !important;
      font-size: 38rpx;
      color: #999;
    }
    .icon.checked::before {
      content: '\e6cc';
      font-size: 38rpx;
      color: #27ba9b;
    }
  }
  .footer {
    display: flex;
    justify-content: space-between;
    padding: 30rpx 0 40rpx;
    font-size: 28rpx;
    color: #444;
    .button {
      flex: 1;
      height: 72rpx;
      text-align: center;
      line-height: 72rpx;
      margin: 0 20rpx;
      color: #444;
      border-radius: 72rpx;
      border: 1rpx solid #ccc;
    }
    .primary {
      color: #fff;
      background-color: #27ba9b;
      border: none;
    }
  }
}
</style>

2.2 自定义导航栏交互

  1. 导航栏左上角按钮:获取当前页面栈,如果不能返回上一页,按钮变成返回首页

    js
    // 获取页面栈 返回页面数组
    const pages = getCurrentPages()
  2. 导航栏动画效果:滚动驱动的动画,根据滚动位置而不断改变动画的进度

    js
    this(页面实例对象).animate(selector, keyframes, duration, ScrollTimeline)

滚动驱动的动画目前仅支持微信小程序端,暂不支持 H5 端、App 端,多端兼容时添加条件编译。

导航栏交互

参考代码

vue
<script setup lang="ts">
// 获取页面栈 返回历史页面数组
const pages = getCurrentPages()
// 获取当前页面实例(页面栈最后一项)
const pageInstance = pages.at(-1) as any

// 页面渲染完毕,绑定动画效果
onReady(() => {
  // 动画效果,导航栏背景色
  pageInstance.animate(
    '.navbar',  // 添加过渡动画的元素
    [  // 关键帧动画
      {   // 初始状态
        backgroundColor: 'transparent',
      },
      {  // 最终状态
        backgroundColor: '#00c09c',
      },
    ],
    1000, // 持续时间
    {
      scrollSource: '#scroller', // 指定滚动元素的选择器 scroll-view
      timeRange: 1000,  // 持续时间
      startScrollOffset: 0, // 过渡动画开始的偏移量
      endScrollOffset: 50,  // 过渡动画结束的偏移量
    },
  )
  // 动画效果,导航栏标题
  pageInstance.animate(
    '.navbar .title',
    [
      {
        color: 'transparent',
      },
      {
        color: '#fff',
      },
    ],
    1000,
    {
      scrollSource: '#scroller',
      timeRange: 1000,
      startScrollOffset: 0,
      endScrollOffset: 50,
    },
  )
})
</script>

<template>
  <!-- 自定义导航栏: 背景色透明不可见, scroll-view 滚动到 50 时展示 -->
  <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
    <view class="wrap">
      <navigator
        v-if="pages.length > 1"
        open-type="navigateBack"
        class="back icon-left"
      ></navigator>
      <navigator v-else url="/pages/index/index" open-type="switchTab" class="back icon-home">
      </navigator>
      <!-- 文字颜色透明,当scroll-view滚动到50时展示 -->
      <view class="title">订单详情</view>
    </view>
  </view>
  <scroll-view class="viewport" scroll-y enable-back-to-top id="scroller">
    ...滚动容器
  </scroll-view>
</template>

注意:滚动驱动的动画需要在 onReady() 生命周期函数中设置

版本升级

  • uni-app 不支持 animate 类型。
  • 原生微信小程序 支持 animate 类型
  • 当前需求可基于 原生微信小程序 的 Page 实例类型 扩展 uni-app 的 Page 实例,参考代码 👇
ts
// 基于小程序的 Page 实例类型扩展 uni-app 的 Page
type PageInstance = Page.PageInstance & WechatMiniprogram.Page.InstanceMethods<any>
const pageInstance = pages.at(-1) as PageInstance

const pageInstance = pages.at(-1) as any

2.3 获取订单详情

请求封装

ts
/**
 * 获取订单详情的接口方法
 * @param id 订单id
 */
export const getOrderDetailAPI = (id: string) =>
  http.get<OrderResult>(`/member/order/${id}`)

类型声明

ts
/** 订单详情 返回信息 */
export type OrderResult = {
  /** 订单编号 */
  id: string
  /** 订单状态,1为待付款、2为待发货、3为待收货、4为待评价、5为已完成、6为已取消 */
  orderState: number
  /** 倒计时--剩余的秒数 -1 表示已经超时,正数表示倒计时未结束 */
  countdown: number
  /** 商品集合 [ 商品信息 ] */
  skus: OrderSkuItem[]
  /** 收货人 */
  receiverContact: string
  /** 收货人手机 */
  receiverMobile: string
  /** 收货人完整地址 */
  receiverAddress: string
  /** 下单时间 */
  createTime: string
  /** 商品总价 */
  totalMoney: number
  /** 运费 */
  postFee: number
  /** 应付金额 */
  payMoney: number
}
/** 商品信息 */
export type OrderSkuItem = {
  /** sku id */
  id: string
  /** 商品 id */
  spuId: string
  /** 商品名称 */
  name: string
  /** 商品属性文字 */
  attrsText: string
  /** 数量 */
  quantity: number
  /** 购买时单价 */
  curPrice: number
  /** 图片地址 */
  image: string
}

2.4 订单状态

image-20240803181901135

在订单详情中除了展示订单信息外,还需要根据不同订单状态展示不同的内容。

订单状态(orderState)含义
1待付款(可取消)
2待发货
3待收货
4待评价
5已完成
6已取消

订单状态常量

枚举的作用:通过枚举来替代无意义的订单状态数字,提高程序的可读性

src\services\constants.ts

ts
/** 订单状态枚举 */
export enum OrderState {
  /** 待付款 */
  DaiFuKuan = 1,
  /** 待发货 */
  DaiFaHuo = 2,
  /** 待收货 */
  DaiShouHuo = 3,
  /** 待评价 */
  DaiPingJia = 4,
  /** 已完成 */
  YiWanCheng = 5,
  /** 已取消 */
  YiQuXiao = 6,
}

/** 订单状态列表 */
export const orderStateList = [
  { id: 0, text: '' },
  { id: 1, text: '待付款' },
  { id: 2, text: '待发货' },
  { id: 3, text: '待收货' },
  { id: 4, text: '待评价' },
  { id: 5, text: '已完成' },
  { id: 6, text: '已取消' },
]

根据后端返回的数据渲染订单详情。

vue
<script setup lang="ts">
import { OrderState, orderStateList } from '@/services/constants'

// 获取订单详情
const getOrderDetail = async () => {
  const result = await getOrderDetailAPI(query.id)
  // 存储订单详情的数据
  orderDetail.value = result.result
}

onLoad(() => {
  getOrderDetail()
})
</script>

<template>
  <!-- 订单状态 -->
  <view class="overview">
    <!-- 待付款状态:展示去支付按钮和倒计时 -->
    <template v-if="order.orderState === OrderState.DaiFuKuan">
      <view class="status icon-clock">等待付款</view>
      <view class="tips">
        <text class="money">应付金额: ¥ 99.00</text>
        <text class="time">支付剩余</text>
        00 时 29 分 59 秒
      </view>
      <view class="button">去支付</view>
    </template>
    <!-- 其他订单状态:展示再次购买按钮 -->
    <template v-else>
      <!-- 订单状态文字 -->
      <view class="status"> {{ orderStateList[order.orderState].text }} </view>
      <navigator
        class="button"
        :url="`/pagesOrder/create/create?orderId=${query.id}`"
        hover-class="none"
      >
        再次购买
      </navigator>
      <!-- 待发货状态:模拟发货,开发期间使用,用于修改订单状态为已发货 -->
      <view v-if="false" class="button"> 模拟发货 </view>
    </template>
  </view>
</template>

2.5 再次购买

现在是第三种生成订单信息,从订单详情页的【再次购买】按钮跳转到填写订单页,需要传递页面参数订单id

接口封装

ts
/**
 * 获取再次购买商品数据的接口方法
 * @param id 订单id
 */
export const getOrderBuyAgainInfoAPI = (id: string) =>
  http.get<OrderInfo>(`/member/order/repurchase/${id}`)

订单详情页

vue
<template>
  <navigator
    class="button"
    hover-class="none"
    :url="`/pagesOrder/create/create?orderId=${query.id}`"
  >
    再次购买
  </navigator>
</template>

填写订单页

vue
<script setup lang="ts">
// 获取页面参数
const query = defineProps<{
  /**商品SKUID */
  skuId?: string
  /**购买数量 */
  count?: string
  /**下单时已经选择好的地址id */
  addressId?: string
  /**再次购买的订单id */
  orderId?: string
}>()

// 获取再次购买订单数据
const getOrderBuyAgainInfo = async () => {
  const result = await getOrderBuyAgainInfoAPI(query.orderId!)
  orderPreInfo.value = result.result
}

onLoad(() => {
  // 判断是点击立即购买跳转还是购物车中点击结算跳转
  if (query.skuId) {
    // 获取立即购买商品数据
    getOrderBuyNowInfo()
  } else if (query.orderId) {
    // 获取再次购买订单数据
    getOrderBuyAgainInfo()
  } else {
    // 获取购物车中选中商品数据
    getOrderPreInfo()
  }
})
</script>

2.6 支付倒计时

通过 uni-ui 组件库的 uni-countdown 实现倒计时

  • uni-countdown组件绑定 @timeup(倒计时结束)事件
  • 当倒计时结束或获取到订单的倒计时为 -1时触发该事件,修改订单状态为已取消,并发请求修改后端数据
vue
<script setup lang="ts">
+// 倒计时结束回调
+const onTimeup = () => {
+  // 修改当前订单状态为已取消
+  orderDetail.value!.orderState = OrderState.YiQuXiao
+  // 发请求更新服务器中的数据
+  cancelOrderAPI(orderDetail.value!.id, { cancelReason: '订单超时' })
+}
</script>

<template>
  <!-- 待付款状态:展示去支付按钮和倒计时 -->
  <template v-if="order.orderState === OrderState.DaiFuKuan">
    <view class="status icon-clock">等待付款</view>
    <view class="tips">
      <text class="money">应付金额: ¥ 99.00</text>
      <text class="time">支付剩余</text>
      <!-- uni-ui倒计时组件 -->
+      <uni-countdown
+        :show-day="false" // 不显示天数
+        :second="orderDetail.countdown" // 总秒数
+        :show-colon="false" // 不使用 : 分割 使用 秒进行分割
+         color="#fff" // 数字颜色
+         splitorColor="#fff" // 分割符颜色
+         @timeup="onTimeup" // 倒计时结束触发的事件
+       /> 
    </view>
    <view class="button">去支付</view>
  </template>
</template>

2.7 订单支付

订单支付其实就是根据订单号查询到支付信息,在小程序中调用微信支付的 API 而已(待付款->待发货)

微信支付说明

  1. 由于微信支付的限制,仅 appidwx26729f20b9efae3a 的开发者才能调用该接口。此外,开发者还需要微信授权登录
  2. 对于其他开发者,可以使用模拟支付接口进行开发测试,调用后,订单状态将自动更新为已支付

调用接口

  • 生产环境:调用正式接口,获取微信支付参数 + 发起微信支付+判断是否支付成功+提示
  • 开发环境:调用模拟接口,通过模拟支付,修改订单状态为已支付
  • 通过环境变量区分开发环境,调用不同接口
ts
/**
 * 生产环境/开发环境模拟获取微信支付参数接口方法
 * @param orderId 订单id
 */
export const getWeMiniPayOrMockAPI = (orderId: string) =>
  import.meta.env.DEV
    ? http.get<{ msg: string }>('/pay/mock', { orderId })
    : http.get<WechatMiniprogram.RequestPaymentOption>('/pay/wxPay/miniPay', {
        orderId,
      })

参考代码

vue
<script setup lang="ts">
// 订单支付
const onOrderPay = async () => {
  // 调用支付的接口方法
  const result = await getWeMiniPayOrMockAPI(orderDetail.value!.id)
  if (import.meta.env.PROD) {
    // 发起微信支付
    wx.requestPayment(result.result as WechatMiniprogram.RequestPaymentOption)
  }
  // 关闭当前页,再跳转支付结果页,并携带订单id
  uni.redirectTo({ url: `/pagesOrder/payment/payment?id=${query.id}` })
}
</script>

<template>
+  <view class="button" @tap="onOrderPay">去支付</view>
.....
  <!-- 底部操作栏 -->
  <!-- 待付款 状态:展示去支付按钮和取消订单按钮  -->
   <template v-if="orderDetail.orderState === OrderState.DaiFuKuan">
+     <view class="button primary" @tap="onOrderPay"> 去支付 </view>
     <view class="button" @tap="popup?.open?.()"> 取消订单 </view>
     </template>
     <!-- 其他订单状态:按需展示按钮 -->
     ....
</template>

通过环境变量获取当前环境(DEV/PROD)

import.meta.envimage-20240803222059546

import.meta.env.DEV true

2.8 支付成功页

主要用于展示支付结果

src/pagesOrder/payment/payment.vue

vue
<script setup lang="ts">
import useCustomGuess from '@/hooks/useCustomGuess'

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

// 猜你喜欢
const { guessRef, onScrolltolower } = useCustomGuess()
</script>

<template>
  <scroll-view class="viewport" scroll-y @scrolltolower="onScrolltolower">
    <!-- 订单状态 -->
    <view class="overview">
      <view class="status icon-checked">支付成功</view>
      <view class="buttons">
        <navigator
          hover-class="none"
          class="button navigator"
          url="/pages/index/index"
          open-type="switchTab"
        >
          返回首页
        </navigator>
        <navigator
          hover-class="none"
          class="button navigator"
          :url="`/pagesOrder/detail/detail?id=${query.id}`"
          open-type="redirect"
        >
          查看订单
        </navigator>
      </view>
    </view>

    <!-- 猜你喜欢 -->
    <CustomGuess ref="guessRef" />
  </scroll-view>
</template>

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

.viewport {
  background-color: #f7f7f8;
}

.overview {
  line-height: 1;
  padding: 50rpx 0;
  color: #fff;
  background-color: #27ba9b;

  .status {
    font-size: 36rpx;
    font-weight: 500;
    text-align: center;
  }

  .status::before {
    display: block;
    font-size: 110rpx;
    margin-bottom: 20rpx;
  }

  .buttons {
    height: 60rpx;
    line-height: 60rpx;
    display: flex;
    justify-content: center;
    align-items: center;
    margin-top: 60rpx;
  }

  .button {
    text-align: center;
    margin: 0 10rpx;
    font-size: 28rpx;
    color: #fff;

    &:first-child {
      width: 200rpx;
      border-radius: 64rpx;
      border: 1rpx solid #fff;
    }
  }
}
</style>

2.9 模拟发货

仅在订单状态为待发货时,可模拟发货,调用后订单状态修改为待收货,包含模拟物流(待发货->待收货)

仅在开发期间使用,项目上线后应该是由商家发货

接口文档:

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

接口封装:

ts
/**
 * 模拟订单发货接口方法 在DEV环境中使用仅且订单状态必须为待发货,调用后订单状态修改为待收货,包含模拟物流
 * @param id 订单id
 */
export const getMockDeliverAPI = (id: string) =>
  http.get(`/member/order/consignment/${id}`)

实施步骤:

  • 条件渲染待发货按钮,并绑定事件
  • 点击模拟发货按钮,调用API更新后端数据,并更新页面数据,驱动页面更新
  • 模拟物流信息,并在订单状态为待收货时展示

src\services\constants.ts

js
/**模拟物流信息 */
export const orderDeliverStateList = [
  {
    id: 3,
    text: '【广州市天河区】快递员完成取件,准备发往【郑州集散点】',
    time: '2024-5-2',
  },
  { id: 2, text: '商家已发货,正在通知快递员取件', time: '2024-5-2' },
  { id: 1, text: '您的订单开始拣货', time: '2024-5-1' },
  { id: 0, text: '您已提交订单,已通知商家配货', time: '2024-5-1' },
]

src\pagesOrder\detail\detail.vue

vue
<script setup lang="ts">
// 当前是否为开发环境
+let isDev = import.meta.env.DEV 
    
+// 模拟订单发货
+const onMockDeliver = async () => {
// 如果当前为开发环境打包以下代码,生产环境不会进行打包(tree shaking 树摇优化)
+ if (isDev) {
+   // 将当前订单状态调整为,待收货
+   // 修改后端数据
+   await getMockDeliverAPI(orderDetail.value!.id)
+   uni.showToast({ title: '模拟发货成功', icon: 'none' })
+   // 修改前端数据,驱动页面视图更新
+   // orderDetail.value!.orderState = OrderState.DaiShouHuo
+   // 重新获取订单信息
+   getOrderDetail()
+  }
+}
</script>

<template>
  <!-- 模拟发货:在开发环境且订单状态为待发货时可用 -->
  <view
+    v-if="
+      isDev && orderDetail.orderState === OrderState.DaiFaHuo
+    "
+    @tap="onMockDeliver"
    class="button"
  >
    模拟发货
  </view>
   <!-- 配送状态 -->
  <view
    class="shipment"
+    v-show="orderDetail.orderState === OrderState.DaiShouHuo"
    ><!-- 用户收货地址 -->
    <view class="locate">
      <view class="user">
        {{ orderDetail.receiverContact }} {{ orderDetail.receiverMobile }}
      </view>
      <view class="address"> {{ orderDetail.receiverAddress }} </view>
    </view>
    <!-- 订单物流信息 -->
    <view
+      v-for="(item, index) in orderDeliverStateList"
+      :key="item.id"
      class="item"
    >
+      <view class="message" :class="{ 'message-active': index === 0 }">
+        {{ item.text }}
+      </view>
+      <view class="date"> {{ item.data }} </view>
    </view>
  </view>
</template>

注意:在模板中不能直接使用 import.meta.env.DEV,可以将其保存到变量中

2.10确认收货

点击确认收货时需二次确认,提示文案:为保障您的权益,请收到货并确认无误后,再确认收货(待收货->待评价)

接口封装

ts
/**
 * 确认收货接口方法
 * @param id
 * @returns 订单详情
 */
export const confirmReceiptAPI = (id: string) =>
  http.put<OrderResult>(`/member/order/${id}/receipt`)

参考代码

vue
<script setup lang="ts">
// 确认收货
const onConfirmReceipt = async () => {
  // 二次确认弹窗
  const { confirm } = await uni.showModal({
    title: '确认收货',
    content: '为保障您的权益,请收到货并确认无误后,再确认收货',
  })
  // 如果确认
  if (confirm) {
    // 发请求确认收货 待收货 -> 待评价
    const result = await confirmReceiptAPI(query.id)
    // 将返回的最新订单数据覆盖本地数据
    orderDetail.value = result.result
    uni.showToast({ title: '确认收货成功', icon: 'success' })
    // 修改订单状态为待评价
    // orderDetail.value!.orderState = OrderState.DaiPingJia
  }
}
....
// 底部导航栏确认收货按钮(按需展示)
</script>

<template>
  <!-- 收货状态:展示确认收货按钮 -->
+  <view
+    v-if="orderDetail.orderState === OrderState.DaiShouHuo"
+    @tap="onConfirmReceipt"
+    class="button"
+    >确认收货</view>
</template>

2.11 物流信息

仅在订单状态为待收货,待评价,已完成时,可获取物流信息

接口详情

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

请求封装

ts
/**
 * 获取订单物流
 * @description 仅在订单状态为待收货,待评价,已完成时,可获取物流信息。
 * @param id 订单id
 * @returns 物流信息
 */
export const getOrderLogisticsAPI = (id: string) =>
  http.get<OrderLogisticResult>(`/member/order/${id}/logistics`)

/** 物流信息 返回值类型 */
export type OrderLogisticResult = {
  /** 快递公司 */
  company: {
    /** 公司名称 */
    name: string
    /** 快递编号 */
    number: string
    /** 联系电话 */
    tel: string
  }
  /** 商品件数 */
  count: number
  /** 物流日志 */
  list: LogisticItem[]
}

/** 物流日志 */
export type LogisticItem = {
  /** 信息ID */
  id: string
  /** 信息文字 */
  text: string
  /** 时间 */
  time: string
}

获取数据并渲染:

vue
<script>
// 物流信息
+const orderDeliverStateList = ref<LogisticItem[]>([])

// 获取订单详情
const getOrderDetail = async () => {
  const result = await getOrderDetailAPI(query.id)
  // 存储订单详情的数据
  orderDetail.value = result.result
+  // 如果当前订单状态为待收货,待评价,已完成获取订单物流信息
+  if ([3, 4, 5].includes(orderDetail.value.orderState)) {
+    getOrderLogistics()
+  }
}
// 获取物流信息
+const getOrderLogistics = async () => {
+  const result = await getOrderLogisticsAPI(query.id)
+  // 存储物流信息
+  orderDeliverStateList.value = result.result.list
+}
</script>
<template>
<!-- 配送状态 -->
<view class="shipment" v-show="[3, 4, 5].includes(orderDetail.orderState)"
  ><!-- 用户收货地址 -->
  <view class="locate">
    <view class="user">
 +     {{ orderDetail.receiverContact }} {{ orderDetail.receiverMobile }}
    </view>
 +   <view class="address"> {{ orderDetail.receiverAddress }} </view>
  </view>
  <!-- 订单物流信息 -->
  <view
 +   v-for="(item, index) in orderDeliverStateList"
 +   :key="item.id"
    class="item"
  >
 +   <view class="message" :class="{ 'message-active': index === 0 }"> //最后一项物流信息高亮
 +     {{ item.text }}
 +   </view>
 +   <view class="date"> {{ item.time }} </view>
  </view>
</view>
</template>

使用物流接口返回的数据,替换模拟的数据

2.12 页面刷新

需求:当前在订单详情页面刷新时,重新获取订单的数据(包括物流信息),并重置猜你喜欢的数据

vue
<script>
// scroll-view 刷新状态
const refreshTrigger = ref(false)

// scroll-view 下拉刷新
const onRefresh = async () => {
  refreshTrigger.value = true
  // 重新获取订单数据(物流信息)和猜你喜欢数据
  await Promise.all([getOrderDetail(), guessRef.value?.resetData()])
  // 关闭刷新框
  refreshTrigger.value = false
}
</script>
<template>
  <scroll-view
    scroll-y
    class="viewport"
    id="scroller"
+    refresher-enabled   // 允许刷新
+    refresher-default-style="white" // 刷新样式
+    refresher-background="#00c09c" // 刷新背景色
+    :refresher-triggered="refreshTrigger" // 控制刷新
+    :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }" 
+    @refresherrefresh="onRefresh"
+    @scrolltolower="onScrolltolower"
  >
   ......
</template>

2.13 删除订单

仅在订单状态为待评价,已完成,已取消时,可删除订单

接口详情

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

接口封装

ts
/**
 * 删除订单
 * @description 仅在订单状态为待评价,已完成,已取消时,可删除订单。
 * @param data ids 订单集合
 */
export const deleteMemberOrderAPI = (data: { ids: string[] }) => {
  return http({
    method: 'DELETE',
    url: `/member/order`,
    data,
  })
}

实现步骤:

  • 删除订单按钮条件渲染
  • 二次弹窗确认
  • 调用API
  • 删除成功提示,跳转到订单列表页
vue
<script>
// 删除订单
const onDeleteOrder = async () => {
  // 二次确认弹窗
  const { confirm } = await uni.showModal({
    title: '删除订单',
    content: '确定删除该订单吗?',
  })
  if (confirm) {
    // 发请求删除订单
    await deleteOrderAPI({ ids: [query.id] })
    uni.showToast({ title: '删除成功', icon: 'none' })
    // 跳转到订单列表页
    uni.redirectTo({ url: '/pagesOrder/list/list' })
  }
}
</script>
<template>
  <!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
  <view
    v-if="[4, 5, 6].includes(orderDetail!.orderState)"
    @tap="onDeleteOrder"
    class="button delete"
  >
    删除订单
  </view>
</template>

2.14 取消订单

仅在订单状态为待付款时,可取消订单

需求:用户点击取消订单按钮后显示弹出层,用户选择取消原因,点击确定可以取消订单

接口封装

ts
/**
 * 取消订单的接口方法
 * @param id(path)  订单id
 * @param data(body)  取消理由
 */
export const cancelOrderAPI = (id: string, data: { cancelReason: string }) =>
  http.put(`/member/order/${id}/cancel`,data)

实现步骤:

  • 用户点击取消按钮,展示弹出层供用户选择取消的原因
  • 用户点击取消,关闭弹出层
  • 用户点击确定,判断用户是否选择取消原因
  • 取消原因不为空,发请求修改后端数据,并修改订单状态为已取消(用于页面渲染)
  • 提示用户取消成功
vue
<script>
// 弹出层组件实例对象
const popup = ref<UniHelper.UniPopupInstance>()
// 取消原因列表
const reasonList = ref([
  '商品无货',
  '不想要了',
  '商品信息填错了',
  '地址信息填写错误',
  '商品降价',
  '其它',
])
// 订单取消原因
const reason = ref('')
    
// 取消订单
const onCancelOrder = async () => {
  // 取消原因不能为空
  if (!reason.value) return uni.showToast({ title: '请选择取消原因', icon: 'none' })
  // 关闭弹出层
  popup.value?.close?.()
  // 加载提示
  uni.showLoading()
  // 发请求取消订单,传入取消原因
  await cancelOrderAPI(query.id, { cancelReason: reason.value })
  // 重新获取订单详情/修改前端订单状态
  orderDetail.value!.orderState = OrderState.YiQuXiao
  // 关闭加载提示
  uni.hideLoading()
  // 提示取消成功
  uni.showToast({ title: '取消成功', icon: 'none' })
}
</script>
<template>
 <!-- 取消订单弹窗 -->
  <uni-popup
    ref="popup"
    type="bottom"
    borderRadius="10px 10px 0 0"
    background-color="#fff"
  >
    <view class="popup-root">
      <view class="title">订单取消</view>
      <view class="description">
        <view class="tips">请选择取消订单的原因:</view>
        <view
          class="cell"
          v-for="item in reasonList"
          :key="item"
+          @tap="reason = item" // 更新当前取消原因
        >
          <text class="text">{{ item }}</text>
+          <text class="icon" :class="{ checked: item === reason }"></text> // 更新选中图标
        </view>
      </view>
      <view class="footer">
+        <view class="button" @tap="popup?.close?.()">取消</view>
+        <view class="button primary" @tap="onCancelOrder">确认</view>
      </view>
    </view>
  </uni-popup>
</template>

2.15 复制订单号

需求:实现点击复制按钮将订单号保存到系统剪切板中

方法:调用 uni.setClipboardData({data:xxx})设置系统剪贴板的内容

注意:设置剪贴板内容后,小程序平台会自动弹出轻提示;

image-20240804120044896

实现代码:

vue
<script>
// 复制内容
const onCopy = (id: string) => {
  // 设置系统剪贴板的内容
  uni.setClipboardData({ data: id })
}
</script>
<template>
<view class="item">
    订单编号: {{ query.id }}
    <text class="copy" @tap="onCopy(query.id)">复制</text>
</view>
</template>

3. 订单列表页

根据订单的不同状态展示订单列表,并实现多 Tabs 分页加载。

订单列表

3.1 静态结构

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

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// tabs 数据
const orderTabs = ref([
  { orderState: 0, title: '全部' },
  { orderState: 1, title: '待付款' },
  { orderState: 2, title: '待发货' },
  { orderState: 3, title: '待收货' },
  { orderState: 4, title: '待评价' },
])
</script>

<template>
  <view class="viewport">
    <!-- tabs -->
    <view class="tabs">
      <text class="item" v-for="item in 5" :key="item"> 待付款 </text>
      <!-- 游标 -->
      <view class="cursor" :style="{ left: 0 * 20 + '%' }"></view>
    </view>
    <!-- 滑动容器 -->
    <swiper class="swiper">
      <!-- 滑动项 -->
      <swiper-item v-for="item in 5" :key="item">
        <!-- 订单列表 -->
        <scroll-view scroll-y class="orders">
          <view class="card" v-for="item in 2" :key="item">
            <!-- 订单信息 -->
            <view class="status">
              <text class="date">2023-04-14 13:14:20</text>
              <!-- 订单状态文字 -->
              <text>待付款</text>
              <!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
              <text class="icon-delete"></text>
            </view>
            <!-- 商品信息,点击商品跳转到订单详情,不是商品详情 -->
            <navigator
              v-for="sku in 2"
              :key="sku"
              class="goods"
              :url="`/pagesOrder/detail/detail?id=1`"
              hover-class="none"
            >
              <view class="cover">
                <image
                  mode="aspectFit"
                  src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg"
                ></image>
              </view>
              <view class="meta">
                <view class="name ellipsis">ins风小碎花泡泡袖衬110-160cm</view>
                <view class="type">藏青小花 130</view>
              </view>
            </navigator>
            <!-- 支付信息 -->
            <view class="payment">
              <text class="quantity">共5件商品</text>
              <text>实付</text>
              <text class="amount"> <text class="symbol">¥</text>99</text>
            </view>
            <!-- 订单操作按钮 -->
            <view class="action">
              <!-- 待付款状态:显示去支付按钮 -->
              <template v-if="true">
                <view class="button primary">去支付</view>
              </template>
              <template v-else>
                <navigator
                  class="button secondary"
                  :url="`/pagesOrder/create/create?orderId=id`"
                  hover-class="none"
                >
                  再次购买
                </navigator>
                <!-- 待收货状态: 展示确认收货 -->
                <view v-if="false" class="button primary">确认收货</view>
              </template>
            </view>
          </view>
          <!-- 底部提示文字 -->
          <view
            class="loading-text"
            :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
          >
            {{ true ? '没有更多数据~' : '正在加载...' }}
          </view>
        </scroll-view>
      </swiper-item>
    </swiper>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
  overflow: hidden;
}
.viewport {
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: #fff;
}
// tabs
.tabs {
  display: flex;
  justify-content: space-around;
  line-height: 60rpx;
  margin: 0 10rpx;
  background-color: #fff;
  box-shadow: 0 4rpx 6rpx rgba(240, 240, 240, 0.6);
  position: relative;
  z-index: 9;
  .item {
    flex: 1;
    text-align: center;
    padding: 20rpx;
    font-size: 28rpx;
    color: #262626;
  }
  .cursor {
    position: absolute;
    left: 0;
    bottom: 0;
    width: 20%;
    height: 6rpx;
    padding: 0 50rpx;
    background-color: #27ba9b;
    /* 过渡效果 */
    transition: all 0.4s;
  }
}
// swiper
.swiper {
  background-color: #f7f7f8;
}
// 订单列表
.orders {
  .card {
    min-height: 100rpx;
    padding: 20rpx;
    margin: 20rpx 20rpx 0;
    border-radius: 10rpx;
    background-color: #fff;
    &:last-child {
      padding-bottom: 40rpx;
    }
  }
  .status {
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: 28rpx;
    color: #999;
    margin-bottom: 15rpx;
    .date {
      color: #666;
      flex: 1;
    }
    .primary {
      color: #ff9240;
    }
    .icon-delete {
      line-height: 1;
      margin-left: 10rpx;
      padding-left: 10rpx;
      border-left: 1rpx solid #e3e3e3;
    }
  }
  .goods {
    display: flex;
    margin-bottom: 20rpx;
    .cover {
      width: 170rpx;
      height: 170rpx;
      margin-right: 20rpx;
      border-radius: 10rpx;
      overflow: hidden;
      position: relative;
    }
    .quantity {
      position: absolute;
      bottom: 0;
      right: 0;
      line-height: 1;
      padding: 6rpx 4rpx 6rpx 8rpx;
      font-size: 24rpx;
      color: #fff;
      border-radius: 10rpx 0 0 0;
      background-color: rgba(0, 0, 0, 0.6);
    }
    .meta {
      flex: 1;
      display: flex;
      flex-direction: column;
      justify-content: center;
    }
    .name {
      height: 80rpx;
      font-size: 26rpx;
      color: #444;
    }
    .type {
      line-height: 1.8;
      padding: 0 15rpx;
      margin-top: 10rpx;
      font-size: 24rpx;
      align-self: flex-start;
      border-radius: 4rpx;
      color: #888;
      background-color: #f7f7f8;
    }
    .more {
      flex: 1;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 22rpx;
      color: #333;
    }
  }
  .payment {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    line-height: 1;
    padding: 20rpx 0;
    text-align: right;
    color: #999;
    font-size: 28rpx;
    border-bottom: 1rpx solid #eee;
    .quantity {
      font-size: 24rpx;
      margin-right: 16rpx;
    }
    .amount {
      color: #444;
      margin-left: 6rpx;
    }
    .symbol {
      font-size: 20rpx;
    }
  }
  .action {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    padding-top: 20rpx;
    .button {
      width: 180rpx;
      height: 60rpx;
      display: flex;
      justify-content: center;
      align-items: center;
      margin-left: 20rpx;
      border-radius: 60rpx;
      border: 1rpx solid #ccc;
      font-size: 26rpx;
      color: #444;
    }
    .secondary {
      color: #27ba9b;
      border-color: #27ba9b;
    }
    .primary {
      color: #fff;
      background-color: #27ba9b;
    }
  }
  .loading-text {
    text-align: center;
    font-size: 28rpx;
    color: #666;
    padding: 20rpx 0;
  }
}
</style>

3.2 路由鉴权

在我的页面点击查看订单列表时:

  • 用户未登录:跳转到登录页面
  • 用户登录:跳转到订单页面

src\pages\my\my.vue

vue
<script>
// 点击查看订单列表按钮
const onClickOrderList = (type: number) => {
  // 判断用户是否登录
  if (!profile.value?.token)
    return uni.navigateTo({
      url: '/pages/login/login',
    })
  // 跳转到订单列表页并携带参数
  uni.navigateTo({
    url: `/pagesOrder/list/list?type=${type}`,
  })
}   
</script>
<template>
  <!-- 我的订单 -->
  <view class="orders">
    <view class="title">
      我的订单
 +     <view class="navigator" hover-class="none" @tap="onClickOrderList(0)">
        查看全部订单<text class="icon-right"></text>
 +     </view>
    </view>
    <view class="section">
      <!-- 订单 -->
+      <view
        v-for="item in orderTypes"
        :key="item.type"
        :class="item.icon"
        class="navigator"
+        @tap="onClickOrderList(item.type)"
+      >
        {{ item.text }}
      </view>
      <!-- 客服 -->
      <button class="contact icon-handset" open-type="contact">售后</button>
    </view>
  </view>
</template>

3.3 Tabs 滑动切换(重要)

使用 swiper组件嵌套 swiper-item组件在 swiper-item组件中设置 tabs 页面内容,实现页面的滑动切换

顶部导航栏激活状态联动

  • 订单列表的 Tabs 支持滑动切换并高亮对应的下标,和点击 Tab 标题实现订单列表切换
  • 从【我的】页面点击不同的订单分类进入订单列表不同的 Tab,能高亮对应的下标
vue
<script setup lang="ts">
+// 获取路由参数 type:订单类型(0:全部 1:待付款 2:待发货 3:待收货 4:待评价 )
+const query = defineProps<{
+  /**订单类型 */
+  type: string  // 路由参数为string类型
+}>()
// tabs 数据
const orderTabs = ref([
  { orderState: 0, title: '全部' },
  { orderState: 1, title: '待付款' },
  { orderState: 2, title: '待发货' },
  { orderState: 3, title: '待收货' },
  { orderState: 4, title: '待评价' },
])
+// 当前激活的tab的下标(==订单状态)
+const activeIndex = ref(Number(query.type) || 0)
</script>

<template>
  <view class="viewport">
    <!-- tabs -->
    <view class="tabs">
      <text
        class="item"
+        v-for="item in orderTabs"
+        :key="item.orderState"
+        @tap="activeIndex = item.orderState" // 点击文字修改当前激活的下标
      >
+        {{ item.title }}
      </text>
      <!-- 游标 -->
+      <view class="cursor" :style="{ left: activeIndex * 20 + '%' }"></view> // 通过定位+过渡动画实现移动
    </view>
    <!-- 滑动容器 -->
+    <!-- current当前激活的下标 @change轮播图切换时触发 -->
    <swiper
      class="swiper"
+      :current="activeIndex"   // 当前激活轮播图的下标
+      @change="($event) => (activeIndex = $event.detail.current)" // 修改当前激活的下标
    >
      <!-- 滑动项 -->
      <swiper-item v-for="item in orderTabs" :key="item.orderState">
        <!-- 订单列表 -->
        <scroll-view scroll-y class="orders">...省略</scroll-view>
      </swiper-item>
    </swiper>
  </view>
</template>
tips:事件回调的简写方式

tips:事件回调的简写方式

若事件回调中的逻辑较为简单可以直接写到事件绑定后,并使用 $evnet获取当前事件对象

js
@change="($event) => (activeIndex = $event.detail.current)" // 简单的赋值语句可以直接写

3.4 获取订单列表接口封装

接口信息

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

接口封装

ts
/**
 * 获取订单列表的接口方法
 * @param query (请求query参数)
 * @returns 订单列表数据
 */
export const getOrderListAPI = (query?:GetOrderListParams)=>
  http.get<OrderListResult>('/member/order',query)

/**获取订单列表的接口参数类型 */
export interface GetOrderListParams {
  /**页码 */
  page?: number
  /**每页条数 */
  pageSize?: number
  /**订单状态 */
  orderState?: number
}
/**订单列表返回数据类型 */
export interface OrderListResult {
  /** 总记录数 */
  counts: number
  /** 数据集合    [ 订单信息 ] */
  items: OrderItem[]
  /** 当前页码 */
  page: number
  /** 总页数 */
  pages: number
  /** 页尺寸 */
  pageSize: number
}
/**订单有列表项 */
export interface OrderItem extends OrderResult {
  /** 总件数 */
  totalNum: number
}

3.5 封装订单列表组件

当前页面是多 Tabs 列表的情况,每个 Tabs 都是独立的列表,且页面结构功能相近可以复用同一个组件(获取展示不同 tab 页面的数据)

为了更好维护多 Tabs 列表,把列表抽离成业务组件,在组件内部独立维护列表数据,包括分页,下拉刷新等业务,且在订单页中遍历生成的每个订单列表组件都相互独立

  1. 订单列表页,遍历生成多个订单列表组件把订单状态传递给列表组件(父传子)

src\pagesOrder\list\list.vue

vue
<script setup lang="ts">
// 导入列表组件
import OrderList from './components/OrderList.vue'
</script>

<template>
  <!-- 滑动容器 -->
  <swiper class="swiper" :current="activeIndex" @change="activeIndex = $event.detail.current">
    <!-- 滑动项 -->
    <swiper-item v-for="item in orderTabs" :key="item.title">
      <!-- 订单列表 -->
      <OrderList :order-state="item.orderState" />
    </swiper-item>
  </swiper>
</template>

2. 订单列表组件:src\pagesOrder\list\components\OrderList.vue

  • 获取父组件传递订单 id
  • 调用接口获取当前订单分类列表的数据
  • 渲染数据,条件渲染页面按钮
  • scroll-view上拉加载更多 -> 判断是否加载完毕 -> 获取数据进行拼接
  • scroll-view 下拉刷新 -> 重置数据
  • 删除订单 -> 二次确认 -> 发请求 -> 过滤本地数据
  • 订单支付 -> 判断订单是否超时 -> 发请求 -> 修改订单状态
  • 确认收货 -> 二次确认 -> 发请求 -> 修改订单状态
vue
<script setup lang="ts">
import {
  confirmReceiptAPI,
  deleteOrderAPI,
  getOrderListAPI,
  getWeMiniPayOrMockAPI,
} from '@/services/order'
import type { OrderItem } from '@/types/order'
import { onMounted, ref } from 'vue'
import { OrderState, orderStateList } from '@/services/constants'
// @ts-ignore
import { throttle, debounce } from 'lodash'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 接收父组件传递的订单数据
const props = defineProps<{
  // 当前页面订单类型
  orderState: number
}>()
// 当前页码
let currPage = ref(0)
// 总页码
let totalPages = ref(1)
// 一页每次加载的数据
let pageSize = ref(2)
// 订单列表数据
const orderList = ref<OrderItem[]>([])
// 底部加载提示文字
let loadingText = ref('正在加载...')
// 控制 scroll-view 刷新
let refreshTrigger = ref(false)

// 获取订单列表数据(上拉获取更多)
const getOrderList = throttle(async () => {
  uni.showLoading()
  // 判断是否有下一页数据
  if (!(currPage.value < totalPages.value))
    return (loadingText.value = '没有更多数据~')
  // 继续获取数据更新页码并拼接商品列表数据
  const result = await getOrderListAPI({
    page: currPage.value + 1,
    pageSize: pageSize.value,
    orderState: props.orderState,
  })
  orderList.value = orderList.value.concat(result.result.items)
  currPage.value = result.result.page
  totalPages.value = result.result.pages
  uni.hideLoading()
}, 2000)
// 下拉刷新
const onRefresh = debounce(async () => {
  refreshTrigger.value = true
  // 清空订单列表数据
  orderList.value = []
  // 重新获取第一页数据
  currPage.value = 0
  await getOrderList()
  // 关闭刷新提示框
  refreshTrigger.value = false
}, 500)
// 删除订单
const onDeleteOrder = throttle(async (orderId: string) => {
  // 二次确认弹窗
  const { confirm } = await uni.showModal({
    title: '删除订单',
    content: '确定删除该订单吗?',
  })
  if (confirm) {
    // 发请求删除订单
    await deleteOrderAPI({ ids: [orderId] })
    uni.showToast({ title: '删除成功', icon: 'none' })
    // 删除前端数据
    orderList.value = orderList.value.filter((item) => item.id !== orderId)
  }
}, 2000)
// 订单支付
const onOrderPay = throttle(async (item: OrderItem) => {
  // 判断订单是否超时
  if (item.countdown < 0) {
    uni.showToast({ title: '订单已超时不可支付', icon: 'none' })
    // 修改订单状态
    item.orderState = OrderState.YiQuXiao
    return
  }
  // 调用支付的接口方法
  const result = await getWeMiniPayOrMockAPI(item.id)
  if (import.meta.env.PROD) {
    // 发起微信支付
    wx.requestPayment(result.result as WechatMiniprogram.RequestPaymentOption)
  }
  // 支付成功提示
  uni.showToast({ title: '支付成功', icon: 'success' })
  // 更新状态
  item.orderState = OrderState.DaiFaHuo
}, 2000)
// 确认收货
const onConfirmReceipt = throttle(async (item: OrderItem) => {
  // 二次确认弹窗
  const { confirm } = await uni.showModal({
    title: '确认收货',
    content: '为保障您的权益,请收到货并确认无误后,再确认收货',
  })
  // 如果确认
  if (confirm) {
    uni.showToast({ title: '确认收货成功', icon: 'success' })
    // 发请求确认收货 待收货 -> 待评价
    const result = await confirmReceiptAPI(item.id)
    // 更新订单状态
    item.orderState = OrderState.YiWanCheng
  }
}, 2000)

// 组件使用Vue的生命周期函数
onMounted(async () => {
  // 获取当前订单分类的数据
  await getOrderList()
})
</script>
<template>
  <scroll-view
    scroll-y
    class="orders"
    @scrolltolower="getOrderList"
    refresher-enabled
    refresher-default-style="white"
    refresher-background="#eee"
    :refresher-triggered="refreshTrigger"
    @refresherrefresh="onRefresh"
  >
    <view class="card" v-for="item in orderList" :key="item.id">
      <!-- 订单信息 -->
      <view class="status">
        <text class="date">{{ item.createTime }}</text>
        <!-- 订单状态文字 -->
        <text>{{ orderStateList[item.orderState].text }}</text>
        <!-- 待评价/已完成/已取消 状态: 展示删除订单 -->
        <text
          v-if="[4, 5, 6].includes(item.orderState)"
          @tap="onDeleteOrder(item.id)"
          class="icon-delete"
        ></text>
      </view>
      <!-- 商品信息,点击商品跳转到订单详情(传递订单id),不是商品详情 -->
      <navigator
        v-for="sku in item.skus"
        :key="sku.id"
        class="goods"
        :url="`/pagesOrder/detail/detail?id=${item.id}`"
        hover-class="none"
      >
        <view class="cover">
          <image mode="aspectFit" :src="" data-missing="sku.image"></image>
        </view>
        <view class="meta">
          <view class="name ellipsis">{{ sku.name }}</view>
          <view class="type">{{ sku.attrsText }}</view>
        </view>
      </navigator>
      <!-- 支付信息 -->
      <view class="payment">
        <text class="quantity">共{{ item.totalNum }}件商品</text>
        <text>实付</text>
        <text class="amount">
          <text class="symbol">¥</text>{{ item.totalMoney }}</text
        >
      </view>
      <!-- 订单操作按钮 -->
      <view class="action">
        <!-- 待付款状态:显示去支付按钮 -->
        <template v-if="item.orderState === 1">
          <view class="button primary" @tap="onOrderPay(item)">去支付</view>
        </template>
        <template v-else>
          <navigator
            class="button secondary"
            :url="`/pagesOrder/create/create?orderId=${item.id}`"
            hover-class="none"
          >
            再次购买
          </navigator>
          <!-- 待收货状态: 展示确认收货 -->
          <view
            v-if="item.orderState === 3"
            @tap="onConfirmReceipt(item)"
            class="button primary"
            >确认收货</view
          >
        </template>
      </view>
    </view>
    <!-- 底部提示文字 -->
    <view
      class="loading-text"
      :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
    >
      {{ loadingText }}
    </view>
  </scroll-view>
</template>

<style lang="scss">
.orders {
  .card {
    min-height: 100rpx;
    padding: 20rpx;
    margin: 20rpx 20rpx 0;
    border-radius: 10rpx;
    background-color: #fff;
    &:last-child {
      padding-bottom: 40rpx;
    }
  }
  .status {
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: 28rpx;
    color: #999;
    margin-bottom: 15rpx;
    .date {
      color: #666;
      flex: 1;
    }
    .primary {
      color: #ff9240;
    }
    .icon-delete {
      line-height: 1;
      margin-left: 10rpx;
      padding-left: 10rpx;
      border-left: 1rpx solid #e3e3e3;
    }
  }
  .goods {
    display: flex;
    margin-bottom: 20rpx;
    .cover {
      width: 170rpx;
      height: 170rpx;
      margin-right: 20rpx;
      border-radius: 10rpx;
      overflow: hidden;
      position: relative;
    }
    .quantity {
      position: absolute;
      bottom: 0;
      right: 0;
      line-height: 1;
      padding: 6rpx 4rpx 6rpx 8rpx;
      font-size: 24rpx;
      color: #fff;
      border-radius: 10rpx 0 0 0;
      background-color: rgba(0, 0, 0, 0.6);
    }
    .meta {
      flex: 1;
      display: flex;
      flex-direction: column;
      justify-content: center;
    }
    .name {
      height: 80rpx;
      font-size: 26rpx;
      color: #444;
    }
    .type {
      line-height: 1.8;
      padding: 0 15rpx;
      margin-top: 10rpx;
      font-size: 24rpx;
      align-self: flex-start;
      border-radius: 4rpx;
      color: #888;
      background-color: #f7f7f8;
    }
    .more {
      flex: 1;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 22rpx;
      color: #333;
    }
  }
  .payment {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    line-height: 1;
    padding: 20rpx 0;
    text-align: right;
    color: #999;
    font-size: 28rpx;
    border-bottom: 1rpx solid #eee;
    .quantity {
      font-size: 24rpx;
      margin-right: 16rpx;
    }
    .amount {
      color: #444;
      margin-left: 6rpx;
    }
    .symbol {
      font-size: 20rpx;
    }
  }
  .action {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    padding-top: 20rpx;
    .button {
      width: 180rpx;
      height: 60rpx;
      display: flex;
      justify-content: center;
      align-items: center;
      margin-left: 20rpx;
      border-radius: 60rpx;
      border: 1rpx solid #ccc;
      font-size: 26rpx;
      color: #444;
    }
    .secondary {
      color: #27ba9b;
      border-color: #27ba9b;
    }
    .primary {
      color: #fff;
      background-color: #27ba9b;
    }
  }
  .loading-text {
    text-align: center;
    font-size: 28rpx;
    color: #666;
    padding: 20rpx 0;
  }
}
</style>

十三、小兔鲜儿 - 分享功能

1. 转发

为首页,商品详情页添加分享功能

image-20240805174515947image-20240805174537071image-20240805174557443

src\pages\index\index.vue

js
// 分享
onShareAppMessage(() => {  // 监听右上角转发按钮
  return {
    title: '我发现了一个超赞的商品,快来看看', // 分享标题
    path: '/pages/home/home', // 跳转路径
    imageUrl: '../../static/images/logo_icon.png', // 封面图
  }
})

src\pages\goods\goods.vue

js
// 分享
onShareAppMessage(() => {
  return {
    title: '这也太便宜了吧',
    path: `/pages/goods/goods?id=${goodsInfo.value?.id}`,
    imageUrl: goodsInfo.value?.mainPictures[0],
  }
})

2. 转发到朋友圈

image-20240805180823414

js
// 分享到朋友圈
onShareTimeline(() => {
  return {
    title: '我发现了一个超赞的商品,快来看看',
    path: '/pages/home/home',
    imageUrl: '../../static/images/logo_icon.png',
  }
})

注意:

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

image-20240805175827763

十四、小兔鲜儿 - 项目打包

1. 微信小程序端

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

1. 1 核心步骤

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

1.2 步骤图示

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

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

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

tips

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

2. 条件编译

常见问题:

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

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

多端登录

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

2.1 条件编译语法

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

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

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

vue
<script setup lang="ts">
// 微信平台特有API,需要条件编译
// #ifdef MP-WEIXIN
wx.login()
wx.requestPayment()
// #endif
</script>

<template>
  <!-- 微信开发能力按钮,需要条件编译 -->
  <!-- #ifdef MP-WEIXIN -->
  <button open-type="openSetting">授权管理</button>
  <button open-type="feedback">问题反馈</button>
  <button open-type="contact">联系我们</button>
  <!-- #endif -->
</template>

<style>
/* 如果出现样式兼容,也可添加条件编译 */
page {
  /* #ifdef H5 */
  background-color: pink;
  /* #endif */
}
</style>

Tips:

可通过搜索 wx.open-type 等平台关键词,快速查找需要小程序端需添加编译模式的代码。

3. 打包为 H5 端

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

3.1 核心步骤

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

3.2 路由基础路径

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

json
// manifest.json
{
  /* 网页端特有配置 */
+  "h5": {
+    "router": {
+      // 基础路径:./ 为相对路径
+      "base": "./"  // 默认为 / 服务器的根路径
+    }
  } /* 小程序特有相关 */,
  "mp-weixin": {
    // …省略
  },
  "vueVersion": "3"
}

修改后就可以访问打包后的 index.html 文件,可以打开页面

4. 打包为 APP 端

App 端 的打包,预览、测试、发行,使用 HBuilderX 工具。

image-20240805194801908

image-20240805200302253

image-20240805202004063

十五、跨端兼容

1. 样式兼容

1.1 视口差异

image-20240806100827304

image-20240806101240339

1.2 样式隔离

image-20240806160130757

问题:骨架屏在 H5端和APP端会出现样式错乱

原因:H5 端会默认开启 scoped 导致骨架屏页面中的部分共用页面样式无法获取

解决方案:将骨架屏使用的(公共/页面组件)的样式抽离为单独的.scss文件,在骨架屏页面中样式中引入

vue
<style lang="scss">
/* 解决H5和APP端默认开启scoped属性导致样式失效问题*/
/* #ifdef H5 || APP-PLUS */ // 条件编译只在H5端和APP端引入以下样式
/* 引入轮播图全局组件样式*/
@import '@/components/styles/CustomSwiper.scss';
/* 前台类目页面组件样式 */
@import './styles/CategoryPanel.scss';
/* 热门推荐页面组件样式 */
@import './styles/HotPanel.scss';
/* #endif */
</style>

骨架屏使用注意:需要将生成的结构中的行内样式修改为 class 样式(添加类名进行提取)

2. 组件兼容

image-20240806181803480

3. JSAPI兼容

image-20240806204113225

十六、uniCloud 云开发

image-20240806204640079

image-20240806210254381

uniCloud(登录 HBuilder 账号 新建服务空间选择阿里云可以使用一个月的免费服务空间

picker省市区选择器不支持 H5App端,可以基于多列picker或picker-view,自行填充城市数据

注意:基于多列picker方式的地区选择不能运行在支付宝小程序上,只有基于picker-view的可以全端运行。尤其推荐插件uni-data-picker,自带省市区的联网数据,自带懒加载。

image-20240806213041590

image-20240806220335558