import { useEffect, useMemo, useRef, useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { View, Text } from '@tarojs/components' import { Button, Cell, CellGroup, ConfigProvider, Empty, Input, InputNumber, Popup, Space } from '@nutui/nutui-react-taro' import { ArrowRight, Location, 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 { getShopUserAddress, 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' import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder' import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model' import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model' import { listShopStoreRider } from '@/api/shop/shopStoreRider' import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model' import { listShopStoreFence } from '@/api/shop/shopStoreFence' import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence' const MIN_START_QTY = 10 const ADDRESS_CHANGE_COOLDOWN_DAYS = 30 const OrderConfirm = () => { const [goods, setGoods] = useState(null); const [address, setAddress] = useState() const [quantity, setQuantity] = useState(MIN_START_QTY) const [orderRemark, setOrderRemark] = useState('') // Delivery date only (no hour/min selection). const [sendTime] = useState(() => dayjs().startOf('day').toDate()) 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 storeAutoPickingRef = useRef(false) const storeManualPickedRef = useRef(false) // 水票:用于“立即送水”下单(用水票抵扣,无需支付) const [tickets, setTickets] = useState([]) const [ticketPopupVisible, setTicketPopupVisible] = useState(false) const [ticketLoading, setTicketLoading] = useState(false) const [ticketLoaded, setTicketLoaded] = useState(false) const noTicketPromptedRef = useRef(false) // Delivery range (geofence): block ordering if address/current location is outside. const [fences, setFences] = useState([]) const fencesLoadedRef = useRef(false) const fencesPromiseRef = useRef | null>(null) const fencesErrorRef = useRef(null) const [deliveryRangeChecking, setDeliveryRangeChecking] = useState(false) const deliveryRangeCheckingRef = useRef(false) const [inDeliveryRange, setInDeliveryRange] = useState(undefined) // Prevent using stale `inDeliveryRange` from a previous address when user switches addresses. const [deliveryRangeCheckedAddressId, setDeliveryRangeCheckedAddressId] = useState(undefined) 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 }, []) type TicketAddressModifyLimit = { loaded: boolean canModify: boolean nextAllowedText?: string lockedAddressId?: number } const [ticketAddressModifyLimit, setTicketAddressModifyLimit] = useState({ loaded: false, canModify: true, }) const ticketAddressModifyLimitPromiseRef = useRef | null>(null) const parseTime = (raw?: unknown) => { if (raw === undefined || raw === null || raw === '') return null // Compatible with seconds/milliseconds timestamps. if (typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(raw))) { const n = Number(raw) if (!Number.isFinite(n)) return null return dayjs(n < 1e12 ? n * 1000 : n) } const d = dayjs(raw as any) return d.isValid() ? d : null } const getOrderTime = (o?: Partial | null) => { return parseTime(o?.createTime) || parseTime(o?.updateTime) } const getOrderAddressKey = (o?: Partial | null) => { const id = Number(o?.addressId) if (Number.isFinite(id) && id > 0) return `id:${id}` const txt = String(o?.address || '').trim() if (txt) return `txt:${txt}` return '' } const loadTicketAddressModifyLimit = async (): Promise => { if (ticketAddressModifyLimitPromiseRef.current) return ticketAddressModifyLimitPromiseRef.current ticketAddressModifyLimitPromiseRef.current = (async () => { if (!userId) return { loaded: true, canModify: true } const now = dayjs() const pageSize = 20 let page = 1 const all: GltTicketOrder[] = [] let latestKey = '' let latestAddressId: number | undefined = undefined while (true) { const res = await pageGltTicketOrder({ page, limit: pageSize, userId }) const list = Array.isArray(res?.list) ? res.list : [] if (page === 1) { const first = list[0] latestKey = getOrderAddressKey(first) const id = Number(first?.addressId) latestAddressId = Number.isFinite(id) && id > 0 ? id : undefined } if (!list.length) break all.push(...list) // Find the oldest order in the newest contiguous block of the latest address key. // That order's time represents the last time user "set/changed" the ticket delivery address. const currentKey = latestKey if (!currentKey) { return { loaded: true, canModify: true } } let lastSameIndex = 0 let foundDifferent = false for (let i = 1; i < all.length; i++) { const k = getOrderAddressKey(all[i]) if (!k) continue if (k === currentKey) { lastSameIndex = i continue } foundDifferent = true break } if (foundDifferent) { const lastSetAt = getOrderTime(all[lastSameIndex]) if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId } const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day') const canModify = now.isAfter(nextAllowed) return { loaded: true, canModify, nextAllowedText: nextAllowed.format('YYYY-MM-DD'), lockedAddressId: latestAddressId, } } const oldest = getOrderTime(all[all.length - 1]) if (oldest && now.diff(oldest, 'day') >= ADDRESS_CHANGE_COOLDOWN_DAYS) { // We have enough history beyond the cooldown window, and still no different address found. return { loaded: true, canModify: true, lockedAddressId: latestAddressId } } const totalCount = typeof (res as any)?.count === 'number' ? Number((res as any).count) : undefined if (totalCount !== undefined && all.length >= totalCount) break if (list.length < pageSize) break page += 1 if (page > 10) break // safety: avoid excessive paging } if (!all.length) return { loaded: true, canModify: true } // If we can't prove the last-set time is older than the cooldown window, be conservative and lock. const lastSetAt = getOrderTime(all[all.length - 1]) if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId } const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day') const canModify = now.isAfter(nextAllowed) return { loaded: true, canModify, nextAllowedText: nextAllowed.format('YYYY-MM-DD'), lockedAddressId: latestAddressId, } })() .finally(() => { ticketAddressModifyLimitPromiseRef.current = null }) return ticketAddressModifyLimitPromiseRef.current } const getTicketAvailableQty = (t?: Partial | null) => { if (!t) return 0 const anyT: any = t const raw = anyT.availableQty ?? anyT.availableNum ?? anyT.availableCount ?? anyT.remainQty ?? anyT.remainNum ?? anyT.remainCount const n = Number(raw) if (Number.isFinite(n)) return n // Fallback for tenants that don't return `availableQty`. const total = Number(anyT.totalQty ?? anyT.totalNum ?? anyT.totalCount ?? 0) const used = Number(anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount ?? 0) const frozen = Number(anyT.frozenQty ?? anyT.frozenNum ?? anyT.frozenCount ?? 0) const computed = (Number.isFinite(total) ? total : 0) - (Number.isFinite(used) ? used : 0) - (Number.isFinite(frozen) ? frozen : 0) return Number.isFinite(computed) ? computed : 0 } const usableTickets = useMemo(() => { const list = (tickets || []) .filter(t => Number(t?.deleted) !== 1) // 1 = 冻结(兼容 status 为字符串) .filter(t => Number(t?.status) !== 1) .filter(t => Number.isFinite(Number(t?.id)) && Number(t?.id) > 0) .filter(t => getTicketAvailableQty(t) > 0) // Some tenants return goodsId as string; coerce before comparison. .filter((t) => { if (!numericGoodsId) return true const tg = Number((t as any)?.goodsId) const hasGoodsId = Number.isFinite(tg) && tg > 0 return !hasGoodsId || tg === numericGoodsId }) // Default order in list: older first (reduce disputes). Real consumption order is computed separately. 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 availableTicketTotal = useMemo(() => { return usableTickets.reduce((sum, t) => sum + getTicketAvailableQty(t), 0) }, [usableTickets]) // Consume tickets with smaller available qty first; ties: older first. const ticketsToConsume = useMemo(() => { const list = [...usableTickets] return list.sort((a, b) => { const qa = getTicketAvailableQty(a) const qb = getTicketAvailableQty(b) if (qa !== qb) return qa - qb 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) }) }, [usableTickets]) const noUsableTickets = useMemo(() => { // Only show "go buy tickets" guidance after we have finished loading. return !!userId && ticketLoaded && !ticketLoading && usableTickets.length === 0 }, [ticketLoaded, ticketLoading, usableTickets.length, userId]) 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') }, [sendTime]) const distanceMeters = (a: { lng: number; lat: number }, b: { lng: number; lat: number }) => { const toRad = (x: number) => (x * Math.PI) / 180 const R = 6371000 // meters const dLat = toRad(b.lat - a.lat) const dLng = toRad(b.lng - a.lng) const lat1 = toRad(a.lat) const lat2 = toRad(b.lat) const sin1 = Math.sin(dLat / 2) const sin2 = Math.sin(dLng / 2) const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2 return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))) } const parseStoreCoords = (s: ShopStore) => { return parseLngLatFromText((s.lngAndLat || s.location || '').trim()) } const openAddressPage = async () => { const limit = ticketAddressModifyLimit.loaded ? ticketAddressModifyLimit : await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit)) if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit) if (!limit.canModify) { Taro.showToast({ title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次${limit.nextAllowedText ? ',' + limit.nextAllowedText + ' 后可修改' : ''}`, icon: 'none', }) return } Taro.navigateTo({ url: '/user/address/index' }) } const loadFences = async (): Promise => { if (fencesLoadedRef.current) return fences if (fencesPromiseRef.current) return fencesPromiseRef.current fencesPromiseRef.current = (async () => { try { const list = await listShopStoreFence() const safe = Array.isArray(list) ? list : [] setFences(safe) fencesErrorRef.current = null fencesLoadedRef.current = true return safe } catch (e) { console.error('获取电子围栏失败:', e) setFences([]) fencesErrorRef.current = e instanceof Error ? e : new Error('获取电子围栏失败') fencesLoadedRef.current = true return [] } finally { fencesPromiseRef.current = null } })() return fencesPromiseRef.current } const isPointInFence = async (p: { lng: number; lat: number }): Promise => { const list = fencesLoadedRef.current ? fences : await loadFences() if (!list.length) { // If we failed to fetch fences, block ordering (can't verify delivery range). if (fencesErrorRef.current) throw fencesErrorRef.current // No fence configured => do not block. return true } const active = (list || []) .filter(f => f?.status === 0 || f?.status === undefined) const polygons = active .map(f => parseFencePoints(f?.points)) .filter(poly => poly.length >= 3) if (!polygons.length) { // If backend has active fence rows but points can't be parsed, block and surface the issue. const hasPointsText = active.some(f => String(f?.points || '').trim().length > 0) if (hasPointsText) throw new Error('电子围栏数据异常,请联系管理员配置') // No usable polygon configured => do not block. return true } return pointInAnyPolygon(p, polygons) } const getCheckPoint = async (): Promise<{ lng: number; lat: number }> => { // Immediate water delivery must validate by the delivery address coordinates. // Falling back to current GPS may allow ordering with an out-of-fence address. const byAddress = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`) if (byAddress) return byAddress throw new Error('该收货地址缺少经纬度,请在地址里选择地图定位后重试') } const ensureInDeliveryRange = async (): Promise => { if (deliveryRangeCheckingRef.current) return false deliveryRangeCheckingRef.current = true setDeliveryRangeChecking(true) try { const p = await getCheckPoint() const ok = await isPointInFence(p) setInDeliveryRange(ok) setDeliveryRangeCheckedAddressId(address?.id) if (!ok) { Taro.showToast({ title: '不在配送范围内,暂不支持下单', icon: 'none' }) } return ok } catch (e: any) { console.error('配送范围校验失败:', e) setInDeliveryRange(undefined) setDeliveryRangeCheckedAddressId(undefined) // Note: we validate by address coords only; no GPS permission prompt here. Taro.showToast({ title: e?.message || '配送范围校验失败,请稍后重试', icon: 'none' }) return false } finally { setDeliveryRangeChecking(false) deliveryRangeCheckingRef.current = false } } const loadStores = async (): Promise => { if (storeLoading) return stores try { setStoreLoading(true) const list = await listShopStore() const usable = (list || []).filter(s => s?.isDelete !== 1) setStores(usable) return usable } catch (e) { console.error('获取门店列表失败:', e) setStores([]) Taro.showToast({title: '获取门店列表失败', icon: 'none'}) return [] } finally { setStoreLoading(false) } } const ensureStoreDetail = async (s: ShopStore): Promise => { if (!s?.id) return s // If backend already returned "delivery area polygon"/warehouse info, skip extra request. if (s.points || s.warehouseId) return s try { const full = await getShopStore(s.id) return full || s } catch (_e) { return s } } const pickNearestStore = async (p: { lng: number; lat: number }, list: ShopStore[]) => { const usable = (list || []).filter(s => s?.isDelete !== 1) if (!usable.length) return null // 1) If a store has a polygon (points) and the delivery point is inside, prefer those stores. const inside: { s: ShopStore; d?: number }[] = [] const outside: { s: ShopStore; d?: number }[] = [] for (const s of usable) { const poly = parseFencePoints(s?.points) const isInside = poly.length >= 3 ? pointInPolygon(p, poly) : false const coords = parseStoreCoords(s) const d = coords ? distanceMeters(p, coords) : undefined ;(isInside ? inside : outside).push({ s, d }) } const sortByDistanceThenSortNo = (a: { s: ShopStore; d?: number }, b: { s: ShopStore; d?: number }) => { const da = a.d ?? Number.POSITIVE_INFINITY const db = b.d ?? Number.POSITIVE_INFINITY if (da !== db) return da - db const sa = a.s.sortNumber ?? Number.POSITIVE_INFINITY const sb = b.s.sortNumber ?? Number.POSITIVE_INFINITY if (sa !== sb) return sa - sb return (a.s.id || 0) - (b.s.id || 0) } const best = (inside.length ? inside.sort(sortByDistanceThenSortNo) : outside.sort(sortByDistanceThenSortNo))[0]?.s if (!best) return null return ensureStoreDetail(best) } const resolveStoreForOrder = async (opts?: { silent?: boolean }): Promise => { // If user already picked a store on this page, don't auto override. if (storeManualPickedRef.current && selectedStore?.id) return selectedStore const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`) if (!p) { // If we already have a selected store, allow ordering to continue (can't auto-pick though). if (!opts?.silent && !selectedStore?.id) { Taro.showToast({ title: '该地址缺少经纬度,无法自动分配门店,请在地址里选择地图定位后重试', icon: 'none' }) } return selectedStore?.id ? selectedStore : null } if (storeAutoPickingRef.current) return selectedStore?.id ? selectedStore : null storeAutoPickingRef.current = true try { const list = stores.length ? stores : await loadStores() // If a store is already selected and its polygon contains the point, keep it. if (selectedStore?.id) { const full = await ensureStoreDetail(selectedStore) const poly = parseFencePoints(full?.points) if (poly.length >= 3 && pointInPolygon(p, poly)) { if (full !== selectedStore) { setSelectedStore(full) saveSelectedStoreToStorage(full) } return full } } const best = await pickNearestStore(p, list) if (best?.id) { setSelectedStore(best) saveSelectedStoreToStorage(best) return best } return selectedStore?.id ? selectedStore : null } finally { storeAutoPickingRef.current = false } } const resolveAutoRiderForStore = async (storeId: number): Promise => { try { const list = await listShopStoreRider({ storeId, status: 1 }) const usable = (list || []) .filter(r => r?.isDelete !== 1) // Prefer enabled/online/auto-dispatch riders; fallback to any usable rider. .sort((a, b) => { const score = (r: ShopStoreRider) => { const enabled = r.status === 1 ? 1000 : 0 const online = r.workStatus === 1 ? 100 : (r.workStatus === 2 ? 50 : 0) const auto = r.autoDispatchEnabled === 1 ? 10 : 0 const prio = Number(r.dispatchPriority || 0) return enabled + online + auto + prio } return score(b) - score(a) }) return usable[0] || null } catch (e) { console.warn('自动获取配送员失败,将由后台/门店人工派单:', e) return null } } // @ts-ignore 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([]) setTicketLoaded(false) return } try { setTicketLoading(true) // Do not pass `status` here: some backends use different status semantics; // we filter out frozen tickets on the client for compatibility. const list = await listGltUserTicket({ userId }) setTickets(list || []) } catch (e) { console.error('获取水票失败:', e) setTickets([]) Taro.showToast({ title: '获取水票失败', icon: 'none' }) } finally { setTicketLoading(false) setTicketLoaded(true) } } const goBuyTickets = async () => { try { setTicketPopupVisible(false) // If this page is opened with a goodsId, guide user back to that goods detail to purchase. if (numericGoodsId) { await Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${numericGoodsId}` }) return } await Taro.switchTab({ url: '/pages/index/index' }) } catch (e) { console.error('跳转购买水票失败:', e) Taro.showToast({ title: '跳转失败,请稍后重试', icon: 'none' }) } } const onSubmit = async () => { if (submitLoading) return if (deliveryRangeCheckingRef.current) return if (!goods?.goodsId) return // 基础校验 if (!userId) { Taro.showToast({ title: '请先登录', icon: 'none' }) return } if (!address?.id) { Taro.showToast({ title: '请选择收货地址', icon: 'none' }) return } // Ticket delivery address is based on order snapshot. Enforce "once per 30 days" by latest ticket-order history. const limit = ticketAddressModifyLimit.loaded ? ticketAddressModifyLimit : await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit)) if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit) if (!limit.canModify && limit.lockedAddressId && address.id !== limit.lockedAddressId) { Taro.showToast({ title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次,请使用上次下单地址${limit.nextAllowedText ? '(' + limit.nextAllowedText + ' 后可修改)' : ''}`, icon: 'none', }) try { const locked = await getShopUserAddress(limit.lockedAddressId) if (locked?.id) setAddress(locked) } catch (_e) { // ignore: keep current address, but still block submission } return } if (!addressHasCoords) { Taro.showToast({ title: '该收货地址缺少经纬度,请在地址里选择地图定位后重试', icon: 'none' }) return } // Ensure ticket list is loaded. if (ticketLoading) { Taro.showToast({ title: '水票加载中,请稍后再试', icon: 'none' }) return } if (!ticketLoaded) { await loadUserTickets() } const storeForOrder = await resolveStoreForOrder() if (!storeForOrder?.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 ok = await ensureInDeliveryRange() if (!ok) return const confirmRes = await Taro.showModal({ title: '确认下单', content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?` }) if (!confirmRes.confirm) return try { setSubmitLoading(true) Taro.showLoading({ title: '提交中...' }) // Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it. const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null // Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId). // Consume tickets with smaller available qty first. let remain = finalQty let created = 0 for (const t of ticketsToConsume) { if (remain <= 0) break const avail = getTicketAvailableQty(t) const useQty = Math.min(remain, avail) if (useQty <= 0) continue await addGltTicketOrder({ userTicketId: Number(t.id), storeId: storeForOrder.id, addressId: address.id, totalNum: useQty, buyerRemarks: orderRemark, sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'), // Backend may take userId from token; pass-through is harmless if backend ignores it. userId, riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined, riderName: autoRider?.realName, riderPhone: autoRider?.mobile, comments: goods.name ? `立即送水:${goods.name}` : '立即送水' }) remain -= useQty created += 1 } if (remain > 0) { // Ticket counts might have changed between loading and submission. throw new Error('水票可用次数不足,请刷新后重试') } await loadUserTickets() Taro.showToast({ title: created > 1 ? '下单成功(已拆分多张水票)' : '下单成功', 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]) } // Load ticket-order history to enforce "address can be modified once per 30 days". // If currently locked, force using last ticket-order address (snapshot) to avoid getting stuck with a new default address. try { const limit = await loadTicketAddressModifyLimit() setTicketAddressModifyLimit(limit) if (!limit.canModify && limit.lockedAddressId) { const locked = await getShopUserAddress(limit.lockedAddressId) if (locked?.id) setAddress(locked) } } catch (e) { console.error('加载送水地址修改限制失败:', e) setTicketAddressModifyLimit({ loaded: true, canModify: true }) } // 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 }) }) const addressHasCoords = useMemo(() => { if (!address?.id) return false return !!parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`) }, [address?.id, address?.lng, address?.lat]) // Auto-pick nearest store by delivery address (best-effort, won't override manual selection). useEffect(() => { if (!address?.id) return if (storeManualPickedRef.current) return resolveStoreForOrder({ silent: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [address?.id, address?.lng, address?.lat]) // Background pre-check when we already have an address coordinate (no permission prompt). useEffect(() => { let cancelled = false ;(async () => { if (!address?.id) { setInDeliveryRange(undefined) setDeliveryRangeCheckedAddressId(undefined) return } const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`) if (!p) { // Cannot validate without address coords -> treat as out of range to block ordering. setInDeliveryRange(false) setDeliveryRangeCheckedAddressId(address.id) return } // Avoid keeping stale state from previous address while we validate this one. setInDeliveryRange(undefined) setDeliveryRangeCheckedAddressId(undefined) let ok = true try { ok = await isPointInFence(p) } catch (_e) { // Pre-check is best-effort; don't block UI here. if (!cancelled) { setInDeliveryRange(undefined) setDeliveryRangeCheckedAddressId(undefined) } return } if (cancelled) return setInDeliveryRange(ok) setDeliveryRangeCheckedAddressId(address.id) })() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [address?.id, address?.lng, address?.lat]) // When user changes the delivery address to an out-of-fence one, prompt immediately (once per address). const outOfRangePromptedAddressIdRef = useRef(undefined) useEffect(() => { // Only prompt when user is allowed to change the ticket delivery address. // Otherwise this toast is noisy (they can't fix it within the cooldown window). if (!ticketAddressModifyLimit.loaded) return if (!ticketAddressModifyLimit.canModify) return const id = address?.id if (!id) return if (deliveryRangeCheckedAddressId !== id) return if (inDeliveryRange !== false) return if (outOfRangePromptedAddressIdRef.current === id) return outOfRangePromptedAddressIdRef.current = id Taro.showToast({ title: addressHasCoords ? '该地址不在配送范围,请更换围栏内地址' : '该地址缺少定位,请在地址里选择地图定位后重试', icon: 'none' }) }, [ address?.id, addressHasCoords, deliveryRangeCheckedAddressId, inDeliveryRange, ticketAddressModifyLimit.loaded, ticketAddressModifyLimit.canModify ]) // 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]) // If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle). useEffect(() => { if (!noUsableTickets) return if (noTicketPromptedRef.current) return noTicketPromptedRef.current = true ;(async () => { const r = await Taro.showModal({ title: '暂无可用水票', content: '您当前没有可用水票,购买后再来下单更方便。', confirmText: '去购买', cancelText: '暂不' }) if (r.confirm) { await goBuyTickets() } })() // eslint-disable-next-line react-hooks/exhaustive-deps }, [noUsableTickets]) // 重新加载数据 const handleRetry = () => { loadAllData() } // 错误状态 if (error) { return ( {error} ) } // 加载状态 if (loading || !goods) { return } return (
{/**/} {/* */} {/* */} {/* 选择门店*/} {/* */} {/* )}*/} {/* extra={(*/} {/* */} {/* */} {/* {selectedStore?.name || '请选择门店'}*/} {/* */} {/* */} {/* */} {/* )}*/} {/* onClick={openStorePopup}*/} {/* />*/} {/**/} { address && ( 送至 {address.province} {address.city} {address.region} {address.address} {address.name} {address.phone} {ticketAddressModifyLimit.loaded && !ticketAddressModifyLimit.canModify && ( 送水地址每{ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次 {ticketAddressModifyLimit.nextAllowedText ? `,${ticketAddressModifyLimit.nextAllowedText} 后可修改` : ''} )} ) } {!address && ( 添加收货地址 )} {sendTimeText} )} /> )} /> 水票明细 )} extra={( {ticketLoading ? '加载中...' : (ticketLoaded ? (noUsableTickets ? '暂无可用水票' : `可用合计 ${availableTicketTotal}(${usableTickets.length}组)` ) : '点击查看' ) } )} onClick={async () => { if (ticketLoading) return if (!ticketLoaded) { setTicketPopupVisible(true) await loadUserTickets() return } if (noUsableTickets) { const r = await Taro.showModal({ title: '暂无可用水票', content: '您还没有可用水票,是否前往购买?', confirmText: '去购买', cancelText: '暂不' }) if (r.confirm) await goBuyTickets() return } setTicketPopupVisible(true) }} /> {noUsableTickets && ( 还没有购买水票} description="购买水票后即可在这里直接下单送水" extra={( )} /> )} {displayQty} 张} /> setOrderRemark(value)} maxLength={100} /> )}/> {/* 水票明细弹窗 */} setTicketPopupVisible(false)} > 水票明细 setTicketPopupVisible(false)} > 关闭 {!!usableTickets.length && !ticketLoading && ( 可用合计 {availableTicketTotal} 张;下单时将优先使用可用数量少的水票 )} {ticketLoading ? ( 加载中... ) : ( <> {!!usableTickets.length ? ( {ticketsToConsume.map((t) => { return ( 票号 {t.id}} description={t.orderNo ? `来源订单:${t.orderNo}` : ''} extra={可用 {getTicketAvailableQty(t)}} onClick={() => setTicketPopupVisible(false)} /> ) })} ) : ( )} )} {/* 门店选择弹窗 */} 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) storeManualPickedRef.current = true saveSelectedStoreToStorage(storeToSave) setStorePopupVisible(false) Taro.showToast({title: '门店已切换', icon: 'success'}) }} /> ) })} {!stores.length && ( 暂无门店数据} /> )} )}
使用水票: {displayQty}
{noUsableTickets ? ( ) : ( )}
); }; export default OrderConfirm;