Files
template-10584/src/user/ticket/use.tsx
赵忠林 25177d724e feat(ticket): 完善水票配送订单功能
- 优化导入路径,修复 PageParam 类型引用
- 新增 DeliverConfirmMode 类型定义,支持拍照完成和等待客户确认两种模式
- 实现配送确认的双模式功能,支持直接完成和等待确认流程
- 重构订单状态判断逻辑,完善配送流程状态管理
- 新增用户端确认收货功能,支持手动确认收货操作
- 优化订单列表展示,增加票号、取货点、门店电话等详细信息
- 添加地址复制和联系门店功能按钮
- 实现补传照片完成订单功能
- 更新订单流程状态显示,提供更准确的状态标识
- 添加配送确认模式切换的单选框界面
- 优化下单成功后的页面跳转逻辑
- 新增水票配送订单后端接口设计文档
2026-02-06 20:33:56 +08:00

633 lines
21 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, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import {
Button,
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'
import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model'
import './use.scss'
import Gap from "@/components/Gap";
import OrderConfirmSkeleton from "@/components/OrderConfirmSkeleton";
import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
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>(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 = {
nutuiInputnumberButtonWidth: '28px',
nutuiInputnumberButtonHeight: '28px',
nutuiInputnumberInputWidth: '40px',
nutuiInputnumberInputHeight: '28px',
nutuiInputnumberInputBorderRadius: '4px',
nutuiInputnumberButtonBorderRadius: '4px',
}
// 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 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 [tickets, setTickets] = useState<GltUserTicket[]>([])
const [selectedTicketId, setSelectedTicketId] = useState<number | undefined>(undefined)
const [ticketPopupVisible, setTicketPopupVisible] = useState(false)
const [ticketLoading, setTicketLoading] = useState(false)
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
const numericGoodsId = useMemo(() => {
const n = goodsId ? Number(goodsId) : undefined
return typeof n === 'number' && Number.isFinite(n) ? n : undefined
}, [goodsId])
const userId = useMemo(() => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}, [])
const usableTickets = useMemo(() => {
const list = (tickets || [])
.filter(t => t?.deleted !== 1)
.filter(t => t?.status !== 1)
.filter(t => Number.isFinite(Number(t?.id)) && Number(t.id) > 0)
.filter(t => (t.availableQty ?? 0) > 0)
// Some tenants don't fill goodsId on ticket; allow it as a fallback.
.filter(t => (numericGoodsId ? (!t.goodsId || t.goodsId === numericGoodsId) : true))
// FIFO: use older tickets first (reduce disputes).
return list.sort((a, b) => {
const ta = new Date(a.createTime || 0).getTime() || 0
const tb = new Date(b.createTime || 0).getTime() || 0
if (ta !== tb) return ta - tb
return (a.id || 0) - (b.id || 0)
})
}, [tickets, numericGoodsId])
const selectedTicket = useMemo(() => {
if (!selectedTicketId) return undefined
return usableTickets.find(t => Number(t.id) === Number(selectedTicketId))
}, [usableTickets, selectedTicketId])
const availableTicketTotal = useMemo(() => {
return Number(selectedTicket?.availableQty || 0)
}, [selectedTicket?.availableQty])
const maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999
return Math.max(0, Math.min(stockMax, availableTicketTotal))
}, [availableTicketTotal, goods?.stock])
const canStartOrder = useMemo(() => {
return maxQuantity >= MIN_START_QTY
}, [maxQuantity])
const displayQty = useMemo(() => {
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
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)
}
}
const openStorePopup = async () => {
setStorePopupVisible(true)
if (!stores.length) {
await loadStores()
}
}
// 处理数量变化
const handleQuantityChange = (value: string | number) => {
const parsed = typeof value === 'string' ? parseInt(value) : value
const newQuantity = Number.isFinite(parsed) ? Number(parsed) : 0
const upper = maxQuantity
if (!canStartOrder || upper <= 0) {
setQuantity(0)
return
}
setQuantity(Math.max(MIN_START_QTY, Math.min(newQuantity || MIN_START_QTY, upper)))
}
const loadUserTickets = async () => {
if (ticketLoading) return
if (!userId) {
setTickets([])
return
}
try {
setTicketLoading(true)
const list = await listGltUserTicket({ userId, status: 0 })
setTickets(list || [])
} catch (e) {
console.error('获取水票失败:', e)
setTickets([])
Taro.showToast({ title: '获取水票失败', icon: 'none' })
} finally {
setTicketLoading(false)
}
}
const onSubmit = async () => {
if (submitLoading) return
if (!goods?.goodsId) return
// 基础校验
if (!userId) {
Taro.showToast({ title: '请先登录', icon: 'none' })
return
}
if (!selectedStore?.id) {
Taro.showToast({ title: '请选择门店', icon: 'none' })
return
}
if (!address?.id) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
if (!selectedTicket?.id) {
Taro.showToast({ title: '请选择水票', icon: 'none' })
return
}
if (availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return
}
const finalQty = displayQty
if (finalQty <= 0) {
Taro.showToast({ title: '请选择送水数量', icon: 'none' })
return
}
if (finalQty > availableTicketTotal) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return
}
if (goods.stock !== undefined && finalQty > goods.stock) {
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: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单,送水 ${finalQty} 桶,是否确认?`
})
if (!confirmRes.confirm) return
try {
setSubmitLoading(true)
Taro.showLoading({ title: '提交中...' })
await addGltTicketOrder({
userTicketId: selectedTicket.id,
storeId: selectedStore.id,
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}` : '立即送水'
})
await loadUserTickets()
Taro.showToast({ title: '下单成功', icon: 'success' })
setTimeout(() => {
// 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
}, 800)
} catch (e: any) {
console.error('水票下单失败:', e)
Taro.showToast({ title: e?.message || '下单失败', icon: 'none' })
} finally {
Taro.hideLoading()
setSubmitLoading(false)
}
}
// 统一的数据加载函数
const loadAllData = async (opts?: { silent?: boolean }) => {
if (loadAllDataLoadingRef.current) return
loadAllDataLoadingRef.current = true
try {
if (!opts?.silent) setLoading(true)
setError('')
const [goodsRes, addressRes] = await Promise.all([
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null),
listShopUserAddress({ isDefault: true })
])
// 设置商品信息
if (goodsRes) {
setGoods(goodsRes)
hasInitialLoadedRef.current = true
}
// 设置默认收货地址
if (addressRes && addressRes.length > 0) {
setAddress(addressRes[0])
}
// Tickets are non-blocking for first paint; load in background.
loadUserTickets()
} catch (err) {
console.error('加载数据失败:', err)
if (opts?.silent) {
Taro.showToast({ title: '刷新失败,请稍后重试', icon: 'none' })
} else {
setError('加载数据失败,请重试')
}
} finally {
if (!opts?.silent) setLoading(false)
loadAllDataLoadingRef.current = false
}
}
useDidShow(() => {
// 返回/切换到该页面时,刷新一下当前已选门店
setSelectedStore(getSelectedStoreFromStorage())
loadAllData({ silent: hasInitialLoadedRef.current })
})
// When tickets/stock change, clamp quantity into [0..maxQuantity].
useEffect(() => {
setQuantity(prev => {
if (maxQuantity <= 0) return 0
if (maxQuantity < MIN_START_QTY) return 0
if (!prev || prev < MIN_START_QTY) return MIN_START_QTY
return Math.min(prev, maxQuantity)
})
}, [maxQuantity])
// Auto-pick a default ticket (first usable) when ticket list changes.
useEffect(() => {
if (!usableTickets.length) {
setSelectedTicketId(undefined)
return
}
const currentValid = selectedTicketId && usableTickets.some(t => Number(t.id) === Number(selectedTicketId))
if (!currentValid) {
setSelectedTicketId(Number(usableTickets[0].id))
}
}, [usableTickets, selectedTicketId])
// 重新加载数据
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>
<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>
{
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>
<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={displayQty}
min={canStartOrder ? MIN_START_QTY : 0}
max={canStartOrder ? maxQuantity : 0}
disabled={!canStartOrder}
onChange={handleQuantityChange}
/>
</ConfigProvider>
)}
/>
</CellGroup>
<CellGroup>
<Cell
title={(
<View className="flex items-center gap-2">
<Ticket className={'text-gray-500'}/>
<Text></Text>
</View>
)}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>
{ticketLoading
? '加载中...'
: (selectedTicket
? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0}`
: '请选择'
)
}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
onClick={() => !ticketLoading && setTicketPopupVisible(true)}
/>
<Cell
title={'本次使用'}
extra={<View className={'font-medium'}>{displayQty} </View>}
/>
</CellGroup>
<CellGroup>
<Cell title={'备注'} extra={(
<Input
placeholder={'(选填)请填写备注'}
style={{padding: '0'}}
value={orderRemark}
onChange={(value) => setOrderRemark(value)}
maxLength={100}
/>
)}/>
</CellGroup>
{/* 水票明细弹窗 */}
<Popup
visible={ticketPopupVisible}
position="bottom"
style={{ height: '70vh' }}
onClose={() => setTicketPopupVisible(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={() => setTicketPopupVisible(false)}
>
</Text>
</View>
{ticketLoading ? (
<View className="py-10 text-center text-gray-500">
<Text>...</Text>
</View>
) : (
<CellGroup>
{usableTickets.map((t) => {
const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id)
return (
<Cell
key={t.id}
title={<Text className={active ? 'text-green-600' : ''}>{t.templateName || '水票'}</Text>}
description={t.orderNo ? `来源订单:${t.orderNo}` : ''}
extra={<Text className="text-gray-700"> {t.availableQty ?? 0}</Text>}
onClick={() => {
setSelectedTicketId(Number(t.id))
setTicketPopupVisible(false)
Taro.showToast({ title: '水票已选择', icon: 'success' })
}}
/>
)})}
{!usableTickets.length && (
<Cell title={<Text className="text-gray-500"></Text>} />
)}
</CellGroup>
)}
</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="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'}>
<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'}>
{displayQty}
</span>
<span className={'text-sm text-gray-500'}></span>
</View>
</div>
<div className={'buy-btn mx-4'}>
<Button
type="success"
size="large"
loading={submitLoading}
disabled={!selectedTicket?.id || availableTicketTotal <= 0 || !canStartOrder}
onClick={onSubmit}
>
{submitLoading ? '提交中...' : '立即提交'}
</Button>
</div>
</View>
</div>
</div>
);
};
export default OrderConfirm;