Browse Source
- 添加优惠券选择功能 - 增加商品数量选择 - 完善订单信息展示 - 优化支付流程 - 添加错误状态和加载状态处理 - 新增 OrderConfirmSkeleton 组件用于加载骨架屏master
10 changed files with 1313 additions and 85 deletions
@ -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; |
||||
|
} |
||||
|
} |
@ -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 |
@ -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 |
@ -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); |
||||
|
} |
||||
|
} |
@ -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 |
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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 |
Loading…
Reference in new issue