feat(ticket): 将核销记录替换为送水订单功能并优化用户体验

- 替换核销记录为送水订单展示功能
- 在订单模型中新增门店、配送员、仓库的名称和联系方式字段
- 添加用户昵称、头像、手机号等个人信息字段
- 实现配送时间选择器功能
- 设置最低起送数量限制为10桶
- 优化订单列表展示界面和交互逻辑
- 添加订单状态显示功能
- 实现订单数据分页加载和搜索功能
- 优化页面数据加载性能,支持静默刷新
This commit is contained in:
2026-02-06 19:41:31 +08:00
parent c0954564a6
commit 56d933ddf8
3 changed files with 199 additions and 134 deletions

View File

@@ -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 ? '提交中...' : '立即提交'}