forked from gxwebsoft/mp-10550
feat(orderConfirm): 优化订单确认页面功能和样式
- 添加优惠券选择功能 - 增加商品数量选择 - 完善订单信息展示 - 优化支付流程 - 添加错误状态和加载状态处理 - 新增 OrderConfirmSkeleton 组件用于加载骨架屏
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
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 Taro, {useDidShow} from '@tarojs/taro'
|
||||
import {ShopGoods} from "@/api/shop/shopGoods/model";
|
||||
@@ -12,6 +24,10 @@ import Gap from "@/components/Gap";
|
||||
import {selectPayment} from "@/api/system/payment";
|
||||
import {Payment} from "@/api/system/payment/model";
|
||||
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 [goods, setGoods] = useState<ShopGoods | null>(null);
|
||||
@@ -19,91 +35,254 @@ const OrderConfirm = () => {
|
||||
const [payments, setPayments] = useState<any[]>([])
|
||||
const [payment, setPayment] = useState<Payment>()
|
||||
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 goodsId = router?.params?.goodsId;
|
||||
|
||||
const reload = async () => {
|
||||
// 默认收货地址
|
||||
const address = await listShopUserAddress({isDefault: true});
|
||||
if (address.length > 0) {
|
||||
setAddress(address[0])
|
||||
// 计算商品总价
|
||||
const getGoodsTotal = () => {
|
||||
if (!goods) return 0
|
||||
return parseFloat(goods.price || '0') * quantity
|
||||
}
|
||||
|
||||
// 计算优惠券折扣
|
||||
const getCouponDiscount = () => {
|
||||
if (!selectedCoupon || !goods) return 0
|
||||
const total = getGoodsTotal()
|
||||
|
||||
// 检查是否满足使用条件
|
||||
if (selectedCoupon.minAmount && total < selectedCoupon.minAmount) {
|
||||
return 0
|
||||
}
|
||||
// 支付方式
|
||||
const paymentList = await selectPayment({});
|
||||
if (paymentList && paymentList.length > 0) {
|
||||
setPayments(paymentList?.map((d, _) => {
|
||||
return {
|
||||
type: d.type,
|
||||
name: d.name
|
||||
}
|
||||
}))
|
||||
setPayment(paymentList[0])
|
||||
|
||||
switch (selectedCoupon.type) {
|
||||
case 1: // 满减券
|
||||
return selectedCoupon.amount
|
||||
case 2: // 折扣券
|
||||
return total * (1 - selectedCoupon.amount / 10)
|
||||
case 3: // 免费券
|
||||
return total
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 计算实付金额
|
||||
const getFinalPrice = () => {
|
||||
const total = getGoodsTotal()
|
||||
const discount = getCouponDiscount()
|
||||
return Math.max(0, total - discount)
|
||||
}
|
||||
|
||||
|
||||
const handleSelect = (item: any) => {
|
||||
setPayment(payments.find(payment => payment.name === item.name))
|
||||
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) => {
|
||||
// 基础校验
|
||||
if (!address) {
|
||||
Taro.showToast({
|
||||
title: '请选择收货地址',
|
||||
icon: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setPayLoading(true)
|
||||
|
||||
if (!payment) {
|
||||
Taro.showToast({
|
||||
title: '请选择支付方式',
|
||||
icon: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建订单数据
|
||||
const orderData = buildSingleGoodsOrder(
|
||||
goods.goodsId!,
|
||||
1,
|
||||
address.id,
|
||||
{
|
||||
comments: goods.name,
|
||||
deliveryType: 0,
|
||||
buyerRemarks: '',
|
||||
// 基础校验
|
||||
if (!address) {
|
||||
Toast.show('请选择收货地址')
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
// 根据支付方式选择支付类型
|
||||
const paymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
|
||||
if (!payment) {
|
||||
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(() => {
|
||||
reload().then()
|
||||
loadAllData()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (goodsId) {
|
||||
getShopGoods(Number(goodsId)).then(res => {
|
||||
setGoods(res);
|
||||
}).catch(error => {
|
||||
console.error("Failed to fetch goods detail:", error);
|
||||
});
|
||||
}
|
||||
reload().then()
|
||||
loadAllData()
|
||||
}, [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 (
|
||||
@@ -120,7 +299,7 @@ const OrderConfirm = () => {
|
||||
<View className={'font-medium text-sm flex items-center w-full'}>
|
||||
<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>
|
||||
</Space>
|
||||
<View className={'pt-1 pb-3 text-gray-500'}>{address.name} {address.phone}</View>
|
||||
@@ -141,20 +320,37 @@ const OrderConfirm = () => {
|
||||
|
||||
<CellGroup>
|
||||
<Cell key={goods.goodsId}>
|
||||
<Space>
|
||||
<Image src={goods.image} mode={'aspectFill'} style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
}} lazyLoad={false}/>
|
||||
<View className={'flex flex-col'}>
|
||||
<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 className={'flex w-full justify-between gap-3'}>
|
||||
<View>
|
||||
<Image src={goods.image} mode={'aspectFill'} style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
}} lazyLoad={false}/>
|
||||
</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>
|
||||
</CellGroup>
|
||||
|
||||
@@ -172,30 +368,46 @@ const OrderConfirm = () => {
|
||||
</CellGroup>
|
||||
|
||||
<CellGroup>
|
||||
<Cell title={`商品总价(共1件)`} extra={<View className={'font-medium'}>{'¥' + goods.price}</View>}/>
|
||||
<Cell title={'优惠券'} extra={(
|
||||
<View className={'flex justify-between items-center'}>
|
||||
<View className={'text-red-500 text-sm mr-1'}>-¥0.00</View>
|
||||
<ArrowRight className={'text-gray-400'} size={14}/>
|
||||
</View>
|
||||
)}/>
|
||||
<Cell
|
||||
title={`商品总价(共${quantity}件)`}
|
||||
extra={<View className={'font-medium'}>¥{getGoodsTotal().toFixed(2)}</View>}
|
||||
/>
|
||||
<Cell
|
||||
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 extra={(
|
||||
<View className={'flex items-end gap-2'}>
|
||||
<Text>已优惠</Text>
|
||||
<Text className={'text-red-500 text-sm'}>¥0.0</Text>
|
||||
<Text className={'ml-2'}>小计</Text>
|
||||
<Text className={'text-gray-700 font-bold'}>¥{goods.price}</Text>
|
||||
<Text className={'text-red-500 text-sm'}>¥{getCouponDiscount().toFixed(2)}</Text>
|
||||
<Text className={'ml-2'}>实付</Text>
|
||||
<Text className={'text-gray-700 font-bold'} style={{fontSize: '18px'}}>¥{getFinalPrice().toFixed(2)}</Text>
|
||||
</View>
|
||||
)}/>
|
||||
</CellGroup>
|
||||
|
||||
<CellGroup>
|
||||
<Cell title={'订单备注'} extra={(
|
||||
<Input placeholder={'选填,请先和商家协商一致'} style={{padding: '0'}}/>
|
||||
<Input
|
||||
placeholder={'选填,请先和商家协商一致'}
|
||||
style={{padding: '0'}}
|
||||
value={orderRemark}
|
||||
onChange={(value) => setOrderRemark(value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
)}/>
|
||||
</CellGroup>
|
||||
|
||||
{/* 支付方式选择 */}
|
||||
<ActionSheet
|
||||
visible={isVisible}
|
||||
options={payments}
|
||||
@@ -203,18 +415,85 @@ const OrderConfirm = () => {
|
||||
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}/>
|
||||
|
||||
<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)'
|
||||
}}>
|
||||
<View className={'btn-bar flex justify-between items-center'}>
|
||||
<div className={'flex justify-center items-center mx-4'}>
|
||||
<span className={'total-price text-sm text-gray-500'}>实付金额:</span>
|
||||
<span className={'text-red-500 text-xl font-bold'}>¥{goods.price}</span>
|
||||
<div className={'flex flex-col justify-center items-start mx-4'}>
|
||||
<View className={'flex items-center gap-2'}>
|
||||
<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 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>
|
||||
</View>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user