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(null); const [address, setAddress] = useState() const [quantity, setQuantity] = useState(MIN_START_QTY) const [orderRemark, setOrderRemark] = useState('') const [sendTime, setSendTime] = useState(new Date()) const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [submitLoading, setSubmitLoading] = useState(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([]) const [storeLoading, setStoreLoading] = useState(false) const [selectedStore, setSelectedStore] = useState(getSelectedStoreFromStorage()) // 水票:用于“立即送水”下单(用水票抵扣,无需支付) const [tickets, setTickets] = useState([]) const [selectedTicketId, setSelectedTicketId] = useState(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 ( {error} ) } // 加载状态 if (loading || !goods) { return } return (
选择门店 )} extra={( {selectedStore?.name || '请选择门店'} )} onClick={openStorePopup} /> { address && ( Taro.navigateTo({url: '/user/address/index'})}> 送至 {address.province} {address.city} {address.region} {address.address} {address.name} {address.phone} ) } {!address && ( Taro.navigateTo({url: '/user/address/index'})}> 添加收货地址 )} {sendTimeText} )} onClick={() => setSendTimePickerVisible(true)} /> )} /> 选择水票 )} extra={( {ticketLoading ? '加载中...' : (selectedTicket ? `${selectedTicket.templateName || '水票'}(可用${selectedTicket.availableQty ?? 0})` : '请选择' ) } )} onClick={() => !ticketLoading && setTicketPopupVisible(true)} /> {displayQty} 张} /> setOrderRemark(value)} maxLength={100} /> )}/> {/* 水票明细弹窗 */} setTicketPopupVisible(false)} > 水票明细 setTicketPopupVisible(false)} > 关闭 {ticketLoading ? ( 加载中... ) : ( {usableTickets.map((t) => { const active = selectedTicket?.id && Number(selectedTicket.id) === Number(t.id) return ( {t.templateName || '水票'}} description={t.orderNo ? `来源订单:${t.orderNo}` : ''} extra={可用 {t.availableQty ?? 0}} onClick={() => { setSelectedTicketId(Number(t.id)) setTicketPopupVisible(false) Taro.showToast({ title: '水票已选择', icon: 'success' }) }} /> )})} {!usableTickets.length && ( 暂无可用水票} /> )} )} {/* 门店选择弹窗 */} setStorePopupVisible(false)} > 选择门店 setStorePopupVisible(false)} > 关闭 {storeLoading ? ( 加载中... ) : ( {stores.map((s) => { const isActive = !!selectedStore?.id && selectedStore.id === s.id return ( {s.name || `门店${s.id}`}} 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 && ( 暂无门店数据} /> )} )} 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) }} />
使用水票: {displayQty}
); }; export default OrderConfirm;