forked from gxwebsoft/mp-10550
feat(ticket): 将核销记录替换为送水订单功能并优化用户体验
- 替换核销记录为送水订单展示功能 - 在订单模型中新增门店、配送员、仓库的名称和联系方式字段 - 添加用户昵称、头像、手机号等个人信息字段 - 实现配送时间选择器功能 - 设置最低起送数量限制为10桶 - 优化订单列表展示界面和交互逻辑 - 添加订单状态显示功能 - 实现订单数据分页加载和搜索功能 - 优化页面数据加载性能,支持静默刷新
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import {
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
Cell,
|
||||
CellGroup,
|
||||
ConfigProvider,
|
||||
DatePicker,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popup,
|
||||
Space
|
||||
} from '@nutui/nutui-react-taro'
|
||||
import { ArrowRight, Location, Shop, Ticket } from '@nutui/icons-react-taro'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
import { listShopUserAddress } from '@/api/shop/shopUserAddress'
|
||||
@@ -26,14 +28,20 @@ import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
|
||||
import { listGltUserTicket } from '@/api/glt/gltUserTicket'
|
||||
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder'
|
||||
|
||||
const MIN_START_QTY = 10
|
||||
|
||||
const OrderConfirm = () => {
|
||||
const [goods, setGoods] = useState<ShopGoods | null>(null);
|
||||
const [address, setAddress] = useState<ShopUserAddress>()
|
||||
const [quantity, setQuantity] = useState<number>(1)
|
||||
const [quantity, setQuantity] = useState<number>(MIN_START_QTY)
|
||||
const [orderRemark, setOrderRemark] = useState<string>('')
|
||||
const [sendTime, setSendTime] = useState<Date>(new Date())
|
||||
const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [submitLoading, setSubmitLoading] = useState<boolean>(false)
|
||||
const loadAllDataLoadingRef = useRef(false)
|
||||
const hasInitialLoadedRef = useRef(false)
|
||||
|
||||
// InputNumber 主题配置
|
||||
const customTheme = {
|
||||
@@ -101,10 +109,18 @@ const OrderConfirm = () => {
|
||||
return Math.max(0, Math.min(stockMax, availableTicketTotal))
|
||||
}, [availableTicketTotal, goods?.stock])
|
||||
|
||||
const canStartOrder = useMemo(() => {
|
||||
return maxQuantity >= MIN_START_QTY
|
||||
}, [maxQuantity])
|
||||
|
||||
const displayQty = useMemo(() => {
|
||||
if (maxQuantity <= 0) return 0
|
||||
return Math.max(1, Math.min(quantity, maxQuantity))
|
||||
}, [quantity, maxQuantity])
|
||||
if (!canStartOrder) return 0
|
||||
return Math.max(MIN_START_QTY, Math.min(quantity, maxQuantity))
|
||||
}, [quantity, maxQuantity, canStartOrder])
|
||||
|
||||
const sendTimeText = useMemo(() => {
|
||||
return dayjs(sendTime).format('YYYY-MM-DD HH:mm')
|
||||
}, [sendTime])
|
||||
|
||||
const loadStores = async () => {
|
||||
if (storeLoading) return
|
||||
@@ -133,11 +149,11 @@ const OrderConfirm = () => {
|
||||
const parsed = typeof value === 'string' ? parseInt(value) : value
|
||||
const newQuantity = Number.isFinite(parsed) ? Number(parsed) : 0
|
||||
const upper = maxQuantity
|
||||
if (upper <= 0) {
|
||||
if (!canStartOrder || upper <= 0) {
|
||||
setQuantity(0)
|
||||
return
|
||||
}
|
||||
setQuantity(Math.max(1, Math.min(newQuantity || 1, upper)))
|
||||
setQuantity(Math.max(MIN_START_QTY, Math.min(newQuantity || MIN_START_QTY, upper)))
|
||||
}
|
||||
|
||||
const loadUserTickets = async () => {
|
||||
@@ -198,10 +214,18 @@ const OrderConfirm = () => {
|
||||
Taro.showToast({ title: '商品库存不足', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (finalQty < MIN_START_QTY) {
|
||||
Taro.showToast({ title: `最低起送 ${MIN_START_QTY} 桶`, icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!sendTime) {
|
||||
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const confirmRes = await Taro.showModal({
|
||||
title: '确认下单',
|
||||
content: `将使用 ${finalQty} 张水票下单,送水 ${finalQty} 桶,是否确认?`
|
||||
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单,送水 ${finalQty} 桶,是否确认?`
|
||||
})
|
||||
if (!confirmRes.confirm) return
|
||||
|
||||
@@ -215,6 +239,7 @@ const OrderConfirm = () => {
|
||||
addressId: address.id,
|
||||
totalNum: finalQty,
|
||||
buyerRemarks: orderRemark,
|
||||
sendTime: dayjs(sendTime).format('YYYY-MM-DD HH:mm:ss'),
|
||||
// Backend may take userId from token; pass-through is harmless if backend ignores it.
|
||||
userId,
|
||||
comments: goods.name ? `立即送水:${goods.name}` : '立即送水'
|
||||
@@ -237,17 +262,15 @@ const OrderConfirm = () => {
|
||||
}
|
||||
|
||||
// 统一的数据加载函数
|
||||
const loadAllData = async () => {
|
||||
const loadAllData = async (opts?: { silent?: boolean }) => {
|
||||
if (loadAllDataLoadingRef.current) return
|
||||
loadAllDataLoadingRef.current = true
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!opts?.silent) setLoading(true)
|
||||
setError('')
|
||||
|
||||
let goodsRes: ShopGoods | null = null
|
||||
if (numericGoodsId) {
|
||||
goodsRes = await getShopGoods(numericGoodsId)
|
||||
}
|
||||
|
||||
const [addressRes] = await Promise.all([
|
||||
const [goodsRes, addressRes] = await Promise.all([
|
||||
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null),
|
||||
listShopUserAddress({ isDefault: true })
|
||||
])
|
||||
|
||||
@@ -260,30 +283,34 @@ const OrderConfirm = () => {
|
||||
if (addressRes && addressRes.length > 0) {
|
||||
setAddress(addressRes[0])
|
||||
}
|
||||
await loadUserTickets()
|
||||
hasInitialLoadedRef.current = true
|
||||
// Tickets are non-blocking for first paint; load in background.
|
||||
loadUserTickets()
|
||||
} catch (err) {
|
||||
console.error('加载数据失败:', err)
|
||||
setError('加载数据失败,请重试')
|
||||
if (opts?.silent) {
|
||||
Taro.showToast({ title: '刷新失败,请稍后重试', icon: 'none' })
|
||||
} else {
|
||||
setError('加载数据失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!opts?.silent) setLoading(false)
|
||||
loadAllDataLoadingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
// 返回/切换到该页面时,刷新一下当前已选门店
|
||||
setSelectedStore(getSelectedStoreFromStorage())
|
||||
loadAllData()
|
||||
loadAllData({ silent: hasInitialLoadedRef.current })
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadAllData()
|
||||
}, [goodsId]);
|
||||
|
||||
// When tickets/stock change, clamp quantity into [0..maxQuantity].
|
||||
useEffect(() => {
|
||||
setQuantity(prev => {
|
||||
if (maxQuantity <= 0) return 0
|
||||
if (!prev || prev < 1) return 1
|
||||
if (maxQuantity < MIN_START_QTY) return 0
|
||||
if (!prev || prev < MIN_START_QTY) return MIN_START_QTY
|
||||
return Math.min(prev, maxQuantity)
|
||||
})
|
||||
}, [maxQuantity])
|
||||
@@ -374,16 +401,34 @@ const OrderConfirm = () => {
|
||||
)}
|
||||
</CellGroup>
|
||||
|
||||
<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>
|
||||
<Cell
|
||||
title={'送水数量'}
|
||||
description={
|
||||
canStartOrder
|
||||
? `最低起送 ${MIN_START_QTY} 桶`
|
||||
: `最低起送 ${MIN_START_QTY} 桶(当前最多 ${maxQuantity} 桶)`
|
||||
}
|
||||
extra={(
|
||||
<ConfigProvider theme={customTheme}>
|
||||
<InputNumber
|
||||
value={maxQuantity <= 0 ? 0 : quantity}
|
||||
min={maxQuantity <= 0 ? 0 : 1}
|
||||
max={maxQuantity <= 0 ? 0 : maxQuantity}
|
||||
disabled={maxQuantity <= 0}
|
||||
value={displayQty}
|
||||
min={canStartOrder ? MIN_START_QTY : 0}
|
||||
max={canStartOrder ? maxQuantity : 0}
|
||||
disabled={!canStartOrder}
|
||||
onChange={handleQuantityChange}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
@@ -402,12 +447,18 @@ const OrderConfirm = () => {
|
||||
extra={(
|
||||
<View className={'flex items-center gap-2'}>
|
||||
<View className={'text-gray-900'}>
|
||||
{selectedTicket ? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0})` : '请选择'}
|
||||
{ticketLoading
|
||||
? '加载中...'
|
||||
: (selectedTicket
|
||||
? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0})`
|
||||
: '请选择'
|
||||
)
|
||||
}
|
||||
</View>
|
||||
<ArrowRight className={'text-gray-400'} size={14}/>
|
||||
</View>
|
||||
)}
|
||||
onClick={() => setTicketPopupVisible(true)}
|
||||
onClick={() => !ticketLoading && setTicketPopupVisible(true)}
|
||||
/>
|
||||
<Cell
|
||||
title={'本次使用'}
|
||||
@@ -533,6 +584,23 @@ const OrderConfirm = () => {
|
||||
|
||||
<Gap height={50}/>
|
||||
|
||||
<DatePicker
|
||||
visible={sendTimePickerVisible}
|
||||
title="选择配送时间"
|
||||
type="datetime"
|
||||
startDate={new Date()}
|
||||
endDate={dayjs().add(30, 'day').toDate()}
|
||||
value={sendTime}
|
||||
onClose={() => setSendTimePickerVisible(false)}
|
||||
onCancel={() => setSendTimePickerVisible(false)}
|
||||
onConfirm={(_options, selectedValue) => {
|
||||
const [y, m, d, hh, mm] = (selectedValue || []).map(v => Number(v))
|
||||
const next = new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 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'}>
|
||||
@@ -549,7 +617,7 @@ const OrderConfirm = () => {
|
||||
type="success"
|
||||
size="large"
|
||||
loading={submitLoading}
|
||||
disabled={!selectedTicket?.id || availableTicketTotal <= 0 || maxQuantity <= 0}
|
||||
disabled={!selectedTicket?.id || availableTicketTotal <= 0 || !canStartOrder}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{submitLoading ? '提交中...' : '立即提交'}
|
||||
|
||||
Reference in New Issue
Block a user