feat(orderConfirm): 优化订单确认页面功能和样式
- 添加优惠券选择功能 - 增加商品数量选择 - 完善订单信息展示 - 优化支付流程 - 添加错误状态和加载状态处理 - 新增 OrderConfirmSkeleton 组件用于加载骨架屏
This commit is contained in:
@@ -124,6 +124,8 @@ export interface ShopGoods {
|
|||||||
canUseDate?: string;
|
canUseDate?: string;
|
||||||
ensureTag?: string;
|
ensureTag?: string;
|
||||||
expiredDay?: number;
|
expiredDay?: number;
|
||||||
|
// 可购买数量
|
||||||
|
canBuyNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BathSet {
|
export interface BathSet {
|
||||||
|
|||||||
253
src/components/CouponCard.scss
Normal file
253
src/components/CouponCard.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/components/CouponCard.tsx
Normal file
168
src/components/CouponCard.tsx
Normal 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
|
||||||
96
src/components/CouponList.tsx
Normal file
96
src/components/CouponList.tsx
Normal 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
|
||||||
78
src/components/OrderConfirmSkeleton.scss
Normal file
78
src/components/OrderConfirmSkeleton.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/components/OrderConfirmSkeleton.tsx
Normal file
80
src/components/OrderConfirmSkeleton.tsx
Normal 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
|
||||||
121
src/components/QuantitySelector.scss
Normal file
121
src/components/QuantitySelector.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/components/QuantitySelector.tsx
Normal file
88
src/components/QuantitySelector.tsx
Normal 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
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
.order-confirm-page {
|
.order-confirm-page {
|
||||||
padding-bottom: 100px; // 留出底部固定按钮的空间
|
padding-bottom: 100px; // 留出底部固定按钮的空间
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fixed-bottom {
|
.fixed-bottom {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -42,3 +57,51 @@
|
|||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优惠券弹窗样式
|
||||||
|
.coupon-popup {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__current {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {Image, Button, Cell, CellGroup, Input, Space, ActionSheet} from '@nutui/nutui-react-taro'
|
import {
|
||||||
|
Image,
|
||||||
|
Button,
|
||||||
|
Cell,
|
||||||
|
CellGroup,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
ActionSheet,
|
||||||
|
Popup,
|
||||||
|
Toast,
|
||||||
|
InputNumber,
|
||||||
|
ConfigProvider
|
||||||
|
} from '@nutui/nutui-react-taro'
|
||||||
import {Location, ArrowRight} from '@nutui/icons-react-taro'
|
import {Location, ArrowRight} from '@nutui/icons-react-taro'
|
||||||
import Taro, {useDidShow} from '@tarojs/taro'
|
import Taro, {useDidShow} from '@tarojs/taro'
|
||||||
import {ShopGoods} from "@/api/shop/shopGoods/model";
|
import {ShopGoods} from "@/api/shop/shopGoods/model";
|
||||||
@@ -12,6 +24,10 @@ import Gap from "@/components/Gap";
|
|||||||
import {selectPayment} from "@/api/system/payment";
|
import {selectPayment} from "@/api/system/payment";
|
||||||
import {Payment} from "@/api/system/payment/model";
|
import {Payment} from "@/api/system/payment/model";
|
||||||
import {PaymentHandler, PaymentType, buildSingleGoodsOrder} from "@/utils/payment";
|
import {PaymentHandler, PaymentType, buildSingleGoodsOrder} from "@/utils/payment";
|
||||||
|
import OrderConfirmSkeleton from "@/components/OrderConfirmSkeleton";
|
||||||
|
import CouponList from "@/components/CouponList";
|
||||||
|
import {CouponCardProps} from "@/components/CouponCard";
|
||||||
|
|
||||||
|
|
||||||
const OrderConfirm = () => {
|
const OrderConfirm = () => {
|
||||||
const [goods, setGoods] = useState<ShopGoods | null>(null);
|
const [goods, setGoods] = useState<ShopGoods | null>(null);
|
||||||
@@ -19,91 +35,254 @@ const OrderConfirm = () => {
|
|||||||
const [payments, setPayments] = useState<any[]>([])
|
const [payments, setPayments] = useState<any[]>([])
|
||||||
const [payment, setPayment] = useState<Payment>()
|
const [payment, setPayment] = useState<Payment>()
|
||||||
const [isVisible, setIsVisible] = useState<boolean>(false)
|
const [isVisible, setIsVisible] = useState<boolean>(false)
|
||||||
|
const [quantity, setQuantity] = useState<number>(1)
|
||||||
|
const [orderRemark, setOrderRemark] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState<boolean>(true)
|
||||||
|
const [error, setError] = useState<string>('')
|
||||||
|
const [payLoading, setPayLoading] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// InputNumber 主题配置
|
||||||
|
const customTheme = {
|
||||||
|
nutuiInputnumberButtonWidth: '28px',
|
||||||
|
nutuiInputnumberButtonHeight: '28px',
|
||||||
|
nutuiInputnumberInputWidth: '40px',
|
||||||
|
nutuiInputnumberInputHeight: '28px',
|
||||||
|
nutuiInputnumberInputBorderRadius: '4px',
|
||||||
|
nutuiInputnumberButtonBorderRadius: '4px',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优惠券相关状态
|
||||||
|
const [selectedCoupon, setSelectedCoupon] = useState<CouponCardProps | null>(null)
|
||||||
|
const [couponVisible, setCouponVisible] = useState<boolean>(false)
|
||||||
|
const [availableCoupons] = useState<CouponCardProps[]>([
|
||||||
|
{
|
||||||
|
amount: 5,
|
||||||
|
minAmount: 20,
|
||||||
|
type: 1,
|
||||||
|
status: 0,
|
||||||
|
title: '满20减5',
|
||||||
|
startTime: '2024-01-01',
|
||||||
|
endTime: '2024-12-31',
|
||||||
|
theme: 'red'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 10,
|
||||||
|
minAmount: 50,
|
||||||
|
type: 1,
|
||||||
|
status: 0,
|
||||||
|
title: '满50减10',
|
||||||
|
startTime: '2024-01-01',
|
||||||
|
endTime: '2024-12-31',
|
||||||
|
theme: 'orange'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 20,
|
||||||
|
minAmount: 100,
|
||||||
|
type: 1,
|
||||||
|
status: 0,
|
||||||
|
title: '满100减20',
|
||||||
|
startTime: '2024-01-01',
|
||||||
|
endTime: '2024-12-31',
|
||||||
|
theme: 'blue'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
const router = Taro.getCurrentInstance().router;
|
const router = Taro.getCurrentInstance().router;
|
||||||
const goodsId = router?.params?.goodsId;
|
const goodsId = router?.params?.goodsId;
|
||||||
|
|
||||||
const reload = async () => {
|
// 计算商品总价
|
||||||
// 默认收货地址
|
const getGoodsTotal = () => {
|
||||||
const address = await listShopUserAddress({isDefault: true});
|
if (!goods) return 0
|
||||||
if (address.length > 0) {
|
return parseFloat(goods.price || '0') * quantity
|
||||||
setAddress(address[0])
|
}
|
||||||
|
|
||||||
|
// 计算优惠券折扣
|
||||||
|
const getCouponDiscount = () => {
|
||||||
|
if (!selectedCoupon || !goods) return 0
|
||||||
|
const total = getGoodsTotal()
|
||||||
|
|
||||||
|
// 检查是否满足使用条件
|
||||||
|
if (selectedCoupon.minAmount && total < selectedCoupon.minAmount) {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
// 支付方式
|
|
||||||
const paymentList = await selectPayment({});
|
switch (selectedCoupon.type) {
|
||||||
if (paymentList && paymentList.length > 0) {
|
case 1: // 满减券
|
||||||
setPayments(paymentList?.map((d, _) => {
|
return selectedCoupon.amount
|
||||||
return {
|
case 2: // 折扣券
|
||||||
type: d.type,
|
return total * (1 - selectedCoupon.amount / 10)
|
||||||
name: d.name
|
case 3: // 免费券
|
||||||
}
|
return total
|
||||||
}))
|
default:
|
||||||
setPayment(paymentList[0])
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算实付金额
|
||||||
|
const getFinalPrice = () => {
|
||||||
|
const total = getGoodsTotal()
|
||||||
|
const discount = getCouponDiscount()
|
||||||
|
return Math.max(0, total - discount)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleSelect = (item: any) => {
|
const handleSelect = (item: any) => {
|
||||||
setPayment(payments.find(payment => payment.name === item.name))
|
setPayment(payments.find(payment => payment.name === item.name))
|
||||||
setIsVisible(false)
|
setIsVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理数量变化
|
||||||
|
const handleQuantityChange = (value: string | number) => {
|
||||||
|
const newQuantity = typeof value === 'string' ? parseInt(value) || 1 : value
|
||||||
|
setQuantity(Math.max(1, Math.min(newQuantity, goods?.stock || 999)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理优惠券选择
|
||||||
|
const handleCouponSelect = (coupon: CouponCardProps) => {
|
||||||
|
const total = getGoodsTotal()
|
||||||
|
|
||||||
|
// 检查是否满足使用条件
|
||||||
|
if (coupon.minAmount && total < coupon.minAmount) {
|
||||||
|
Toast.show(`需满${coupon.minAmount}元才能使用此优惠券`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCoupon(coupon)
|
||||||
|
setCouponVisible(false)
|
||||||
|
Toast.show('优惠券选择成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消选择优惠券
|
||||||
|
const handleCouponCancel = () => {
|
||||||
|
setSelectedCoupon(null)
|
||||||
|
Toast.show('已取消使用优惠券')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一支付入口
|
* 统一支付入口
|
||||||
*/
|
*/
|
||||||
const onPay = async (goods: ShopGoods) => {
|
const onPay = async (goods: ShopGoods) => {
|
||||||
// 基础校验
|
try {
|
||||||
if (!address) {
|
setPayLoading(true)
|
||||||
Taro.showToast({
|
|
||||||
title: '请选择收货地址',
|
|
||||||
icon: 'error'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payment) {
|
// 基础校验
|
||||||
Taro.showToast({
|
if (!address) {
|
||||||
title: '请选择支付方式',
|
Toast.show('请选择收货地址')
|
||||||
icon: 'error'
|
return;
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建订单数据
|
|
||||||
const orderData = buildSingleGoodsOrder(
|
|
||||||
goods.goodsId!,
|
|
||||||
1,
|
|
||||||
address.id,
|
|
||||||
{
|
|
||||||
comments: goods.name,
|
|
||||||
deliveryType: 0,
|
|
||||||
buyerRemarks: '',
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// 根据支付方式选择支付类型
|
if (!payment) {
|
||||||
const paymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
|
Toast.show('请选择支付方式')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 执行支付
|
// 库存校验
|
||||||
await PaymentHandler.pay(orderData, paymentType);
|
if (goods.stock !== undefined && quantity > goods.stock) {
|
||||||
|
Toast.show('商品库存不足')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建订单数据
|
||||||
|
const orderData = buildSingleGoodsOrder(
|
||||||
|
goods.goodsId!,
|
||||||
|
quantity,
|
||||||
|
address.id,
|
||||||
|
{
|
||||||
|
comments: goods.name,
|
||||||
|
deliveryType: 0,
|
||||||
|
buyerRemarks: orderRemark,
|
||||||
|
couponId: selectedCoupon ? selectedCoupon.amount : undefined,
|
||||||
|
couponDiscount: getCouponDiscount()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据支付方式选择支付类型
|
||||||
|
const paymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
|
||||||
|
|
||||||
|
// 执行支付
|
||||||
|
await PaymentHandler.pay(orderData, paymentType);
|
||||||
|
|
||||||
|
Toast.show('支付成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('支付失败:', error)
|
||||||
|
Toast.show('支付失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setPayLoading(false)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 统一的数据加载函数
|
||||||
|
const loadAllData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// 并行加载商品信息和其他数据
|
||||||
|
const promises = []
|
||||||
|
|
||||||
|
if (goodsId) {
|
||||||
|
promises.push(getShopGoods(Number(goodsId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
promises.push(listShopUserAddress({isDefault: true}))
|
||||||
|
promises.push(selectPayment({}))
|
||||||
|
|
||||||
|
const [goodsRes, addressRes, paymentRes] = await Promise.all(promises)
|
||||||
|
|
||||||
|
// 设置商品信息
|
||||||
|
if (goodsRes) {
|
||||||
|
setGoods(goodsRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认收货地址
|
||||||
|
if (addressRes && addressRes.length > 0) {
|
||||||
|
setAddress(addressRes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置支付方式
|
||||||
|
if (paymentRes && paymentRes.length > 0) {
|
||||||
|
setPayments(paymentRes.map((d) => ({
|
||||||
|
type: d.type,
|
||||||
|
name: d.name
|
||||||
|
})))
|
||||||
|
setPayment(paymentRes[0])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载数据失败:', err)
|
||||||
|
setError('加载数据失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
reload().then()
|
loadAllData()
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (goodsId) {
|
loadAllData()
|
||||||
getShopGoods(Number(goodsId)).then(res => {
|
|
||||||
setGoods(res);
|
|
||||||
}).catch(error => {
|
|
||||||
console.error("Failed to fetch goods detail:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
reload().then()
|
|
||||||
}, [goodsId]);
|
}, [goodsId]);
|
||||||
|
|
||||||
if (!goods) {
|
// 重新加载数据
|
||||||
return <div>加载中...</div>;
|
const handleRetry = () => {
|
||||||
|
loadAllData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View className="order-confirm-page">
|
||||||
|
<View className="error-state">
|
||||||
|
<Text className="error-text">{error}</Text>
|
||||||
|
<Button onClick={handleRetry}>重新加载</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
if (loading || !goods) {
|
||||||
|
return <OrderConfirmSkeleton/>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,7 +299,7 @@ const OrderConfirm = () => {
|
|||||||
<View className={'font-medium text-sm flex items-center w-full'}>
|
<View className={'font-medium text-sm flex items-center w-full'}>
|
||||||
<View
|
<View
|
||||||
style={{width: '64%'}}>{address.province} {address.city} {address.region} {address.address}</View>
|
style={{width: '64%'}}>{address.province} {address.city} {address.region} {address.address}</View>
|
||||||
<ArrowRight className={'text-gray-500'} size={14}/>
|
<ArrowRight className={'text-gray-400'} size={14}/>
|
||||||
</View>
|
</View>
|
||||||
</Space>
|
</Space>
|
||||||
<View className={'pt-1 pb-3 text-gray-500'}>{address.name} {address.phone}</View>
|
<View className={'pt-1 pb-3 text-gray-500'}>{address.name} {address.phone}</View>
|
||||||
@@ -141,20 +320,37 @@ const OrderConfirm = () => {
|
|||||||
|
|
||||||
<CellGroup>
|
<CellGroup>
|
||||||
<Cell key={goods.goodsId}>
|
<Cell key={goods.goodsId}>
|
||||||
<Space>
|
<View className={'flex w-full justify-between gap-3'}>
|
||||||
<Image src={goods.image} mode={'aspectFill'} style={{
|
<View>
|
||||||
width: '80px',
|
<Image src={goods.image} mode={'aspectFill'} style={{
|
||||||
height: '80px',
|
width: '80px',
|
||||||
}} lazyLoad={false}/>
|
height: '80px',
|
||||||
<View className={'flex flex-col'}>
|
}} lazyLoad={false}/>
|
||||||
<View className={'font-medium w-full'}>{goods.name}</View>
|
|
||||||
<View className={'number text-gray-400 text-sm py-2'}>80g/袋</View>
|
|
||||||
<Space className={'flex justify-start items-center'}>
|
|
||||||
<View className={'text-red-500'}>¥{goods.price}</View>
|
|
||||||
<View className={'text-gray-500 text-sm'}>x 1</View>
|
|
||||||
</Space>
|
|
||||||
</View>
|
</View>
|
||||||
</Space>
|
<View className={'flex flex-col w-full'} style={{width: '100%'}}>
|
||||||
|
<Text className={'font-medium w-full'}>{goods.name}</Text>
|
||||||
|
<Text className={'number text-gray-400 text-sm py-2'}>80g/袋</Text>
|
||||||
|
<View className={'flex justify-between items-center'}>
|
||||||
|
<Text className={'text-red-500'}>¥{goods.price}</Text>
|
||||||
|
<View className={'flex flex-col items-end gap-1'}>
|
||||||
|
<ConfigProvider theme={customTheme}>
|
||||||
|
<InputNumber
|
||||||
|
value={quantity}
|
||||||
|
min={1}
|
||||||
|
max={goods.stock || 999}
|
||||||
|
disabled={goods.canBuyNumber != 0}
|
||||||
|
onChange={handleQuantityChange}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
{goods.stock !== undefined && (
|
||||||
|
<Text className={'text-xs text-gray-400'}>
|
||||||
|
库存 {goods.stock} 件
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</Cell>
|
</Cell>
|
||||||
</CellGroup>
|
</CellGroup>
|
||||||
|
|
||||||
@@ -172,30 +368,46 @@ const OrderConfirm = () => {
|
|||||||
</CellGroup>
|
</CellGroup>
|
||||||
|
|
||||||
<CellGroup>
|
<CellGroup>
|
||||||
<Cell title={`商品总价(共1件)`} extra={<View className={'font-medium'}>{'¥' + goods.price}</View>}/>
|
<Cell
|
||||||
<Cell title={'优惠券'} extra={(
|
title={`商品总价(共${quantity}件)`}
|
||||||
<View className={'flex justify-between items-center'}>
|
extra={<View className={'font-medium'}>¥{getGoodsTotal().toFixed(2)}</View>}
|
||||||
<View className={'text-red-500 text-sm mr-1'}>-¥0.00</View>
|
/>
|
||||||
<ArrowRight className={'text-gray-400'} size={14}/>
|
<Cell
|
||||||
</View>
|
title={'优惠券'}
|
||||||
)}/>
|
extra={(
|
||||||
|
<View className={'flex justify-between items-center'}>
|
||||||
|
<View className={'text-red-500 text-sm mr-1'}>
|
||||||
|
{selectedCoupon ? `-¥${getCouponDiscount().toFixed(2)}` : '暂未使用'}
|
||||||
|
</View>
|
||||||
|
<ArrowRight className={'text-gray-400'} size={14}/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
onClick={() => setCouponVisible(true)}
|
||||||
|
/>
|
||||||
<Cell title={'配送费'} extra={'¥0.00'}/>
|
<Cell title={'配送费'} extra={'¥0.00'}/>
|
||||||
<Cell extra={(
|
<Cell extra={(
|
||||||
<View className={'flex items-end gap-2'}>
|
<View className={'flex items-end gap-2'}>
|
||||||
<Text>已优惠</Text>
|
<Text>已优惠</Text>
|
||||||
<Text className={'text-red-500 text-sm'}>¥0.0</Text>
|
<Text className={'text-red-500 text-sm'}>¥{getCouponDiscount().toFixed(2)}</Text>
|
||||||
<Text className={'ml-2'}>小计</Text>
|
<Text className={'ml-2'}>实付</Text>
|
||||||
<Text className={'text-gray-700 font-bold'}>¥{goods.price}</Text>
|
<Text className={'text-gray-700 font-bold'} style={{fontSize: '18px'}}>¥{getFinalPrice().toFixed(2)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}/>
|
)}/>
|
||||||
</CellGroup>
|
</CellGroup>
|
||||||
|
|
||||||
<CellGroup>
|
<CellGroup>
|
||||||
<Cell title={'订单备注'} extra={(
|
<Cell title={'订单备注'} extra={(
|
||||||
<Input placeholder={'选填,请先和商家协商一致'} style={{padding: '0'}}/>
|
<Input
|
||||||
|
placeholder={'选填,请先和商家协商一致'}
|
||||||
|
style={{padding: '0'}}
|
||||||
|
value={orderRemark}
|
||||||
|
onChange={(value) => setOrderRemark(value)}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
)}/>
|
)}/>
|
||||||
</CellGroup>
|
</CellGroup>
|
||||||
|
|
||||||
|
{/* 支付方式选择 */}
|
||||||
<ActionSheet
|
<ActionSheet
|
||||||
visible={isVisible}
|
visible={isVisible}
|
||||||
options={payments}
|
options={payments}
|
||||||
@@ -203,18 +415,85 @@ const OrderConfirm = () => {
|
|||||||
onCancel={() => setIsVisible(false)}
|
onCancel={() => setIsVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 优惠券选择弹窗 */}
|
||||||
|
<Popup
|
||||||
|
visible={couponVisible}
|
||||||
|
position="bottom"
|
||||||
|
onClose={() => setCouponVisible(false)}
|
||||||
|
style={{height: '60vh'}}
|
||||||
|
>
|
||||||
|
<View className="coupon-popup">
|
||||||
|
<View className="coupon-popup__header">
|
||||||
|
<Text className="coupon-popup__title">选择优惠券</Text>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
fill="none"
|
||||||
|
onClick={() => setCouponVisible(false)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="coupon-popup__content">
|
||||||
|
{selectedCoupon && (
|
||||||
|
<View className="coupon-popup__current">
|
||||||
|
<Text className="coupon-popup__current-title">当前使用</Text>
|
||||||
|
<View className="coupon-popup__current-item">
|
||||||
|
<Text>{selectedCoupon.title} -¥{selectedCoupon.amount}</Text>
|
||||||
|
<Button size="small" onClick={handleCouponCancel}>取消使用</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CouponList
|
||||||
|
title="可用优惠券"
|
||||||
|
coupons={availableCoupons.filter(coupon => {
|
||||||
|
const total = getGoodsTotal()
|
||||||
|
return !coupon.minAmount || total >= coupon.minAmount
|
||||||
|
})}
|
||||||
|
layout="vertical"
|
||||||
|
onCouponClick={handleCouponSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CouponList
|
||||||
|
title="不可用优惠券"
|
||||||
|
coupons={availableCoupons.filter(coupon => {
|
||||||
|
const total = getGoodsTotal()
|
||||||
|
return coupon.minAmount && total < coupon.minAmount
|
||||||
|
}).map(coupon => ({...coupon, status: 2}))}
|
||||||
|
layout="vertical"
|
||||||
|
showEmpty={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
<Gap height={50}/>
|
<Gap height={50}/>
|
||||||
|
|
||||||
<div className={'fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-10'} style={{
|
<div className={'fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-10'} style={{
|
||||||
boxShadow: '0 -2px 4px 0 rgba(0,0,0,0.10)'
|
boxShadow: '0 -2px 4px 0 rgba(0,0,0,0.10)'
|
||||||
}}>
|
}}>
|
||||||
<View className={'btn-bar flex justify-between items-center'}>
|
<View className={'btn-bar flex justify-between items-center'}>
|
||||||
<div className={'flex justify-center items-center mx-4'}>
|
<div className={'flex flex-col justify-center items-start mx-4'}>
|
||||||
<span className={'total-price text-sm text-gray-500'}>实付金额:</span>
|
<View className={'flex items-center gap-2'}>
|
||||||
<span className={'text-red-500 text-xl font-bold'}>¥{goods.price}</span>
|
<span className={'total-price text-sm text-gray-500'}>实付金额:</span>
|
||||||
|
<span className={'text-red-500 text-xl font-bold'}>¥{getFinalPrice().toFixed(2)}</span>
|
||||||
|
</View>
|
||||||
|
{selectedCoupon && (
|
||||||
|
<View className={'text-xs text-gray-400'}>
|
||||||
|
已优惠 ¥{getCouponDiscount().toFixed(2)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={'buy-btn mx-4'}>
|
<div className={'buy-btn mx-4'}>
|
||||||
<Button type="success" size="large" onClick={() => onPay(goods)}>立即付款</Button>
|
<Button
|
||||||
|
type="success"
|
||||||
|
size="large"
|
||||||
|
loading={payLoading}
|
||||||
|
onClick={() => onPay(goods)}
|
||||||
|
>
|
||||||
|
{payLoading ? '支付中...' : '立即付款'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</View>
|
</View>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user