feat(orderConfirm): 优化订单确认页面功能和样式

- 添加优惠券选择功能
- 增加商品数量选择
- 完善订单信息展示
- 优化支付流程
- 添加错误状态和加载状态处理
- 新增 OrderConfirmSkeleton 组件用于加载骨架屏
This commit is contained in:
2025-08-11 17:27:00 +08:00
parent c6fcf9c2e5
commit bcaf8203e4
10 changed files with 1314 additions and 86 deletions

View File

@@ -0,0 +1,253 @@
.coupon-card {
position: relative;
display: flex;
width: 100%;
height: 100px;
margin-bottom: 12px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background: #fff;
&--disabled {
opacity: 0.6;
}
// 主题颜色
&--red {
.coupon-card__left {
background: linear-gradient(135deg, #ff6b6b, #ff5252);
}
.coupon-card__btn--receive,
.coupon-card__btn--use {
background: linear-gradient(135deg, #ff6b6b, #ff5252);
color: #fff;
}
}
&--orange {
.coupon-card__left {
background: linear-gradient(135deg, #ffa726, #ff9800);
}
.coupon-card__btn--receive,
.coupon-card__btn--use {
background: linear-gradient(135deg, #ffa726, #ff9800);
color: #fff;
}
}
&--blue {
.coupon-card__left {
background: linear-gradient(135deg, #42a5f5, #2196f3);
}
.coupon-card__btn--receive,
.coupon-card__btn--use {
background: linear-gradient(135deg, #42a5f5, #2196f3);
color: #fff;
}
}
&--purple {
.coupon-card__left {
background: linear-gradient(135deg, #ab47bc, #9c27b0);
}
.coupon-card__btn--receive,
.coupon-card__btn--use {
background: linear-gradient(135deg, #ab47bc, #9c27b0);
color: #fff;
}
}
&--green {
.coupon-card__left {
background: linear-gradient(135deg, #66bb6a, #4caf50);
}
.coupon-card__btn--receive,
.coupon-card__btn--use {
background: linear-gradient(135deg, #66bb6a, #4caf50);
color: #fff;
}
}
// 左侧金额区域
&__left {
flex: 0 0 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
position: relative;
}
&__amount {
display: flex;
align-items: baseline;
margin-bottom: 4px;
}
&__currency {
font-size: 14px;
font-weight: 500;
margin-right: 1px;
}
&__value {
font-size: 28px;
font-weight: bold;
line-height: 1;
}
&__condition {
font-size: 11px;
opacity: 0.9;
margin-top: 2px;
}
// 分割线区域
&__divider {
flex: 0 0 2px;
position: relative;
background: #f0f0f0;
}
&__divider-line {
width: 100%;
height: 100%;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 4px,
#ddd 4px,
#ddd 8px
);
}
&__circle {
position: absolute;
width: 16px;
height: 16px;
background: #f5f5f5;
border-radius: 50%;
left: 50%;
transform: translateX(-50%);
&--top {
top: -8px;
}
&--bottom {
bottom: -8px;
}
}
// 右侧信息区域
&__right {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 12px;
}
&__info {
flex: 1;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
&__validity {
font-size: 11px;
color: #999;
}
&__action {
display: flex;
justify-content: flex-end;
align-items: center;
}
&__btn {
min-width: 50px;
height: 24px;
border-radius: 12px;
font-size: 11px;
border: none;
&--receive,
&--use {
color: #fff;
}
}
&__status {
font-size: 12px;
color: #999;
padding: 4px 8px;
}
// 状态遮罩
&__mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
&__mask-text {
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
}
}
// 优惠券列表容器
.coupon-list {
padding: 16px;
&__title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
&__empty {
text-align: center;
padding: 40px 20px;
color: #999;
font-size: 14px;
}
}
// 优惠券横向滚动容器
.coupon-scroll {
display: flex;
padding: 16px;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
.coupon-card {
flex: 0 0 240px;
margin-bottom: 0;
}
}

View File

@@ -0,0 +1,168 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button } from '@nutui/nutui-react-taro'
import './CouponCard.scss'
export interface CouponCardProps {
/** 优惠券金额 */
amount: number
/** 最低消费金额 */
minAmount?: number
/** 优惠券类型1-满减券 2-折扣券 3-免费券 */
type?: 1 | 2 | 3
/** 优惠券状态0-未使用 1-已使用 2-已过期 */
status?: 0 | 1 | 2
/** 优惠券标题 */
title?: string
/** 有效期开始时间 */
startTime?: string
/** 有效期结束时间 */
endTime?: string
/** 是否显示领取按钮 */
showReceiveBtn?: boolean
/** 是否显示使用按钮 */
showUseBtn?: boolean
/** 领取按钮点击事件 */
onReceive?: () => void
/** 使用按钮点击事件 */
onUse?: () => void
/** 优惠券样式主题red | orange | blue | purple | green */
theme?: 'red' | 'orange' | 'blue' | 'purple' | 'green'
}
const CouponCard: React.FC<CouponCardProps> = ({
amount,
minAmount,
type = 1,
status = 0,
title,
startTime,
endTime,
showReceiveBtn = false,
showUseBtn = false,
onReceive,
onUse,
theme = 'red'
}) => {
// 格式化优惠券金额显示
const formatAmount = () => {
switch (type) {
case 1: // 满减券
return `¥${amount}`
case 2: // 折扣券
return `${amount}`
case 3: // 免费券
return '免费'
default:
return `¥${amount}`
}
}
// 获取优惠券状态文本
const getStatusText = () => {
switch (status) {
case 0:
return '未使用'
case 1:
return '已使用'
case 2:
return '已过期'
default:
return '未使用'
}
}
// 获取使用条件文本
const getConditionText = () => {
if (type === 3) return '无门槛'
if (minAmount && minAmount > 0) {
return `${minAmount}可用`
}
return '无门槛'
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getMonth() + 1}.${date.getDate()}`
}
// 获取有效期文本
const getValidityText = () => {
if (startTime && endTime) {
return `${formatDate(startTime)}-${formatDate(endTime)}`
}
return ''
}
return (
<View className={`coupon-card coupon-card--${theme} ${status !== 0 ? 'coupon-card--disabled' : ''}`}>
{/* 左侧金额区域 */}
<View className="coupon-card__left">
<View className="coupon-card__amount">
<Text className="coupon-card__currency">¥</Text>
<Text className="coupon-card__value">{amount}</Text>
</View>
<View className="coupon-card__condition">
{getConditionText()}
</View>
</View>
{/* 中间分割线 */}
<View className="coupon-card__divider">
<View className="coupon-card__divider-line"></View>
<View className="coupon-card__circle coupon-card__circle--top"></View>
<View className="coupon-card__circle coupon-card__circle--bottom"></View>
</View>
{/* 右侧信息区域 */}
<View className="coupon-card__right">
<View className="coupon-card__info">
<View className="coupon-card__title">
{title || (type === 1 ? '满减券' : type === 2 ? '折扣券' : '免费券')}
</View>
<View className="coupon-card__validity">
{getValidityText()}
</View>
</View>
{/* 按钮区域 */}
<View className="coupon-card__action">
{showReceiveBtn && status === 0 && (
<Button
className="coupon-card__btn coupon-card__btn--receive"
size="small"
onClick={onReceive}
>
</Button>
)}
{showUseBtn && status === 0 && (
<Button
className="coupon-card__btn coupon-card__btn--use"
size="small"
onClick={onUse}
>
使
</Button>
)}
{status !== 0 && (
<View className="coupon-card__status">
{getStatusText()}
</View>
)}
</View>
</View>
{/* 状态遮罩 */}
{status !== 0 && (
<View className="coupon-card__mask">
<Text className="coupon-card__mask-text">{getStatusText()}</Text>
</View>
)}
</View>
)
}
export default CouponCard

View File

@@ -0,0 +1,96 @@
import React from 'react'
import { View, ScrollView } from '@tarojs/components'
import CouponCard, { CouponCardProps } from './CouponCard'
import './CouponCard.scss'
export interface CouponListProps {
/** 优惠券列表数据 */
coupons: CouponCardProps[]
/** 列表标题 */
title?: string
/** 布局方式vertical-垂直布局 horizontal-水平滚动 */
layout?: 'vertical' | 'horizontal'
/** 是否显示空状态 */
showEmpty?: boolean
/** 空状态文案 */
emptyText?: string
/** 优惠券点击事件 */
onCouponClick?: (coupon: CouponCardProps, index: number) => void
}
const CouponList: React.FC<CouponListProps> = ({
coupons = [],
title,
layout = 'vertical',
showEmpty = true,
emptyText = '暂无优惠券',
onCouponClick
}) => {
const handleCouponClick = (coupon: CouponCardProps, index: number) => {
onCouponClick?.(coupon, index)
}
// 垂直布局
if (layout === 'vertical') {
return (
<View className="coupon-list">
{title && (
<View className="coupon-list__title">{title}</View>
)}
{coupons.length === 0 ? (
showEmpty && (
<View className="coupon-list__empty">
{emptyText}
</View>
)
) : (
coupons.map((coupon, index) => (
<View
key={index}
onClick={() => handleCouponClick(coupon, index)}
>
<CouponCard {...coupon} />
</View>
))
)}
</View>
)
}
// 水平滚动布局
return (
<View>
{title && (
<View className="coupon-list__title" style={{ paddingLeft: '16px' }}>
{title}
</View>
)}
{coupons.length === 0 ? (
showEmpty && (
<View className="coupon-list__empty">
{emptyText}
</View>
)
) : (
<ScrollView
scrollX
className="coupon-scroll"
showScrollbar={false}
>
{coupons.map((coupon, index) => (
<View
key={index}
onClick={() => handleCouponClick(coupon, index)}
>
<CouponCard {...coupon} />
</View>
))}
</ScrollView>
)}
</View>
)
}
export default CouponList

View File

@@ -0,0 +1,78 @@
.order-confirm-skeleton {
padding: 0;
background: #f5f5f5;
.skeleton-section {
background: #fff;
margin-bottom: 8px;
padding: 16px;
}
.skeleton-address {
display: flex;
align-items: flex-start;
gap: 12px;
&-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
}
.skeleton-goods {
display: flex;
align-items: flex-start;
gap: 12px;
&-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
&-price {
display: flex;
align-items: center;
gap: 12px;
}
}
.skeleton-payment {
display: flex;
justify-content: space-between;
align-items: center;
}
.skeleton-price-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.skeleton-remark {
display: flex;
justify-content: space-between;
align-items: center;
}
.skeleton-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
}
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import { View } from '@tarojs/components'
import { Skeleton } from '@nutui/nutui-react-taro'
import './OrderConfirmSkeleton.scss'
const OrderConfirmSkeleton: React.FC = () => {
return (
<View className="order-confirm-skeleton">
{/* 收货地址骨架 */}
<View className="skeleton-section">
<View className="skeleton-address">
<Skeleton width="20px" height="20px" animated />
<View className="skeleton-address-content">
<Skeleton width="200px" height="16px" animated />
<Skeleton width="120px" height="14px" animated />
</View>
</View>
</View>
{/* 商品信息骨架 */}
<View className="skeleton-section">
<View className="skeleton-goods">
<Skeleton width="80px" height="80px" animated />
<View className="skeleton-goods-content">
<Skeleton width="180px" height="16px" animated />
<Skeleton width="60px" height="14px" animated />
<View className="skeleton-goods-price">
<Skeleton width="80px" height="16px" animated />
<Skeleton width="40px" height="14px" animated />
</View>
</View>
</View>
</View>
{/* 支付方式骨架 */}
<View className="skeleton-section">
<View className="skeleton-payment">
<Skeleton width="60px" height="16px" animated />
<Skeleton width="80px" height="16px" animated />
</View>
</View>
{/* 价格明细骨架 */}
<View className="skeleton-section">
<View className="skeleton-price-item">
<Skeleton width="100px" height="16px" animated />
<Skeleton width="60px" height="16px" animated />
</View>
<View className="skeleton-price-item">
<Skeleton width="60px" height="16px" animated />
<Skeleton width="80px" height="16px" animated />
</View>
<View className="skeleton-price-item">
<Skeleton width="60px" height="16px" animated />
<Skeleton width="60px" height="16px" animated />
</View>
<View className="skeleton-price-item">
<Skeleton width="120px" height="18px" animated />
<Skeleton width="80px" height="18px" animated />
</View>
</View>
{/* 订单备注骨架 */}
<View className="skeleton-section">
<View className="skeleton-remark">
<Skeleton width="60px" height="16px" animated />
<Skeleton width="200px" height="32px" animated />
</View>
</View>
{/* 底部按钮骨架 */}
<View className="skeleton-bottom">
<Skeleton width="120px" height="20px" animated />
<Skeleton width="100px" height="40px" animated />
</View>
</View>
)
}
export default OrderConfirmSkeleton

View File

@@ -0,0 +1,121 @@
.quantity-selector {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
&__controls {
display: flex;
align-items: center;
border: 1px solid #e5e5e5;
border-radius: 4px;
overflow: hidden;
background: #fff;
}
&__btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: #f8f8f8;
color: #666;
transition: all 0.2s ease;
&:active {
background: #e5e5e5;
}
&--disabled {
background: #f5f5f5 !important;
color: #ccc !important;
cursor: not-allowed;
}
}
&__input {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-left: 1px solid #e5e5e5;
border-right: 1px solid #e5e5e5;
}
&__value {
font-size: 14px;
color: #333;
font-weight: 500;
text-align: center;
}
&__stock {
margin-top: 2px;
}
&__stock-text {
font-size: 12px;
color: #999;
}
// 尺寸变体
&--small {
.quantity-selector__controls {
height: 24px;
}
.quantity-selector__btn {
width: 24px;
height: 24px;
}
.quantity-selector__input {
width: 32px;
height: 24px;
}
.quantity-selector__value {
font-size: 12px;
}
}
&--medium {
.quantity-selector__controls {
height: 32px;
}
.quantity-selector__btn {
width: 32px;
height: 32px;
}
.quantity-selector__input {
width: 40px;
height: 32px;
}
.quantity-selector__value {
font-size: 14px;
}
}
&--large {
.quantity-selector__controls {
height: 40px;
}
.quantity-selector__btn {
width: 40px;
height: 40px;
}
.quantity-selector__input {
width: 48px;
height: 40px;
}
.quantity-selector__value {
font-size: 16px;
}
}
}

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Button } from '@nutui/nutui-react-taro'
import { Minus, Plus } from '@nutui/icons-react-taro'
import './QuantitySelector.scss'
export interface QuantitySelectorProps {
/** 当前数量 */
value: number
/** 最小数量 */
min?: number
/** 最大数量(库存) */
max?: number
/** 是否禁用 */
disabled?: boolean
/** 数量变化回调 */
onChange?: (value: number) => void
/** 尺寸 */
size?: 'small' | 'medium' | 'large'
/** 是否显示库存提示 */
showStock?: boolean
/** 库存数量 */
stock?: number
}
const QuantitySelector: React.FC<QuantitySelectorProps> = ({
value,
min = 1,
max = 999,
disabled = false,
onChange,
size = 'medium',
showStock = false,
stock
}) => {
const handleDecrease = () => {
if (disabled || value <= min) return
const newValue = value - 1
onChange?.(newValue)
}
const handleIncrease = () => {
if (disabled || value >= max) return
const newValue = value + 1
onChange?.(newValue)
}
const canDecrease = !disabled && value > min
const canIncrease = !disabled && value < max
return (
<View className={`quantity-selector quantity-selector--${size}`}>
<View className="quantity-selector__controls">
<Button
className={`quantity-selector__btn quantity-selector__btn--minus ${!canDecrease ? 'quantity-selector__btn--disabled' : ''}`}
size="small"
onClick={handleDecrease}
disabled={!canDecrease}
>
<Minus size={size === 'small' ? 12 : size === 'large' ? 16 : 14} />
</Button>
<View className="quantity-selector__input">
<Text className="quantity-selector__value">{value}</Text>
</View>
<Button
className={`quantity-selector__btn quantity-selector__btn--plus ${!canIncrease ? 'quantity-selector__btn--disabled' : ''}`}
size="small"
onClick={handleIncrease}
disabled={!canIncrease}
>
<Plus size={size === 'small' ? 12 : size === 'large' ? 16 : 14} />
</Button>
</View>
{showStock && stock !== undefined && (
<View className="quantity-selector__stock">
<Text className="quantity-selector__stock-text">
{stock}
</Text>
</View>
)}
</View>
)
}
export default QuantitySelector