案例开源地址:https://atomgit.com/nutpi/rn_openharmony_buy
请添加图片描述

写在前面

优惠券是电商 App 的常见营销工具,用户领了优惠券下单时可以抵扣一部分金额。优惠券列表页展示用户拥有的所有优惠券,分为可使用、已使用、已过期三种状态。

这个页面的设计有点意思:优惠券卡片要做成那种左边是金额、右边是信息的样式,已使用和已过期的要有明显的视觉区分。这篇文章来实现这些效果。

优惠券数据结构

先看看优惠券数据长什么样:

interface Coupon {
  id: number;
  title: string;       // 优惠券名称,如"新人专享"
  discount: number;    // 优惠金额
  minAmount: number;   // 最低消费金额
  expireDate: string;  // 过期日期
  used: boolean;       // 是否已使用
}

minAmount 是使用门槛,比如"满 100 减 20"里的 100。expireDate 是过期日期,过了这个日期优惠券就不能用了。

Tab 配置

优惠券列表有三个 Tab:可使用、已使用、已过期。

const tabs = [
  {key: 'available', label: '可使用'},
  {key: 'used', label: '已使用'},
  {key: 'expired', label: '已过期'},
];

和订单列表的 Tab 类似,用数组配置方便遍历渲染。

引入依赖

import React, {useState} from 'react';
import {View, Text, FlatList, StyleSheet, TouchableOpacity} from 'react-native';
import {useApp} from '../store/AppContext';
import {Header} from '../components/Header';
import {Empty} from '../components/Empty';
import {Coupon} from '../types';

标准的列表页依赖,没什么特别的。

组件主体

