Files
template-10584/src/shop/orderConfirm/index.tsx
赵忠林 9d9762ef17 feat(theme): 实现主题切换系统并优化经销商相关页面
- 新增主题切换系统,支持智能主题和手动选择
- 更新经销商首页、团队、订单、提现等页面样式
- 添加主题相关的Hook和样式工具函数
- 优化部分组件样式以适配新主题
2025-08-19 00:08:26 +08:00

615 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useEffect, useState} from "react";
import {
Image,
Button,
Cell,
CellGroup,
Input,
Space,
ActionSheet,
Popup,
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";
import {getShopGoods} from "@/api/shop/shopGoods";
import {View, Text} from '@tarojs/components';
import {listShopUserAddress} from "@/api/shop/shopUserAddress";
import {ShopUserAddress} from "@/api/shop/shopUserAddress/model";
import './index.scss'
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";
import {getMyAvailableCoupons} from "@/api/shop/shopUserCoupon";
import {
transformCouponData,
calculateCouponDiscount,
isCouponUsable,
getCouponUnusableReason,
sortCoupons,
filterUsableCoupons,
filterUnusableCoupons
} from "@/utils/couponUtils";
const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null);
const [address, setAddress] = useState<ShopUserAddress>()
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, setAvailableCoupons] = useState<CouponCardProps[]>([])
const [couponLoading, setCouponLoading] = useState<boolean>(false)
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
// 计算商品总价
const getGoodsTotal = () => {
if (!goods) return 0
return parseFloat(goods.price || '0') * quantity
}
// 计算优惠券折扣
const getCouponDiscount = () => {
if (!selectedCoupon || !goods) return 0
const total = getGoodsTotal()
return calculateCouponDiscount(selectedCoupon, total)
}
// 计算实付金额
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
const finalQuantity = Math.max(1, Math.min(newQuantity, goods?.stock || 999))
setQuantity(finalQuantity)
// 数量变化时,重新排序优惠券并检查当前选中的优惠券是否还可用
if (availableCoupons.length > 0) {
const newTotal = parseFloat(goods?.price || '0') * finalQuantity
const sortedCoupons = sortCoupons(availableCoupons, newTotal)
setAvailableCoupons(sortedCoupons)
// 检查当前选中的优惠券是否还可用
if (selectedCoupon && !isCouponUsable(selectedCoupon, newTotal)) {
setSelectedCoupon(null)
Taro.showToast({
title: '当前优惠券不满足使用条件,已自动取消',
icon: 'none'
})
}
}
}
// 处理优惠券选择
const handleCouponSelect = (coupon: CouponCardProps) => {
const total = getGoodsTotal()
// 检查是否可用
if (!isCouponUsable(coupon, total)) {
const reason = getCouponUnusableReason(coupon, total)
Taro.showToast({
title: reason || '优惠券不可用',
icon: 'none'
})
return
}
setSelectedCoupon(coupon)
setCouponVisible(false)
Taro.showToast({
title: '优惠券选择成功',
icon: 'success'
})
}
// 取消选择优惠券
const handleCouponCancel = () => {
setSelectedCoupon(null)
Taro.showToast({
title: '已取消使用优惠券',
icon: 'success'
})
}
// 加载用户优惠券
const loadUserCoupons = async () => {
try {
setCouponLoading(true)
// 使用新的API获取可用优惠券
const res = await getMyAvailableCoupons()
if (res && res.length > 0) {
// 转换数据格式
const transformedCoupons = res.map(transformCouponData)
// 按优惠金额排序
const total = getGoodsTotal()
const sortedCoupons = sortCoupons(transformedCoupons, total)
setAvailableCoupons(sortedCoupons)
console.log('加载优惠券成功:', {
originalData: res,
transformedData: transformedCoupons,
sortedData: sortedCoupons
})
} else {
setAvailableCoupons([])
console.log('暂无可用优惠券')
}
} catch (error) {
console.error('加载优惠券失败:', error)
setAvailableCoupons([])
Taro.showToast({
title: '加载优惠券失败',
icon: 'none'
})
} finally {
setCouponLoading(false)
}
}
/**
* 统一支付入口
*/
const onPay = async (goods: ShopGoods) => {
try {
setPayLoading(true)
// 基础校验
if (!address) {
Taro.showToast({
title: '请选择收货地址',
icon: 'error'
})
return;
}
if (!payment) {
Taro.showToast({
title: '请选择支付方式',
icon: 'error'
})
return;
}
// 库存校验
if (goods.stock !== undefined && quantity > goods.stock) {
Taro.showToast({
title: '商品库存不足',
icon: 'error'
})
return;
}
// 优惠券校验
if (selectedCoupon) {
const total = getGoodsTotal()
if (!isCouponUsable(selectedCoupon, total)) {
const reason = getCouponUnusableReason(selectedCoupon, total)
Taro.showToast({
title: reason || '优惠券不可用',
icon: 'error'
})
return;
}
}
// 构建订单数据
const orderData = buildSingleGoodsOrder(
goods.goodsId!,
quantity,
address.id,
{
comments: goods.name,
deliveryType: 0,
buyerRemarks: orderRemark,
// 确保couponId是数字类型
couponId: selectedCoupon ? Number(selectedCoupon.id) : undefined
}
);
// 根据支付方式选择支付类型
const paymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
console.log('开始支付:', {
orderData,
paymentType,
selectedCoupon: selectedCoupon ? {
id: selectedCoupon.id,
title: selectedCoupon.title,
discount: getCouponDiscount()
} : null,
finalPrice: getFinalPrice()
});
// 执行支付 - 移除这里的成功提示让PaymentHandler统一处理
await PaymentHandler.pay(orderData, paymentType);
// ✅ 移除双重成功提示 - PaymentHandler会处理成功提示
// Taro.showToast({
// title: '支付成功',
// icon: 'success'
// })
} catch (error: any) {
console.error('支付失败:', error)
// 只处理PaymentHandler未处理的错误
if (!error.handled) {
let errorMessage = '支付失败,请重试';
// 根据错误类型提供具体提示
if (error.message?.includes('余额不足')) {
errorMessage = '账户余额不足,请充值后重试';
} else if (error.message?.includes('优惠券')) {
errorMessage = '优惠券使用失败,请重新选择';
} else if (error.message?.includes('库存')) {
errorMessage = '商品库存不足,请减少购买数量';
} else if (error.message?.includes('地址')) {
errorMessage = '收货地址信息有误,请重新选择';
} else if (error.message) {
errorMessage = error.message;
}
Taro.showToast({
title: errorMessage,
icon: 'error'
})
}
} finally {
setPayLoading(false)
}
};
// 统一的数据加载函数
const loadAllData = async () => {
try {
setLoading(true)
setError('')
// 分别加载数据,避免类型推断问题
let goodsRes: ShopGoods | null = null
if (goodsId) {
goodsRes = await getShopGoods(Number(goodsId))
}
const [addressRes, paymentRes] = await Promise.all([
listShopUserAddress({isDefault: true}),
selectPayment({})
])
// 设置商品信息
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])
}
// 加载优惠券(在商品信息加载完成后)
if (goodsRes) {
await loadUserCoupons()
}
} catch (err) {
console.error('加载数据失败:', err)
setError('加载数据失败,请重试')
} finally {
setLoading(false)
}
}
useDidShow(() => {
loadAllData()
})
useEffect(() => {
loadAllData()
}, [goodsId]);
// 重新加载数据
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 (
<div className={'order-confirm-page'}>
<CellGroup>
{
address && (
<Cell className={'address-bottom-line'} onClick={() => Taro.navigateTo({url: '/user/address/index'})}>
<Space>
<Location className={'text-gray-500'}/>
<View className={'flex flex-col w-full justify-between items-start'}>
<Space className={'flex flex-row w-full'}>
<View className={'flex-wrap text-nowrap whitespace-nowrap text-gray-500'}></View>
<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-400'} size={14}/>
</View>
</Space>
<View className={'pt-1 pb-3 text-gray-500'}>{address.name} {address.phone}</View>
</View>
</Space>
</Cell>
)
}
{!address && (
<Cell className={''} onClick={() => Taro.navigateTo({url: '/user/address/index'})}>
<Space>
<Location/>
</Space>
</Cell>
)}
</CellGroup>
<CellGroup>
<Cell key={goods.goodsId}>
<View className={'flex w-full justify-between gap-3'}>
<View>
<Image src={goods.image} mode={'aspectFill'} style={{
width: '80px',
height: '80px',
}} lazyLoad={false}/>
</View>
<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>
<CellGroup>
<Cell
title={'支付方式'}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{payment?.name}</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
onClick={() => setIsVisible(true)}
/>
</CellGroup>
<CellGroup>
<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'}>{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'}}
value={orderRemark}
onChange={(value) => setOrderRemark(value)}
maxLength={100}
/>
)}/>
</CellGroup>
{/* 支付方式选择 */}
<ActionSheet
visible={isVisible}
options={payments}
onSelect={handleSelect}
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="text-sm"></Text>
<Button
size="small"
fill="none"
onClick={() => setCouponVisible(false)}
>
</Button>
</View>
<View className="coupon-popup__content">
{couponLoading ? (
<View className="coupon-popup__loading">
<Text>...</Text>
</View>
) : (
<>
{selectedCoupon && (
<View className="coupon-popup__current">
<Text className="coupon-popup__current-title font-medium">使</Text>
<View className="coupon-popup__current-item">
<Text>{selectedCoupon.title} -{calculateCouponDiscount(selectedCoupon, getGoodsTotal()).toFixed(2)}</Text>
<Button size="small" onClick={handleCouponCancel}>使</Button>
</View>
</View>
)}
{(() => {
const total = getGoodsTotal()
const usableCoupons = filterUsableCoupons(availableCoupons, total)
const unusableCoupons = filterUnusableCoupons(availableCoupons, total)
return (
<>
<CouponList
title={`可用优惠券 (${usableCoupons.length})`}
coupons={usableCoupons}
layout="vertical"
onCouponClick={handleCouponSelect}
showEmpty={usableCoupons.length === 0}
emptyText="暂无可用优惠券"
/>
{unusableCoupons.length > 0 && (
<CouponList
title={`不可用优惠券 (${unusableCoupons.length})`}
coupons={unusableCoupons.map(coupon => ({
...coupon,
status: 2 as const
}))}
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 border-t border-gray-200'}>
<View className={'btn-bar flex justify-between items-center'}>
<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"
loading={payLoading}
onClick={() => onPay(goods)}
>
{payLoading ? '支付中...' : '立即付款'}
</Button>
</div>
</View>
</div>
</div>
);
};
export default OrderConfirm;