feat(order): 添加配送时间选择功能

- 在订单模型中新增 sendStartTime 字段用于预约配送时间
- 为水票套票商品添加配送时间选择器组件和日期选择逻辑
- 实现配送时间验证确保水票套票商品必须选择配送时间
- 优化支付错误处理增加配送范围提示和地址更换引导
- 调整套票购买注意事项显示动态最低起送量信息
- 移除用户票据页面重复的时间选择相关代码以保持一致性
This commit is contained in:
2026-02-09 16:48:34 +08:00
parent 37c2f030f2
commit a1e1487d42
4 changed files with 108 additions and 51 deletions

View File

@@ -193,6 +193,8 @@ export interface OrderCreateRequest {
couponId?: number; couponId?: number;
// 备注 // 备注
comments?: string; comments?: string;
// 配送开始时间(用于预约/配送时间)
sendStartTime?: string;
// 配送方式 0快递 1自提 // 配送方式 0快递 1自提
deliveryType?: number; deliveryType?: number;
// 自提店铺ID // 自提店铺ID
@@ -233,6 +235,8 @@ export interface OrderCreateRequest {
couponId?: number; couponId?: number;
// 备注 // 备注
comments?: string; comments?: string;
// 配送开始时间(用于预约/配送时间)
sendStartTime?: string;
// 配送方式 0快递 1自提 // 配送方式 0快递 1自提
deliveryType?: number; deliveryType?: number;
// 自提店铺ID // 自提店铺ID

View File

@@ -1,4 +1,4 @@
import {useEffect, useState} from "react"; import {useEffect, useMemo, useState} from "react";
import { import {
Image, Image,
Button, Button,
@@ -9,6 +9,7 @@ import {
ActionSheet, ActionSheet,
Popup, Popup,
InputNumber, InputNumber,
DatePicker,
ConfigProvider ConfigProvider
} from '@nutui/nutui-react-taro' } from '@nutui/nutui-react-taro'
import {Location, ArrowRight} from '@nutui/icons-react-taro' import {Location, ArrowRight} from '@nutui/icons-react-taro'
@@ -38,6 +39,7 @@ import {
filterUsableCoupons, filterUsableCoupons,
filterUnusableCoupons filterUnusableCoupons
} from "@/utils/couponUtils"; } from "@/utils/couponUtils";
import dayjs from 'dayjs'
import type {ShopStore} from "@/api/shop/shopStore/model"; import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore"; import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection"; import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
@@ -54,6 +56,9 @@ const OrderConfirm = () => {
const [loading, setLoading] = useState<boolean>(true) const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [payLoading, setPayLoading] = useState<boolean>(false) const [payLoading, setPayLoading] = useState<boolean>(false)
// 配送时间(仅水票套票商品需要)
const [sendTime, setSendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false)
// 水票套票活动(若存在则按规则限制最小购买量等) // 水票套票活动(若存在则按规则限制最小购买量等)
const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null) const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null)
@@ -88,6 +93,7 @@ const OrderConfirm = () => {
ticketTemplate.enabled !== false && ticketTemplate.enabled !== false &&
ticketTemplate.status !== 1 && ticketTemplate.status !== 1 &&
ticketTemplate.deleted !== 1 ticketTemplate.deleted !== 1
const hasTicketTemplate = !!ticketTemplate
// 套票活动最低购买量:优先取模板配置 // 套票活动最低购买量:优先取模板配置
const ticketMinBuyQty = (() => { const ticketMinBuyQty = (() => {
@@ -96,6 +102,10 @@ const OrderConfirm = () => {
})() })()
const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1 const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
}, [sendTime])
const getGiftTicketQty = (buyQty: number) => { const getGiftTicketQty = (buyQty: number) => {
if (!isTicketTemplateActive) return 0 if (!isTicketTemplateActive) return 0
const multiplier = Number(ticketTemplate?.giftMultiplier || 0) const multiplier = Number(ticketTemplate?.giftMultiplier || 0)
@@ -420,6 +430,12 @@ const OrderConfirm = () => {
return; return;
} }
// 水票套票商品:保存配送时间到 ShopOrder.sendStartTime
if (hasTicketTemplate && !sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
// 水票套票活动:最小购买量校验 // 水票套票活动:最小购买量校验
if (isTicketTemplateActive && quantity < minBuyQty) { if (isTicketTemplateActive && quantity < minBuyQty) {
Taro.showToast({ Taro.showToast({
@@ -482,6 +498,9 @@ const OrderConfirm = () => {
comments: goods.name, comments: goods.name,
deliveryType: 0, deliveryType: 0,
buyerRemarks: orderRemark, buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
couponId: parseInt(String(bestCoupon.id), 10) couponId: parseInt(String(bestCoupon.id), 10)
} }
); );
@@ -507,6 +526,9 @@ const OrderConfirm = () => {
comments: '桂乐淘', comments: '桂乐淘',
deliveryType: 0, deliveryType: 0,
buyerRemarks: orderRemark, buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
// 🔧 确保 couponId 是正确的数字类型,且不传递 undefined // 🔧 确保 couponId 是正确的数字类型,且不传递 undefined
couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined
} }
@@ -549,31 +571,35 @@ const OrderConfirm = () => {
// icon: 'success' // icon: 'success'
// }) // })
} catch (error: any) { } catch (error: any) {
// return navTo('/user/order/order?statusFilter=0', true) const message = String(error?.message || '')
// console.error('支付失败:', error) const isOutOfDeliveryRange =
message.includes('不在配送范围') ||
message.includes('配送范围') ||
message.includes('电子围栏') ||
message.includes('围栏')
// 只处理PaymentHandler未处理的错误 // “配送范围”类错误给出更友好的解释,并提供快捷入口去更换收货地址
// if (!error.handled) { if (isOutOfDeliveryRange) {
// let errorMessage = '支付失败,请重试'; try {
// const res = await Taro.showModal({
// // 根据错误类型提供具体提示 title: '暂不支持配送',
// if (error.message?.includes('余额不足')) { content: '当前收货地址超出配送范围。您可以更换收货地址后再下单,或联系门店确认配送范围。',
// errorMessage = '账户余额不足,请充值后重试'; confirmText: '更换地址',
// } else if (error.message?.includes('优惠券')) { cancelText: '我知道了'
// errorMessage = '优惠券使用失败,请重新选择'; })
// } else if (error.message?.includes('库存')) { if (res?.confirm) {
// errorMessage = '商品库存不足,请减少购买数量'; Taro.navigateTo({ url: '/user/address/index' })
// } else if (error.message?.includes('地址')) { }
// errorMessage = '收货地址信息有误,请重新选择'; } catch (_e) {
// } else if (error.message) { // ignore
// errorMessage = error.message; }
// } return
// Taro.showToast({ }
// title: errorMessage,
// icon: 'error' // 兜底:仅在 PaymentHandler 未弹过提示时再提示一次
// }) if (!error?.handled) {
// console.log('跳去未付款的订单列表页面') Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' })
// } }
} finally { } finally {
setPayLoading(false) setPayLoading(false)
} }
@@ -673,6 +699,9 @@ const OrderConfirm = () => {
}) })
useEffect(() => { useEffect(() => {
// 切换商品时重置配送时间,避免沿用上一次选择
setSendTime(dayjs().startOf('day').toDate())
setSendTimePickerVisible(false)
loadAllData() loadAllData()
}, [goodsId]); }, [goodsId]);
@@ -731,6 +760,21 @@ const OrderConfirm = () => {
)} )}
</CellGroup> </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={() => setSendTimePickerVisible(true)}
/>
</CellGroup>
)}
{/*<CellGroup>*/} {/*<CellGroup>*/}
{/* <Cell*/} {/* <Cell*/}
{/* title={(*/} {/* title={(*/}
@@ -874,10 +918,10 @@ const OrderConfirm = () => {
<CellGroup> <CellGroup>
<Cell extra={( <Cell extra={(
<div className={'text-red-500 text-sm'}> <div className={'text-red-500 text-sm'}>
<Text></Text>
1.20 <Text>{ticketTemplate.minBuyQty}</Text>
2. <Text></Text>
3. <Text></Text>
</div> </div>
)}/> )}/>
</CellGroup> </CellGroup>
@@ -1023,6 +1067,23 @@ const OrderConfirm = () => {
<Gap height={50}/> <Gap height={50}/>
<DatePicker
visible={sendTimePickerVisible}
title="选择配送时间"
type="date"
startDate={dayjs().startOf('day').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'}> <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'}> <View className={'btn-bar flex justify-between items-center'}>
<div className={'flex flex-col justify-center items-start mx-4'}> <div className={'flex flex-col justify-center items-start mx-4'}>

View File

@@ -6,7 +6,6 @@ import {
Cell, Cell,
CellGroup, CellGroup,
ConfigProvider, ConfigProvider,
DatePicker,
Input, Input,
InputNumber, InputNumber,
Popup, Popup,
@@ -41,8 +40,7 @@ const OrderConfirm = () => {
const [quantity, setQuantity] = useState<number>(MIN_START_QTY) const [quantity, setQuantity] = useState<number>(MIN_START_QTY)
const [orderRemark, setOrderRemark] = useState<string>('') const [orderRemark, setOrderRemark] = useState<string>('')
// Delivery date only (no hour/min selection). // Delivery date only (no hour/min selection).
const [sendTime, setSendTime] = useState<Date>(() => dayjs().startOf('day').toDate()) const [sendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false)
const [loading, setLoading] = useState<boolean>(true) const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [submitLoading, setSubmitLoading] = useState<boolean>(false) const [submitLoading, setSubmitLoading] = useState<boolean>(false)
@@ -705,10 +703,8 @@ const OrderConfirm = () => {
extra={( extra={(
<View className={'flex items-center gap-2'}> <View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View> <View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View> </View>
)} )}
onClick={() => setSendTimePickerVisible(true)}
/> />
</CellGroup> </CellGroup>
@@ -883,23 +879,6 @@ const OrderConfirm = () => {
<Gap height={50}/> <Gap height={50}/>
<DatePicker
visible={sendTimePickerVisible}
title="选择配送时间"
type="date"
startDate={dayjs().startOf('day').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'}> <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'}> <View className={'btn-bar flex justify-between items-center'}>
<div className={'flex flex-col justify-center items-start mx-4'}> <div className={'flex flex-col justify-center items-start mx-4'}>

View File

@@ -372,6 +372,17 @@ export class PaymentHandler {
const message = error.message; const message = error.message;
// 配送范围/电子围栏相关错误(优先于“地址信息有误”的兜底)
if (
message.includes('不在配送范围') ||
message.includes('配送范围') ||
message.includes('电子围栏') ||
message.includes('围栏')
) {
// Toast 文案尽量短(小程序 showToast 标题长度有限),更详细的引导可在业务页面用 Modal 呈现。
return '暂不支持配送';
}
// 余额相关错误 // 余额相关错误
if (message.includes('余额不足') || message.includes('balance')) { if (message.includes('余额不足') || message.includes('balance')) {
return '账户余额不足,请充值后重试'; return '账户余额不足,请充值后重试';
@@ -453,6 +464,7 @@ export function buildSingleGoodsOrder(
skuId?: number; skuId?: number;
specInfo?: string; specInfo?: string;
buyerRemarks?: string; buyerRemarks?: string;
sendStartTime?: string;
} }
): OrderCreateRequest { ): OrderCreateRequest {
return { return {
@@ -467,6 +479,7 @@ export function buildSingleGoodsOrder(
addressId, addressId,
payType: PaymentType.WECHAT, // 默认微信支付会被PaymentHandler覆盖 payType: PaymentType.WECHAT, // 默认微信支付会被PaymentHandler覆盖
comments: options?.buyerRemarks || options?.comments || '', comments: options?.buyerRemarks || options?.comments || '',
sendStartTime: options?.sendStartTime,
deliveryType: options?.deliveryType || 0, deliveryType: options?.deliveryType || 0,
couponId: options?.couponId, couponId: options?.couponId,
selfTakeMerchantId: options?.selfTakeMerchantId selfTakeMerchantId: options?.selfTakeMerchantId