export const CouponListScreen = () => {
  const {coupons, navigate} = useApp();
  const [activeTab, setActiveTab] = useState('available');

  const now = new Date();

从全局状态拿优惠券列表。now 是当前时间,用来判断优惠券是否过期。

优惠券筛选

const filteredCoupons = coupons.filter(c => {
  const expireDate = new Date(c.expireDate);
  if (activeTab === 'available') return !c.used && expireDate >= now;
  if (activeTab === 'used') return c.used;
  if (activeTab === 'expired') return !c.used && expireDate < now;
  return true;
});

根据当前 Tab 筛选优惠券:

  • 可使用:未使用且未过期(!c.used && expireDate >= now
  • 已使用:已使用(c.used
  • 已过期:未使用但已过期(!c.used && expireDate < now

为什么已过期要判断未使用?

因为已使用的优惠券不应该出现在"已过期"里。一张优惠券要么是已使用,要么是已过期,不能同时是两种状态。如果用过了,就算过期了也应该显示在"已使用"里。

优惠券卡片渲染

优惠券卡片的渲染逻辑比较复杂,单独抽成函数:

const renderCoupon = ({item}: {item: Coupon}) => {
  const isExpired = new Date(item.expireDate) < now;
  const isDisabled = item.used || isExpired;

先判断是否过期和是否禁用。已使用或已过期的优惠券都是禁用状态,样式要变灰。

卡片结构

  return (
    <View style={[styles.couponCard, isDisabled && styles.disabledCard]}>
      <View style={[styles.leftPart, isDisabled && styles.disabledLeft]}>
        <Text style={[styles.currency, isDisabled && styles.disabledText]}>$</Text>
        <Text style={[styles.discount, isDisabled && styles.disabledText]}>{item.discount}</Text>
        <Text style={[styles.condition, isDisabled && styles.disabledText]}>满${item.minAmount}可用</Text>
      </View>

左边是金额区域,红色背景(禁用时变灰色)。显示货币符号、优惠金额和使用条件。

条件样式的写法

[styles.leftPart, isDisabled && styles.disabledLeft] 这种写法,当 isDisabledtrue 时会应用 disabledLeft 样式,为 false 时数组里是 false,React Native 会忽略它。

      <View style={styles.rightPart}>
        <Text style={[styles.title, isDisabled && styles.disabledText]}>{item.title}</Text>
        <Text style={[styles.expire, isDisabled && styles.disabledText]}>有效期至 {item.expireDate}</Text>

右边是信息区域,显示优惠券名称和有效期。

状态印章

        {item.used && (
          <View style={styles.usedStamp}>
            <Text style={styles.usedStampText}>已使用</Text>
          </View>
        )}
        {isExpired && !item.used && (
          <View style={styles.expiredStamp}>
            <Text style={styles.expiredStampText}>已过期</Text>
          </View>
        )}
      </View>

已使用和已过期的优惠券右上角有个斜着的印章,像盖了个章一样。用 transform: [{rotate: '-15deg'}] 实现旋转效果。

使用按钮

      {!isDisabled && (
        <TouchableOpacity style={styles.useBtn} onPress={() => navigate('home')}>
          <Text style={styles.useBtnText}>去使用</Text>
        </TouchableOpacity>
      )}
    </View>
  );
};

可用的优惠券右边有个"去使用"按钮,点击跳转到首页去购物。禁用的优惠券不显示这个按钮。

页面主体

return (
  <View style={styles.container}>
    <Header title="我的优惠券" />

    <View style={styles.tabs}>
      {tabs.map(tab => (
        <TouchableOpacity
          key={tab.key}
          style={[styles.tab, activeTab === tab.key && styles.activeTab]}
          onPress={() => setActiveTab(tab.key)}>
          <Text style={[styles.tabText, activeTab === tab.key && styles.activeTabText]}>
            {tab.label}
          </Text>
        </TouchableOpacity>
      ))}
    </View>

Tab 栏和订单列表类似,选中的 Tab 有下划线和红色文字。

空状态处理

    {filteredCoupons.length === 0 ? (
      <Empty
        icon="🎫"
        title={
          activeTab === 'available' ? '暂无可用优惠券' : 
          activeTab === 'used' ? '暂无已使用优惠券' : 
          '暂无过期优惠券'
        }
        subtitle={activeTab === 'available' ? '去领取优惠券吧' : ''}
        buttonText={activeTab === 'available' ? '去领券' : undefined}
        onPress={activeTab === 'available' ? () => navigate('home') : undefined}
      />
    ) : (
      <FlatList
        data={filteredCoupons}
        keyExtractor={item => item.id.toString()}
        contentContainerStyle={styles.list}
        renderItem={renderCoupon}
      />
    )}
  </View>
);
};

空状态的文案根据当前 Tab 不同而不同。"可使用"Tab 下显示"去领券"按钮,其他 Tab 不显示按钮(已使用和已过期的优惠券没什么可操作的)。

三元表达式嵌套

这里用了嵌套的三元表达式来决定 title。如果逻辑更复杂,建议抽成一个函数,不然可读性会很差。

样式详解

优惠券卡片的核心样式:

couponCard: {
  flexDirection: 'row',
  backgroundColor: '#fff',
  borderRadius: 12,
  marginBottom: 12,
  overflow: 'hidden',
  alignItems: 'center',
},
disabledCard: {opacity: 0.7},

卡片是横向布局,overflow: 'hidden' 让圆角生效(不然左边的红色区域会超出圆角)。禁用时整体透明度降低。

左边金额区域:

leftPart: {
  width: 100,
  backgroundColor: '#e74c3c',
  justifyContent: 'center',
  alignItems: 'center',
  paddingVertical: 20,
  alignSelf: 'stretch',
},
disabledLeft: {backgroundColor: '#ccc'},
discount: {fontSize: 32, fontWeight: 'bold', color: '#fff'},
condition: {fontSize: 11, color: 'rgba(255,255,255,0.8)', marginTop: 4},

固定宽度 100,红色背景。alignSelf: 'stretch' 让高度撑满整个卡片。金额用大号加粗字体,使用条件用小号半透明字体。

印章样式:

usedStamp: {
  position: 'absolute',
  top: 8,
  right: 8,
  transform: [{rotate: '-15deg'}],
},
usedStampText: {
  fontSize: 12,
  color: '#999',
  borderWidth: 1,
  borderColor: '#999',
  paddingHorizontal: 6,
  paddingVertical: 2,
  borderRadius: 4,
},

绝对定位在右上角,旋转 -15 度。边框样式让它看起来像个印章。

Context 里的优惠券数据

看看全局状态里的优惠券数据:

const mockCoupons: Coupon[] = [
  {id: 1, title: '新人专享', discount: 10, minAmount: 50, expireDate: '2025-02-28', used: false},
  {id: 2, title: '满减优惠', discount: 20, minAmount: 100, expireDate: '2025-01-31', used: false},
  {id: 3, title: '限时折扣', discount: 15, minAmount: 80, expireDate: '2025-01-15', used: true},
  {id: 4, title: '会员专享', discount: 30, minAmount: 200, expireDate: '2025-03-15', used: false},
];

const [coupons, setCoupons] = useState<Coupon[]>(mockCoupons);

初始化了一些模拟数据,包括不同状态的优惠券。

使用优惠券的方法:

const useCoupon = (id: number) => {
  setCoupons(prev => prev.map(c => (c.id === id ? {...c, used: true} : c)));
};

把指定优惠券的 used 设为 true。这个方法在结算页使用优惠券时调用。

优惠券倒计时

快过期的优惠券可以显示倒计时,提醒用户赶紧使用:

const getDaysLeft = (expireDate: string) => {
  const diff = new Date(expireDate).getTime() - Date.now();
  return Math.ceil(diff / (1000 * 60 * 60 * 24));
};

// 在卡片里
const daysLeft = getDaysLeft(item.expireDate);
{daysLeft <= 3 && daysLeft > 0 && (
  <Text style={styles.urgentText}>还剩 {daysLeft} 天过期</Text>
)}

计算距离过期还有几天,如果小于等于 3 天就显示提醒。用红色文字,制造紧迫感。

领券功能

当前优惠券是写死的,正式项目应该有领券功能:

const claimCoupon = async (couponId: number) => {
  // 调用后端接口领取优惠券
  const newCoupon = await api.claimCoupon(couponId);
  setCoupons(prev => [...prev, newCoupon]);
};

可以做一个领券中心页面,展示可领取的优惠券,用户点击领取后添加到自己的优惠券列表。

优惠券详情

点击优惠券可以查看详情,包括使用规则、适用商品范围等:

<TouchableOpacity onPress={() => navigate('couponDetail', {coupon: item})}>
  {/* 卡片内容 */}
</TouchableOpacity>

详情页展示更多信息,比如"仅限指定商品使用"、"不可与其他优惠叠加"等规则说明。

写在最后

优惠券列表页的核心是状态筛选和视觉区分。三种状态(可使用、已使用、已过期)通过 Tab 切换,每种状态的优惠券有不同的样式:可用的是红色,禁用的是灰色,还有印章标记。

优惠券卡片的设计比较有特色,左边金额区域用醒目的颜色,右边是详细信息。这种设计让用户一眼就能看到优惠金额,是电商 App 常见的优惠券样式。

下一篇写优惠券选择页,在结算时选择要使用的优惠券。


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

Logo

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

更多推荐