Files
glt-taro/src/shop/orderConfirm/index.tsx
赵忠林 86f7506422 fix(order): 解决支付取消后加载状态未正确重置问题
- 在支付流程中添加 skipFinallyResetPayLoading 标志来控制加载状态
- 检测用户取消支付情况并跳转到待付款订单列表页面
- 优化支付取消后的页面导航逻辑,支持 redirectTo 和 navigateTo 两种方式
- 修改订单列表中的按钮文案"取消订单"为"取消"
- 修改订单列表中的按钮文案"立即支付"为"继续支付"
2026-03-08 10:41:00 +08:00

1189 lines
41 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, useMemo, useState} from "react";
import {
Image,
Button,
Cell,
CellGroup,
Input,
Space,
ActionSheet,
Popup,
InputNumber,
DatePicker,
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 {getGltTicketTemplateByGoodsId} from "@/api/glt/gltTicketTemplate";
import type {GltTicketTemplate} from "@/api/glt/gltTicketTemplate/model";
import {
transformCouponData,
calculateCouponDiscount,
isCouponUsable,
getCouponUnusableReason,
sortCoupons,
filterUsableCoupons,
filterUnusableCoupons
} from "@/utils/couponUtils";
import dayjs from 'dayjs'
import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
import { ensureLoggedIn, isLoggedIn } from '@/utils/auth'
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)
// 配送时间(仅水票套票商品需要)
// 当日截单时间:超过该时间下单,最早配送日顺延到次日(避免 21:00 下单仍显示“当天配送”)
const DELIVERY_CUTOFF_HOUR = 21
const getMinSendDate = () => {
const now = dayjs()
const cutoff = now.hour(DELIVERY_CUTOFF_HOUR).minute(0).second(0).millisecond(0)
const startOfToday = now.startOf('day')
// >= 截单时间则最早只能选次日
return now.isSame(cutoff) || now.isAfter(cutoff) ? startOfToday.add(1, 'day') : startOfToday
}
const [sendTime, setSendTime] = useState<Date>(() => getMinSendDate().toDate())
const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false)
// 水票套票活动(若存在则按规则限制最小购买量等)
const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null)
// 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)
// 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 SelectedStore Storage
const [storePopupVisible, setStorePopupVisible] = useState(false)
const [stores, setStores] = useState<ShopStore[]>([])
const [storeLoading, setStoreLoading] = useState(false)
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
// 页面级兜底:未登录直接进入下单页时,引导去注册/登录并回跳
useEffect(() => {
if (!goodsId) {
// 也可能是 orderData 模式;这里只做最小兜底
if (!ensureLoggedIn('/shop/orderConfirm/index')) return
return
}
if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goodsId}`)) return
}, [goodsId])
const isTicketTemplateActive =
!!ticketTemplate &&
ticketTemplate.enabled !== false &&
ticketTemplate.status !== 1 &&
ticketTemplate.deleted !== 1
const hasTicketTemplate = !!ticketTemplate
// 套票活动最低购买量:优先取模板配置
const ticketMinBuyQty = (() => {
const n = Number(ticketTemplate?.minBuyQty)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1
})()
const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
}, [sendTime])
const getGiftTicketQty = (buyQty: number) => {
if (!isTicketTemplateActive) return 0
const multiplier = Number(ticketTemplate?.giftMultiplier || 0)
const startSend = Number(ticketTemplate?.startSendQty || 0)
if (multiplier > 0) return Math.max(0, buyQty) * multiplier
return Math.max(0, startSend)
}
const loadStores = async () => {
if (storeLoading) return
try {
setStoreLoading(true)
const list = await listShopStore()
setStores((list || []).filter(s => s?.isDelete !== 1))
} catch (e) {
console.error('获取门店列表失败:', e)
setStores([])
Taro.showToast({title: '获取门店列表失败', icon: 'none'})
} finally {
setStoreLoading(false)
}
}
// @ts-ignore
const openStorePopup = async () => {
setStorePopupVisible(true)
if (!stores.length) {
await loadStores()
}
}
// 计算商品总价
const getGoodsTotal = () => {
if (!goods) return 0
const price = parseFloat(goods.price || '0')
// const total = price * quantity
// 🔍 详细日志,用于排查数值精度问题
// console.log('💵 商品总价计算:', {
// goodsPrice: goods.price,
// goodsPriceType: typeof goods.price,
// parsedPrice: price,
// quantity: quantity,
// total: total,
// totalFixed2: total.toFixed(2),
// totalString: total.toString()
// })
return price * 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 fallback = isTicketTemplateActive ? minBuyQty : 1
const newQuantity = typeof value === 'string' ? parseInt(value, 10) || fallback : value
const finalQuantity = Math.max(fallback, Math.min(newQuantity, goods?.stock || 999))
setQuantity(finalQuantity)
// 数量变化时,重新排序优惠券并检查当前选中的优惠券是否还可用
if (availableCoupons.length > 0) {
const newTotal = parseFloat(goods?.price || '0') * finalQuantity
const sortedCoupons = sortCoupons(availableCoupons, newTotal)
const usableCoupons = filterUsableCoupons(sortedCoupons, newTotal)
setAvailableCoupons(sortedCoupons)
// 检查当前选中的优惠券是否还可用
if (selectedCoupon && !isCouponUsable(selectedCoupon, newTotal)) {
setSelectedCoupon(null)
Taro.showToast({
title: '当前优惠券不满足使用条件,已自动取消',
icon: 'none'
})
// 🎯 自动推荐新的最优优惠券
if (usableCoupons.length > 0) {
const bestCoupon = usableCoupons[0]
const discount = calculateCouponDiscount(bestCoupon, newTotal)
if (discount > 0) {
setSelectedCoupon(bestCoupon)
Taro.showToast({
title: `已为您重新推荐最优优惠券,可省¥${discount.toFixed(2)}`,
icon: 'success',
duration: 3000
})
}
}
} else if (!selectedCoupon && usableCoupons.length > 0) {
// 🔔 如果没有选中优惠券但有可用的,推荐最优的
const bestCoupon = usableCoupons[0]
const discount = calculateCouponDiscount(bestCoupon, newTotal)
if (discount > 0) {
setSelectedCoupon(bestCoupon)
Taro.showToast({
title: `已为您推荐最优优惠券,可省¥${discount.toFixed(2)}`,
icon: 'success',
duration: 3000
})
}
} else if (selectedCoupon && usableCoupons.length > 0) {
// 🔍 检查是否有更好的优惠券
const bestCoupon = usableCoupons[0]
const currentDiscount = calculateCouponDiscount(selectedCoupon, newTotal)
const bestDiscount = calculateCouponDiscount(bestCoupon, newTotal)
// 如果有更好的优惠券优惠超过0.01元)
if (bestDiscount > currentDiscount + 0.01 && bestCoupon.id !== selectedCoupon.id) {
Taro.showModal({
title: '发现更优惠的优惠券',
content: `有更好的优惠券可用,额外节省¥${(bestDiscount - currentDiscount).toFixed(2)},是否更换?`,
success: (res) => {
if (res.confirm) {
setSelectedCoupon(bestCoupon)
Taro.showToast({
title: '优惠券已更换',
icon: 'success'
})
}
}
})
}
}
}
}
// 处理优惠券选择
const handleCouponSelect = (coupon: CouponCardProps) => {
const total = getGoodsTotal()
// 🔍 详细日志记录,用于排查问题
console.log('🎫 手动选择优惠券详细信息:', {
coupon: {
id: coupon.id,
title: coupon.title,
type: coupon.type,
amount: coupon.amount,
minAmount: coupon.minAmount,
status: coupon.status
},
orderInfo: {
goodsPrice: goods?.price,
quantity: quantity,
total: total,
totalFixed: total.toFixed(2)
},
validation: {
isUsable: isCouponUsable(coupon, total),
discount: calculateCouponDiscount(coupon, total),
reason: getCouponUnusableReason(coupon, total)
}
})
// 检查是否可用
if (!isCouponUsable(coupon, total)) {
const reason = getCouponUnusableReason(coupon, total)
// 🚨 记录手动选择失败的详细信息
console.error('🚨 手动选择优惠券失败:', {
reason,
coupon,
total,
minAmount: coupon.minAmount,
comparison: {
totalVsMinAmount: `${total} < ${coupon.minAmount}`,
result: total < (coupon.minAmount || 0)
}
})
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 (totalOverride?: number) => {
try {
setCouponLoading(true)
// 使用新的API获取可用优惠券
const res = await getMyAvailableCoupons()
if (res && res.length > 0) {
// 转换数据格式
const transformedCoupons = res.map(transformCouponData)
// 按优惠金额排序
const total = totalOverride ?? getGoodsTotal()
const sortedCoupons = sortCoupons(transformedCoupons, total)
const usableCoupons = filterUsableCoupons(sortedCoupons, total)
setAvailableCoupons(sortedCoupons)
// 🎯 智能推荐:自动应用最优惠的可用优惠券
if (usableCoupons.length > 0 && !selectedCoupon) {
const bestCoupon = usableCoupons[0] // 已经按优惠金额排序,第一个就是最优的
const discount = calculateCouponDiscount(bestCoupon, total)
// 🔍 详细日志记录自动推荐的信息
console.log('🤖 自动推荐优惠券详细信息:', {
coupon: {
id: bestCoupon.id,
title: bestCoupon.title,
type: bestCoupon.type,
amount: bestCoupon.amount,
minAmount: bestCoupon.minAmount,
status: bestCoupon.status
},
orderInfo: {
goodsPrice: goods?.price,
quantity: quantity,
total: total,
totalFixed: total.toFixed(2)
},
validation: {
isUsable: isCouponUsable(bestCoupon, total),
discount: discount,
reason: getCouponUnusableReason(bestCoupon, total)
}
})
if (discount > 0) {
setSelectedCoupon(bestCoupon)
// 显示智能推荐提示
Taro.showToast({
title: `已为您推荐最优优惠券,可省¥${discount.toFixed(2)}`,
icon: 'success',
duration: 3000
})
}
}
// 🔔 优惠券提示:如果有可用优惠券,显示提示
if (usableCoupons.length > 0) {
console.log(`发现${usableCoupons.length}张可用优惠券,已为您推荐最优惠券`)
}
console.log('加载优惠券成功:', {
originalData: res,
transformedData: transformedCoupons,
sortedData: sortedCoupons,
usableCoupons: usableCoupons,
recommendedCoupon: usableCoupons[0] || null
})
} else {
setAvailableCoupons([])
console.log('暂无可用优惠券')
}
} catch (error) {
console.error('加载优惠券失败:', error)
setAvailableCoupons([])
Taro.showToast({
title: '加载优惠券失败',
icon: 'none'
})
} finally {
setCouponLoading(false)
}
}
/**
* 统一支付入口
*/
const onPay = async (goods: ShopGoods) => {
let skipFinallyResetPayLoading = false
try {
setPayLoading(true)
// 基础校验
if (!address) {
Taro.showToast({
title: '请选择收货地址',
icon: 'error'
})
return;
}
if (!payment) {
Taro.showToast({
title: '请选择支付方式',
icon: 'error'
})
return;
}
// 水票套票商品:保存配送时间到 ShopOrder.sendStartTime
if (hasTicketTemplate && !sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
if (hasTicketTemplate) {
const min = getMinSendDate()
if (dayjs(sendTime).isBefore(min, 'day')) {
setSendTime(min.toDate())
Taro.showToast({
title: `已过当日${DELIVERY_CUTOFF_HOUR}点截单,最早配送:${min.format('YYYY-MM-DD')}`,
icon: 'none'
})
return
}
}
// 水票套票活动:最小购买量校验
if (isTicketTemplateActive && quantity < minBuyQty) {
Taro.showToast({
title: `最低购买量:${minBuyQty}`,
icon: 'none'
})
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;
}
} else {
// 🔔 支付前最后一次检查:提醒用户是否有可用优惠券
const total = getGoodsTotal()
const usableCoupons = filterUsableCoupons(availableCoupons, total)
if (usableCoupons.length > 0) {
const bestCoupon = usableCoupons[0]
const discount = calculateCouponDiscount(bestCoupon, total)
if (discount > 0) {
// 用模态框提醒用户
const confirmResult = await new Promise<boolean>((resolve) => {
Taro.showModal({
title: '发现可用优惠券',
content: `您有优惠券可使用,可省¥${discount.toFixed(2)},是否使用?`,
success: (res) => resolve(res.confirm),
fail: () => resolve(false)
})
})
if (confirmResult) {
setSelectedCoupon(bestCoupon)
// 🔄 使用优惠券后需要重新构建订单数据,这里直接递归调用支付函数
// 但要确保传递最新的优惠券信息
const currentPaymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
const updatedOrderData = buildSingleGoodsOrder(
goods.goodsId!,
quantity,
address.id,
{
comments: goods.name,
deliveryType: 0,
buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
couponId: parseInt(String(bestCoupon.id), 10)
}
);
console.log('🎯 使用推荐优惠券的订单数据:', updatedOrderData);
// 执行支付
await PaymentHandler.pay(updatedOrderData, currentPaymentType);
return; // 提前返回,避免重复执行支付
} else {
// 用户选择不使用优惠券,继续支付
}
}
}
}
// 构建订单数据
const orderData = buildSingleGoodsOrder(
goods.goodsId!,
quantity,
address.id,
{
comments: '桂乐淘',
deliveryType: 0,
buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
// 🔧 确保 couponId 是正确的数字类型,且不传递 undefined
couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined
}
);
// 根据支付方式选择支付类型
const paymentType = payment.type === 0 ? PaymentType.BALANCE : PaymentType.WECHAT;
// 🔍 支付前的详细信息记录
console.log('💰 开始支付 - 详细信息:', {
orderData,
paymentType,
selectedCoupon: selectedCoupon ? {
id: selectedCoupon.id,
title: selectedCoupon.title,
type: selectedCoupon.type,
amount: selectedCoupon.amount,
minAmount: selectedCoupon.minAmount,
discount: getCouponDiscount()
} : null,
priceCalculation: {
goodsPrice: goods?.price,
quantity: quantity,
goodsTotal: getGoodsTotal(),
couponDiscount: getCouponDiscount(),
finalPrice: getFinalPrice()
},
couponValidation: selectedCoupon ? {
isUsable: isCouponUsable(selectedCoupon, getGoodsTotal()),
reason: getCouponUnusableReason(selectedCoupon, getGoodsTotal())
} : null
});
// 执行支付 - 移除这里的成功提示让PaymentHandler统一处理
await PaymentHandler.pay(orderData, paymentType);
// ✅ 移除双重成功提示 - PaymentHandler会处理成功提示
// Taro.showToast({
// title: '支付成功',
// icon: 'success'
// })
} catch (error: any) {
const message = String(error?.message || '')
const isUserCancelPay =
message.includes('用户取消支付') ||
message.includes('取消支付') ||
message.toLowerCase().includes('requestpayment:fail cancel') ||
message.toLowerCase().includes('cancel')
// 用户取消支付:跳转到待付款列表,方便继续支付
if (isUserCancelPay) {
skipFinallyResetPayLoading = true
setPayLoading(false)
const url = '/user/order/order?statusFilter=0'
try {
await Taro.redirectTo({ url })
} catch (_e) {
try {
await Taro.navigateTo({ url })
} catch (_e2) {
// ignore
}
}
return
}
const isOutOfDeliveryRange =
message.includes('不在配送范围') ||
message.includes('配送范围') ||
message.includes('电子围栏') ||
message.includes('围栏')
// “配送范围”类错误给出更友好的解释,并提供快捷入口去更换收货地址
if (isOutOfDeliveryRange) {
try {
const res = await Taro.showModal({
title: '暂不支持配送',
content: '当前收货地址超出配送范围。您可以更换收货地址后再下单,或联系门店确认配送范围。',
confirmText: '更换地址',
cancelText: '我知道了'
})
if (res?.confirm) {
Taro.navigateTo({ url: '/user/address/index' })
}
} catch (_e) {
// ignore
}
return
}
// 兜底:仅在 PaymentHandler 未弹过提示时再提示一次
if (!error?.handled) {
Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' })
}
} finally {
if (!skipFinallyResetPayLoading) {
setPayLoading(false)
}
}
};
// 统一的数据加载函数
const loadAllData = async () => {
// 未登录时不发起接口请求;页面会被登录兜底逻辑引导走注册/登录页
if (!isLoggedIn()) return
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({})
])
// 设置商品信息
// 查询当前商品是否存在水票套票活动(失败/无数据时不影响正常下单)
let tpl: GltTicketTemplate | null = null
if (goodsId) {
try {
tpl = await getGltTicketTemplateByGoodsId(Number(goodsId))
} catch (e) {
tpl = null
}
}
const tplActive =
!!tpl &&
tpl.enabled !== false &&
tpl.status !== 1 &&
tpl.deleted !== 1
const tplMinBuyQty = (() => {
const n = Number(tpl?.minBuyQty)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1
})()
// 设置商品信息(若存在套票模板,则默认 canBuyNumber 使用模板最小购买量)
if (goodsRes) {
const patchedGoods: ShopGoods = { ...goodsRes }
if (tplActive && ((patchedGoods.canBuyNumber ?? 0) === 0)) {
patchedGoods.canBuyNumber = tplMinBuyQty
}
setGoods(patchedGoods)
// 设置默认购买数量:优先使用 canBuyNumber否则使用 1
const initQty = (patchedGoods.canBuyNumber ?? 0) > 0 ? (patchedGoods.canBuyNumber as number) : 1
setQuantity(initQty)
}
setTicketTemplate(tpl)
// 设置默认收货地址
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) {
const initQty = (() => {
const n = Number(goodsRes?.canBuyNumber)
if (Number.isFinite(n) && n > 0) return Math.floor(n)
if (tplActive) return tplMinBuyQty
return 1
})()
const total = parseFloat(goodsRes.price || '0') * initQty
await loadUserCoupons(total)
}
} catch (err) {
console.error('加载数据失败:', err)
setError('加载数据失败,请重试')
} finally {
setLoading(false)
}
}
useDidShow(() => {
// 返回/切换到该页面时,刷新一下当前已选门店
if (!isLoggedIn()) return
setSelectedStore(getSelectedStoreFromStorage())
loadAllData()
})
useEffect(() => {
// 切换商品时重置配送时间,避免沿用上一次选择
if (!isLoggedIn()) return
setSendTime(getMinSendDate().toDate())
setSendTimePickerVisible(false)
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>
{hasTicketTemplate && (
<CellGroup>
<Cell
title={'配送时间'}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
onClick={() => {
// 若页面停留跨过截单时间,打开选择器前再校正一次最早可选日期
const min = getMinSendDate()
if (dayjs(sendTime).isBefore(min, 'day')) {
setSendTime(min.toDate())
}
setSendTimePickerVisible(true)
}}
/>
</CellGroup>
)}
{/*<CellGroup>*/}
{/* <Cell*/}
{/* title={(*/}
{/* <View className="flex items-center gap-2">*/}
{/* <Shop className={'text-gray-500'}/>*/}
{/* <Text>门店</Text>*/}
{/* </View>*/}
{/* )}*/}
{/* extra={(*/}
{/* <View className={'flex items-center gap-2'}>*/}
{/* <View className={'text-gray-900'}>*/}
{/* {selectedStore?.name || '请选择门店'}*/}
{/* </View>*/}
{/* <ArrowRight className={'text-gray-400'} size={14}/>*/}
{/* </View>*/}
{/* )}*/}
{/* onClick={openStorePopup}*/}
{/* />*/}
{/*</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 ml-2'} 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={isTicketTemplateActive ? minBuyQty : 1}
max={goods.stock || 999}
step={minBuyQty === 1 ? 1 : 10}
readOnly
disabled={((goods.canBuyNumber ?? 0) !== 0) && !isTicketTemplateActive}
onChange={handleQuantityChange}
/>
</ConfigProvider>
{goods.stock !== undefined && (
<Text className={'text-xs text-gray-400'}>
{goods.stock}
</Text>
)}
{isTicketTemplateActive && (
<View className={'text-xs text-gray-500'}>
<Text>{minBuyQty}</Text>
<Text className={'ml-2'}>{getGiftTicketQty(quantity)}</Text>
</View>
)}
</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>
{(() => {
const usableCoupons = filterUsableCoupons(availableCoupons, getGoodsTotal())
if (usableCoupons.length > 0 && !selectedCoupon) {
return (
<View className={'flex items-center'}>
<View className={'bg-red-500 text-white text-xs px-2 py-1 rounded mr-2'}>
{usableCoupons.length}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)
} else if (usableCoupons.length > 0) {
return (
<View className={'flex items-center'}>
<View className={'bg-green-500 text-white text-xs px-2 py-1 rounded mr-2'}>
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)
} else {
return <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>
{ticketTemplate && (
<CellGroup>
<Cell extra={(
<div className={'text-red-500 text-sm'}>
<Text></Text>
<Text>{ticketTemplate.startSendQty}</Text>
<Text></Text>
<Text></Text>
</div>
)}/>
</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>
{/* 门店选择弹窗 */}
<Popup
visible={storePopupVisible}
position="bottom"
style={{height: '70vh'}}
onClose={() => setStorePopupVisible(false)}
>
<View className="p-4">
<View className="flex justify-between items-center mb-3">
<Text className="text-base font-medium"></Text>
<Text
className="text-sm text-gray-500"
onClick={() => setStorePopupVisible(false)}
>
</Text>
</View>
{storeLoading ? (
<View className="py-10 text-center text-gray-500">
<Text>...</Text>
</View>
) : (
<CellGroup>
{stores.map((s) => {
const isActive = !!selectedStore?.id && selectedStore.id === s.id
return (
<Cell
key={s.id}
title={<Text className={isActive ? 'text-green-600' : ''}>{s.name || `门店${s.id}`}</Text>}
description={s.address || ''}
onClick={async () => {
let storeToSave: ShopStore = s
if (s?.id) {
try {
const full = await getShopStore(s.id)
if (full) storeToSave = full
} catch (_e) {
// keep base item
}
}
setSelectedStore(storeToSave)
saveSelectedStoreToStorage(storeToSave)
setStorePopupVisible(false)
Taro.showToast({title: '门店已切换', icon: 'success'})
}}
/>
)
})}
{!stores.length && (
<Cell title={<Text className="text-gray-500"></Text>} />
)}
</CellGroup>
)}
</View>
</Popup>
<Gap height={50}/>
<DatePicker
visible={sendTimePickerVisible}
title="选择配送时间"
type="date"
startDate={getMinSendDate().toDate()}
endDate={dayjs().add(30, 'day').toDate()}
value={sendTime}
onClose={() => setSendTimePickerVisible(false)}
onCancel={() => setSendTimePickerVisible(false)}
onConfirm={(_options, selectedValue) => {
const [y, m, d] = (selectedValue || []).map(v => Number(v))
const next = new Date(y, (m || 1) - 1, d || 1, 0, 0, 0)
setSendTime(next)
setSendTimePickerVisible(false)
}}
/>
<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}
disabled={isTicketTemplateActive && quantity < minBuyQty}
onClick={() => onPay(goods)}
>
{payLoading ? '支付中...' : '立即付款'}
</Button>
</div>
</View>
</div>
</div>
);
};
export default OrderConfirm;