案例开源地址:https://atomgit.com/nutpi/rn_openharmony_buy

写在前面

结算页面是电商 App 里最关键的页面,没有之一。用户从浏览商品、加入购物车,一路走到这里,就差最后一步了。这个页面做得好不好,直接影响转化率。

我见过太多 App 在结算页面翻车的。有的加载太慢,用户等不及就走了;有的信息太乱,用户不知道自己到底要付多少钱;有的流程太长,用户填着填着就烦了。所以做这个页面的时候,我特别注意这几点:信息要清晰、流程要简单、响应要快

这篇文章记录一下结算页面的实现过程,内容比较多,涉及到地址选择、商品清单、优惠券、价格计算等多个模块。
请添加图片描述

先理清楚页面要展示什么

在动手写代码之前,我先列了一下这个页面需要展示的信息:

必须有的:

  • 收货地址:送到哪里
  • 商品清单:买了什么
  • 价格汇总:要付多少钱
  • 提交按钮:完成下单

锦上添花的:

  • 优惠券选择:能省点钱
  • 运费信息:虽然我们是免运费,但也要告诉用户

想清楚了再动手,事半功倍。

引入需要的依赖

import React, {useState} from 'react';

useState 用来管理优惠券选择状态。

import {View, Text, ScrollView, StyleSheet, TouchableOpacity, Image, Alert} from 'react-native';

这里引入的组件比较多。ScrollView 是因为结算页面内容可能很长,需要滚动。Image 用来显示商品图片。Alert 用来弹提示,比如地址为空时提醒用户。

import {useApp} from '../store/AppContext';
import {Header} from '../components/Header';
import {Coupon} from '../types';

useApp 是我们的全局状态 hook,里面有购物车数据、地址数据、优惠券数据等等。Coupon 是优惠券的类型定义。

从全局状态获取数据

