rn_for_openharmony商城项目app实战-结算实现
本文介绍了电商App结算页面的实现过程,重点讲解了如何设计清晰、高效的结算流程。作者首先分析了结算页面的核心要素,包括收货地址、商品清单、价格汇总和提交按钮等关键模块。通过引入全局状态管理购物车数据、地址和优惠券信息,实现了页面数据的整合展示。文章详细说明了优惠券筛选逻辑、价格计算规则以及订单提交的校验流程,强调了防御性编程和用户体验优化的重要性。最后分享了页面布局结构和收货地址卡片的UI实现细节
案例开源地址: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');
};
校验通过后,三步走:
- 调用
createOrder创建订单 - 调用
clearCart清空购物车 - 跳转到支付成功页面
实际项目中,这里应该先调支付接口,支付成功后再创建订单。我这里为了演示简化了流程,直接创建订单跳转成功页面。
页面整体结构
先看一下页面的骨架:
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
更多推荐
所有评论(0)