feat(order): 添加配送时间选择功能
- 在订单模型中新增 sendStartTime 字段用于预约配送时间 - 为水票套票商品添加配送时间选择器组件和日期选择逻辑 - 实现配送时间验证确保水票套票商品必须选择配送时间 - 优化支付错误处理增加配送范围提示和地址更换引导 - 调整套票购买注意事项显示动态最低起送量信息 - 移除用户票据页面重复的时间选择相关代码以保持一致性
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user