export const CheckoutScreen = () => {
  const {cart, totalPrice, defaultAddress, addresses, navigate, createOrder, clearCart, coupons} = useApp();

一口气从全局状态里取了一堆东西,我来解释一下每个是干嘛的:

cart:购物车里的商品列表,是个数组

totalPrice:购物车商品的总价,已经在 Context 里算好了

defaultAddress:用户设置的默认收货地址

addresses:用户所有的收货地址列表

navigate:页面跳转方法

createOrder:创建订单的方法

clearCart:清空购物车的方法

coupons:用户的优惠券列表

你可能会问,为什么要取这么多东西?因为结算页面就是个信息汇总页面,需要把各种数据整合在一起展示给用户。

  const [selectedCoupon, setSelectedCoupon] = useState<Coupon | null>(null);

这个 state 用来存用户选择的优惠券。默认是 null,表示没选优惠券。

优惠券筛选和价格计算

这部分逻辑比较重要,单独拎出来说:

const availableCoupons = coupons.filter(c => !c.used && totalPrice >= c.minAmount);

筛选可用的优惠券,条件有两个:

  • !c.used:没用过
  • totalPrice >= c.minAmount:订单金额达到使用门槛

比如一张"满100减10"的券,如果订单金额只有80块,这张券就不能用。在结算页面实时筛选,用户看到的都是能用的券,不会出现"选了券发现用不了"的尴尬情况。

const discount = selectedCoupon ? selectedCoupon.discount : 0;

如果选了优惠券,折扣金额就是优惠券的 discount 值;没选就是 0。

const finalPrice = Math.max(0, totalPrice - discount);

最终价格 = 商品总价 - 优惠券折扣。

这里用 Math.max(0, ...) 做了个兜底,防止出现负数。虽然正常情况下不会出现(优惠券折扣不可能比商品总价还大),但防御性编程是个好习惯。

选择优惠券的逻辑

const handleSelectCoupon = () => {
  navigate('couponSelect', {
    onSelect: (coupon: Coupon | null) => {
      setSelectedCoupon(coupon);
    },
    minAmount: totalPrice,
  });
};

点击优惠券区域,跳转到优惠券选择页面。传了两个参数:

onSelect:这是个回调函数,优惠券选择页面选完券后会调用这个函数,把选中的券传回来。这种页面间传回调的方式很常用,比用全局状态或者事件总线简单直接。

minAmount:当前订单金额,优惠券选择页面会用这个值来筛选可用的券。

提交订单的逻辑

这是整个页面最核心的逻辑:

const handleSubmit = () => {
  if (!defaultAddress && addresses.length === 0) {
    Alert.alert('提示', '请先添加收货地址', [
      {text: '取消'},
      {text: '添加地址', onPress: () => navigate('addressEdit')},
    ]);
    return;
  }

第一个校验:必须有收货地址。没有地址的话弹个提示,引导用户去添加。

注意这里的 Alert 有两个按钮:取消和添加地址。点"添加地址"会跳转到地址编辑页面。这种引导式的提示比单纯的"请添加地址"友好多了,用户不用自己去找入口。

  if (cart.length === 0) {
    Alert.alert('提示', '购物车为空');
    return;
  }

第二个校验:购物车不能为空。这个校验理论上不会触发(购物车空了就不应该能进结算页面),但加上更安全。

  const address = defaultAddress || addresses[0];

确定使用哪个地址:优先用默认地址,没有默认地址就用第一个地址。

这样做的好处是,用户只要有地址就能下单,不用非得设置默认地址。降低用户操作成本。

  createOrder(cart, address, selectedCoupon || undefined);
  clearCart();
  navigate('paymentSuccess');
};

校验通过后,三步走:

  1. 调用 createOrder 创建订单
  2. 调用 clearCart 清空购物车
  3. 跳转到支付成功页面

实际项目中,这里应该先调支付接口,支付成功后再创建订单。我这里为了演示简化了流程,直接创建订单跳转成功页面。

页面整体结构

先看一下页面的骨架:

return (
  <View style={styles.container}>
    <Header title="确认订单" />

    <ScrollView style={styles.content}>
      {/* 收货地址卡片 */}
      {/* 商品清单 */}
      {/* 优惠券选择 */}
      {/* 价格汇总 */}
    </ScrollView>

    <View style={styles.bottomBar}>
      {/* 合计金额和提交按钮 */}
    </View>
  </View>
);

经典的三段式布局:顶部 Header、中间可滚动内容、底部固定的结算栏。底部结算栏固定不动,用户不管滚动到哪里都能看到要付多少钱、都能点击提交。

收货地址卡片

这部分 UI 比较复杂,我拆开来讲:

<TouchableOpacity style={styles.addressCard} onPress={() => navigate('addressList')}>

整个地址卡片是可点击的,点击跳转到地址列表页面,用户可以选择其他地址。

  {defaultAddress ? (
    <>
      <View style={styles.addressIconWrap}>
        <Text style={styles.addressIcon}>📍</Text>
      </View>

如果有默认地址,显示地址信息。左边是个定位图标。

      <View style={styles.addressInfo}>
        <View style={styles.addressNameRow}>
          <Text style={styles.addressName}>{defaultAddress.name}</Text>
          <Text style={styles.addressPhone}>{defaultAddress.phone}</Text>
          {defaultAddress.isDefault && (
            <View style={styles.defaultTag}>
              <Text style={styles.defaultTagText}>默认</Text>
            </View>
          )}
        </View>

第一行显示收件人姓名、电话,如果是默认地址还会显示一个"默认"标签。标签用红色背景白色文字,很醒目。

        <Text style={styles.addressDetail} numberOfLines={2}>
          {defaultAddress.province} {defaultAddress.city} {defaultAddress.district} {defaultAddress.detail}
        </Text>
      </View>
    </>

第二行显示详细地址,numberOfLines={2} 限制最多显示两行,太长的地址会截断。

  ) : (
    <View style={styles.noAddressWrap}>
      <Text style={styles.noAddressIcon}>➕</Text>
      <Text style={styles.noAddress}>添加收货地址</Text>
    </View>
  )}
  <Text style={styles.arrow}>›</Text>
</TouchableOpacity>

如果没有地址,显示"添加收货地址"的提示。右边有个箭头,暗示这里可以点击。

地址卡片的样式:

addressCard: {
  flexDirection: 'row', 
  alignItems: 'center', 
  backgroundColor: '#fff', 
  padding: 16, 
  marginBottom: 12
},

flexDirection: 'row' 让内容横向排列:图标、地址信息、箭头从左到右。

addressIconWrap: {
  width: 40, 
  height: 40, 
  borderRadius: 20, 
  backgroundColor: '#e8f4fc', 
  justifyContent: 'center', 
  alignItems: 'center', 
  marginRight: 12
},

图标用圆形浅蓝色背景包裹,和主题色呼应。

defaultTag: {
  backgroundColor: '#e74c3c', 
  paddingHorizontal: 6, 
  paddingVertical: 2, 
  borderRadius: 4, 
  marginLeft: 8
},
defaultTagText: {fontSize: 10, color: '#fff'},

"默认"标签用红色背景,字号小一点,不抢主要信息的风头。

商品清单

<View style={styles.section}>
  <Text style={styles.sectionTitle}>商品清单 ({cart.length}件)</Text>

标题后面带上商品数量,用户一眼就知道买了多少件东西。

  {cart.map(item => (
    <View key={item.id} style={styles.productRow}>
      <Image source={{uri: item.image}} style={styles.productImage} />

遍历购物车,每个商品一行。左边是商品图片。

      <View style={styles.productInfo}>
        <Text style={styles.productTitle} numberOfLines={2}>{item.title}</Text>
        <View style={styles.productBottom}>
          <Text style={styles.productPrice}>${item.price.toFixed(2)}</Text>
          <Text style={styles.productQty}>x{item.quantity}</Text>
        </View>
      </View>
    </View>
  ))}
</View>

右边是商品信息:标题、单价、数量。标题限制两行,价格用红色突出显示,数量用灰色。主次分明。

商品行的样式:

productRow: {
  flexDirection: 'row', 
  paddingVertical: 12, 
  borderBottomWidth: 1, 
  borderBottomColor: '#f0f0f0'
},

每行商品之间用浅灰色分割线隔开。

productImage: {width: 80, height: 80, borderRadius: 8, backgroundColor: '#f9f9f9'},

图片固定 80x80,圆角 8。backgroundColor: '#f9f9f9' 是图片加载前的占位背景色,不然会是一片空白。

productPrice: {fontSize: 16, fontWeight: 'bold', color: '#e74c3c'},
productQty: {fontSize: 14, color: '#999'},

价格用红色加粗,数量用灰色,视觉层次清晰。

优惠券选择

<TouchableOpacity style={styles.couponCard} onPress={handleSelectCoupon}>
  <Text style={styles.couponIcon}>🎫</Text>
  <Text style={styles.couponLabel}>优惠券</Text>

优惠券区域也是可点击的,点击跳转到优惠券选择页面。

  <View style={styles.couponRight}>
    {selectedCoupon ? (
      <Text style={styles.couponSelected}>-${selectedCoupon.discount}</Text>
    ) : availableCoupons.length > 0 ? (
      <Text style={styles.couponAvailable}>{availableCoupons.length}张可用</Text>
    ) : (
      <Text style={styles.couponNone}>暂无可用</Text>
    )}
    <Text style={styles.arrow}>›</Text>
  </View>
</TouchableOpacity>

右边的显示有三种情况:

已选择优惠券:显示折扣金额,比如"-$10",红色

有可用优惠券但未选择:显示可用数量,比如"3张可用",红色,吸引用户去选

没有可用优惠券:显示"暂无可用",灰色

couponSelected: {fontSize: 16, color: '#e74c3c', fontWeight: '600', marginRight: 8},
couponAvailable: {fontSize: 14, color: '#e74c3c', marginRight: 8},
couponNone: {fontSize: 14, color: '#999', marginRight: 8},

价格汇总

这部分是用户最关心的:

<View style={styles.section}>
  <View style={styles.summaryRow}>
    <Text style={styles.summaryLabel}>商品金额</Text>
    <Text style={styles.summaryValue}>${totalPrice.toFixed(2)}</Text>
  </View>

第一行:商品金额,就是购物车里所有商品的总价。

  <View style={styles.summaryRow}>
    <Text style={styles.summaryLabel}>运费</Text>
    <Text style={styles.summaryValueFree}>免运费</Text>
  </View>

第二行:运费。我们是免运费的,用绿色显示"免运费",让用户开心一下。

  {discount > 0 && (
    <View style={styles.summaryRow}>
      <Text style={styles.summaryLabel}>优惠券</Text>
      <Text style={styles.discountValue}>-${discount.toFixed(2)}</Text>
    </View>
  )}

第三行:优惠券折扣。只有选了优惠券才显示这行,用红色带负号,表示减了多少钱。

  <View style={[styles.summaryRow, styles.totalRow]}>
    <Text style={styles.totalLabel}>实付款</Text>
    <Text style={styles.totalValue}>${finalPrice.toFixed(2)}</Text>
  </View>
</View>

最后一行:实付款,用大号红色字体突出显示。这是用户最关心的数字。

summaryValueFree: {fontSize: 14, color: '#27ae60'},
discountValue: {fontSize: 14, color: '#e74c3c'},
totalRow: {borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8, paddingTop: 16},
totalValue: {fontSize: 20, fontWeight: 'bold', color: '#e74c3c'},

实付款那行上面加了分割线,和前面的明细区分开。字号用 20,比其他行都大,一眼就能看到。

底部结算栏

<View style={styles.bottomBar}>
  <View style={styles.totalContainer}>
    <Text style={styles.bottomLabel}>合计:</Text>
    <Text style={styles.bottomPrice}>${finalPrice.toFixed(2)}</Text>
  </View>
  <TouchableOpacity style={styles.submitBtn} onPress={handleSubmit}>
    <Text style={styles.submitText}>提交订单</Text>
  </TouchableOpacity>
</View>

底部栏分两部分:左边显示合计金额,右边是提交按钮。

bottomBar: {
  flexDirection: 'row', 
  alignItems: 'center', 
  padding: 16, 
  paddingBottom: 32, 
  backgroundColor: '#fff', 
  borderTopWidth: 1, 
  borderTopColor: '#eee'
},

paddingBottom: 32 给底部安全区域留空间,防止按钮被手机底部的横条挡住。

bottomPrice: {fontSize: 24, fontWeight: 'bold', color: '#e74c3c', marginLeft: 4},

金额用 24 号字,超大,用户一眼就能看到要付多少钱。

submitBtn: {
  backgroundColor: '#e74c3c', 
  paddingHorizontal: 40, 
  paddingVertical: 14, 
  borderRadius: 25
},

提交按钮用红色,和价格颜色呼应。电商 App 的结算按钮基本都是红色或橙色,这是行业惯例,用户已经形成习惯了。

一些实际开发中踩过的坑

做结算页面的时候,我踩过几个坑,分享一下:

1. 价格精度问题

JavaScript 的浮点数计算有精度问题,比如 0.1 + 0.2 不等于 0.3。处理金额的时候一定要注意,最好用整数(分)来计算,显示的时候再转成元。我这里用 toFixed(2) 简单处理了一下,实际项目要更严谨。

2. 库存校验

用户在结算页面停留的时候,商品可能被别人买走了。提交订单前最好再校验一下库存,不然用户付了钱发现没货,体验很差。

3. 地址变更

用户可能在结算页面去改地址,改完回来页面要能刷新显示新地址。我这里用的是全局状态,地址改了会自动更新,不用手动处理。

4. 优惠券过期

用户选了优惠券,但在提交订单前优惠券过期了。提交的时候要再校验一下优惠券是否有效。

还可以优化的地方

如果时间充裕,这个页面还可以加这些功能:

  • 发票信息:有些用户需要开发票
  • 备注功能:让用户可以给订单添加备注
  • 配送时间选择:让用户选择期望的配送时间
  • 支付方式选择:支付宝、微信、银行卡等
  • 商品数量修改:在结算页面也能改数量,不用返回购物车

小结

结算页面是电商 App 的核心页面,信息量大,逻辑复杂。几个关键点:

  • 收货地址要显眼,没有地址要引导用户添加
  • 商品清单要清晰,让用户确认买的是什么
  • 优惠券要实时筛选可用的,别让用户选了用不了
  • 价格汇总要明确,特别是实付款要突出显示
  • 底部结算栏固定,方便用户随时提交

做好这个页面,转化率能提升不少。

下一篇写支付成功页面,敬请期待。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