import { useEffect, useMemo, useRef, useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { View, Text, Picker } 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, { type 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 { getGltUserTicket, listGltUserTicket } from '@/api/glt/gltUserTicket' import { getGltTicketTemplate, getGltTicketTemplateByGoodsId } from '@/api/glt/gltTicketTemplate' import { addGltTicketOrder, getGltTicketOrder, updateGltTicketOrder } 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 DEFAULT_MIN_START_QTY = 10 const OrderConfirm = () => { const [goods, setGoods] = useState(null); const [address, setAddress] = useState() const [minStartQty, setMinStartQty] = useState(DEFAULT_MIN_START_QTY) const [quantity, setQuantity] = useState(DEFAULT_MIN_START_QTY) const [orderRemark, setOrderRemark] = useState('') // Delivery date only (no hour/min selection). const [sendTime, setSendTime] = 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) const ticketAutoRetryCountRef = useRef(0) const ticketAutoRetryTimerRef = useRef | null>(null) // 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) // 配送方式:elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他) const [deliveryMethod, setDeliveryMethod] = useState('') // 步梯是否需要送上楼(null=未选择) const [needCarryUpstairs, setNeedCarryUpstairs] = useState(null) // 楼层(从2开始,需要送上楼时选择) const [deliveryFloor, setDeliveryFloor] = useState(2) // 楼层选择弹窗 const [floorPickerVisible, setFloorPickerVisible] = useState(false) // 计算配送费:每桶每层1元,第1层不收费 const getDeliveryFee = () => { if (deliveryMethod !== 'stairs' || !needCarryUpstairs) return 0 if (deliveryFloor <= 1) return 0 return displayQty * (deliveryFloor - 1) } const router = Taro.getCurrentInstance().router; const goodsId = router?.params?.goodsId; const orderId = router?.params?.orderId; const numericGoodsId = useMemo(() => { const n = goodsId ? Number(goodsId) : undefined return typeof n === 'number' && Number.isFinite(n) ? n : undefined }, [goodsId]) const numericOrderId = useMemo(() => { const n = orderId ? Number(orderId) : undefined return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined }, [orderId]) const isEditMode = !!numericOrderId const [editingOrder, setEditingOrder] = useState(null) const editingInitRef = useRef(false) const userId = useMemo(() => { const raw = Taro.getStorageSync('UserId') const id = Number(raw) return Number.isFinite(id) && id > 0 ? id : undefined }, []) 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 clampSendDateToToday = (d: Dayjs) => { const today = dayjs().startOf('day') if (!d.isValid()) return today return d.isBefore(today, 'day') ? today : d.startOf('day') } const isPendingDeliveryOrder = (o?: Partial | null) => { if (!o) return false const ds = (o as any)?.deliveryStatus const hasProgress = !!o.sendStartTime || !!o.sendEndTime || !!o.receiveConfirmTime return ( Number((o as any)?.deleted) !== 1 && Number(o.status) !== 1 && !hasProgress && (ds === 10 || (typeof ds !== 'number' && !!o.riderId)) ) } 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]) // After buying tickets and redirecting here, some backends may issue tickets asynchronously. // If opened with a `goodsId`, retry a few times to refresh tickets. useEffect(() => { if (isEditMode) return if (!numericGoodsId) return if (!ticketLoaded || ticketLoading) return if (usableTickets.length > 0) { ticketAutoRetryCountRef.current = 0 return } if (ticketAutoRetryCountRef.current >= 4) return if (ticketAutoRetryTimerRef.current) return const delays = [800, 1500, 2500, 4000] const delay = delays[ticketAutoRetryCountRef.current] ?? 2500 ticketAutoRetryCountRef.current += 1 ticketAutoRetryTimerRef.current = setTimeout(async () => { ticketAutoRetryTimerRef.current = null await loadUserTickets() }, delay) }, [isEditMode, numericGoodsId, ticketLoaded, ticketLoading, usableTickets.length]) useEffect(() => { return () => { if (ticketAutoRetryTimerRef.current) { clearTimeout(ticketAutoRetryTimerRef.current) ticketAutoRetryTimerRef.current = null } } }, []) const maxQuantity = useMemo(() => { const stockMax = goods?.stock ?? 999 if (!isEditMode) return Math.max(0, Math.min(stockMax, availableTicketTotal)) const original = Number(editingOrder?.totalNum ?? 0) const originalSafe = Number.isFinite(original) ? original : 0 const ticketId = Number(editingOrder?.userTicketId ?? 0) const ticketIdSafe = Number.isFinite(ticketId) && ticketId > 0 ? ticketId : undefined const rawTicket = ticketIdSafe ? (tickets || []).find(t => Number(t?.id) === ticketIdSafe) : undefined if (!rawTicket) return Math.max(0, Math.min(stockMax, originalSafe)) const avail = getTicketAvailableQty(rawTicket) const upper = Math.max(0, avail + originalSafe) return Math.max(0, Math.min(stockMax, upper)) }, [availableTicketTotal, editingOrder?.totalNum, editingOrder?.userTicketId, goods?.stock, isEditMode, tickets]) const canStartOrder = useMemo(() => { return maxQuantity >= minStartQty }, [maxQuantity, minStartQty]) const displayQty = useMemo(() => { if (!canStartOrder) return 0 return Math.max(minStartQty, Math.min(quantity, maxQuantity)) }, [quantity, maxQuantity, canStartOrder, minStartQty]) 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 () => { if (isEditMode) { if (!editingOrder?.id) { Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' }) return } if (!isPendingDeliveryOrder(editingOrder)) { Taro.showToast({ title: '该订单当前不可修改', 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(minStartQty, Math.min(newQuantity || minStartQty, 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 (!userId) { Taro.showToast({ title: '请先登录', icon: 'none' }) return } if (isEditMode && !editingOrder?.id) { Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' }) return } if (isEditMode && !isPendingDeliveryOrder(editingOrder)) { Taro.showToast({ title: '该订单当前不可修改', icon: 'none' }) return } if (!address?.id) { Taro.showToast({ title: '请选择收货地址', icon: 'none' }) return } // 配送方式校验(必选) if (!deliveryMethod) { Taro.showToast({ title: '请选择配送方式', icon: 'none' }) return } // 步梯场景:必须选择是否送上楼 if (deliveryMethod === 'stairs' && needCarryUpstairs === null) { Taro.showToast({ title: '请选择是否需要送上楼', icon: 'none' }) 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 (!isEditMode && availableTicketTotal <= 0) { Taro.showToast({ title: '暂无可用水票', icon: 'none' }) return } const finalQty = displayQty if (finalQty <= 0) { Taro.showToast({ title: '请选择送水数量', icon: 'none' }) return } if (!isEditMode && finalQty > availableTicketTotal) { Taro.showToast({ title: '水票可用次数不足', icon: 'none' }) return } if (isEditMode && finalQty > maxQuantity) { Taro.showToast({ title: '水票可用次数不足', icon: 'none' }) return } if (goods?.stock !== undefined && finalQty > goods.stock) { Taro.showToast({ title: '商品库存不足', icon: 'none' }) return } if (finalQty < minStartQty) { Taro.showToast({ title: `最低起送 ${minStartQty} 桶`, icon: 'none' }) return } if (!sendTime) { Taro.showToast({ title: '请选择配送时间', icon: 'none' }) return } if (dayjs(sendTime).isBefore(dayjs().startOf('day'), 'day')) { Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' }) setSendTime(dayjs().startOf('day').toDate()) return } // 配送范围校验(电子围栏) const ok = await ensureInDeliveryRange() if (!ok) return const deliveryFee = getDeliveryFee() const confirmContent = isEditMode ? `配送时间:${sendTimeText}\n送水数量:${finalQty} 桶\n配送方式:${deliveryMethod === 'elevator' ? '电梯' : deliveryMethod === 'stairs' ? '步梯' : '一楼商铺/其他'}${deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 ? `(${deliveryFloor}楼)` : deliveryMethod === 'stairs' && !needCarryUpstairs ? '(不送上楼)' : ''}\n${deliveryFee > 0 ? `配送费:¥${deliveryFee.toFixed(2)}\n` : ''}是否确认修改?` : `配送时间:${sendTimeText}\n配送方式:${deliveryMethod === 'elevator' ? '电梯' : deliveryMethod === 'stairs' ? '步梯' : '一楼商铺/其他'}${deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 ? `(${deliveryFloor}楼)` : deliveryMethod === 'stairs' && !needCarryUpstairs ? '(不送上楼)' : ''}\n${deliveryFee > 0 ? `配送费:¥${deliveryFee.toFixed(2)}\n` : ''}将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?` const confirmRes = await Taro.showModal({ title: isEditMode ? '确认修改' : '确认下单', content: confirmContent }) if (!confirmRes.confirm) return try { setSubmitLoading(true) Taro.showLoading({ title: '提交中...' }) if (isEditMode) { await updateGltTicketOrder({ id: editingOrder?.id, addressId: address.id, totalNum: finalQty, buyerRemarks: orderRemark, sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'), deliveryMethod, deliveryFloor: deliveryMethod === 'stairs' && needCarryUpstairs ? deliveryFloor : undefined, deliveryFee: getDeliveryFee() || undefined }) } else { // 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 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}` : '立即送水', // 配送方式信息 deliveryMethod, deliveryFloor: deliveryMethod === 'stairs' && needCarryUpstairs ? deliveryFloor : undefined, deliveryFee: getDeliveryFee() || undefined }) remain -= useQty } if (remain > 0) { // Ticket counts might have changed between loading and submission. throw new Error('水票可用次数不足,请刷新后重试') } } await loadUserTickets() Taro.showToast({ title: isEditMode ? '修改成功' : '下单成功', icon: 'success' }) setTimeout(() => { // 跳转到“我的送水订单” Taro.redirectTo({ url: '/user/ticket/index?tab=order' }) }, 800) } catch (e: any) { console.error(isEditMode ? '送水订单修改失败:' : '水票下单失败:', e) Taro.showToast({ title: e?.message || (isEditMode ? '修改失败' : '下单失败'), 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 [addressRes, editingOrderRes, goodsByParam] = await Promise.all([ listShopUserAddress({ isDefault: true }), numericOrderId ? getGltTicketOrder(numericOrderId) : Promise.resolve(null), numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null) ]) let goodsRes = goodsByParam if (!goodsRes && editingOrderRes?.userTicketId) { const ticketId = Number(editingOrderRes.userTicketId) if (Number.isFinite(ticketId) && ticketId > 0) { try { const ticket = await getGltUserTicket(ticketId) const gid = Number((ticket as any)?.goodsId) if (Number.isFinite(gid) && gid > 0) { goodsRes = await getShopGoods(gid) } } catch (e) { console.error('加载订单关联商品失败:', e) } } } // 设置商品信息 if (goodsRes) { setGoods(goodsRes) hasInitialLoadedRef.current = true } // 设置默认收货地址 if (addressRes && addressRes.length > 0) { setAddress(addressRes[0]) } if (numericOrderId && editingOrderRes && !editingInitRef.current) { editingInitRef.current = true setEditingOrder(editingOrderRes) Taro.setNavigationBarTitle({ title: '订单确认' }) const isPending = isPendingDeliveryOrder(editingOrderRes) if (!isPending) { Taro.showToast({ title: '该订单当前不可修改', icon: 'none' }) setTimeout(() => { Taro.navigateBack() }, 600) return } const initQty = Number(editingOrderRes.totalNum ?? minStartQty) setQuantity(Number.isFinite(initQty) && initQty > 0 ? initQty : minStartQty) setOrderRemark(String(editingOrderRes.buyerRemarks || '')) const st = parseTime(editingOrderRes.sendTime) if (st) setSendTime(clampSendDateToToday(st).toDate()) // 回显配送方式 if (editingOrderRes.deliveryMethod) { setDeliveryMethod(editingOrderRes.deliveryMethod) if (editingOrderRes.deliveryMethod === 'stairs') { const hasFloor = editingOrderRes.deliveryFloor && editingOrderRes.deliveryFloor > 1 setNeedCarryUpstairs(hasFloor) if (hasFloor) setDeliveryFloor(editingOrderRes.deliveryFloor) } } const addrId = Number(editingOrderRes.addressId) const addrIdSafe = Number.isFinite(addrId) && addrId > 0 ? addrId : undefined if (addrIdSafe) { const hit = addressRes?.find(a => Number(a?.id) === addrIdSafe) if (hit?.id) { setAddress(hit) } else { try { const addr = await getShopUserAddress(addrIdSafe) if (addr?.id) setAddress(addr) } catch (e) { console.error('加载订单收货地址失败:', e) } } } } // 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()) ticketAutoRetryCountRef.current = 0 if (ticketAutoRetryTimerRef.current) { clearTimeout(ticketAutoRetryTimerRef.current) ticketAutoRetryTimerRef.current = null } 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(() => { 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 ]) // When tickets/stock change, clamp quantity into [0..maxQuantity]. useEffect(() => { setQuantity(prev => { if (maxQuantity <= 0) return 0 if (maxQuantity < minStartQty) return 0 if (!prev || prev < minStartQty) return minStartQty return Math.min(prev, maxQuantity) }) }, [maxQuantity, minStartQty]) const minStartQtyKey = useMemo(() => { const gid = Number(goods?.goodsId) if (Number.isFinite(gid) && gid > 0) return `g:${gid}` // If there is exactly one ticket template available, infer min start qty from it (covers "稍后再送" without goodsId). const ids = Array.from( new Set( (usableTickets || []) .map(t => Number(t?.templateId)) .filter(id => Number.isFinite(id) && id > 0) ) ) if (ids.length === 1) return `t:${ids[0]}` return '' }, [goods?.goodsId, usableTickets]) // Use configured min start-send qty from ticket template (by goodsId or by user's unique templateId). useEffect(() => { let cancelled = false ;(async () => { try { if (!minStartQtyKey) { setMinStartQty(DEFAULT_MIN_START_QTY) return } const [kind, rawId] = minStartQtyKey.split(':') const id = Number(rawId) const tpl = kind === 'g' ? await getGltTicketTemplateByGoodsId(id) : await getGltTicketTemplate(id) const n = Number(tpl?.startSendQty) const safe = Number.isFinite(n) && n > 0 ? n : DEFAULT_MIN_START_QTY if (!cancelled) setMinStartQty(safe) } catch (_e) { if (!cancelled) setMinStartQty(DEFAULT_MIN_START_QTY) } })() return () => { cancelled = true } }, [minStartQtyKey]) // If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle). useEffect(() => { if (!noUsableTickets) return // Editing an existing order: don't interrupt with "no tickets" prompt. if (isEditMode) 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, isEditMode]) // 重新加载数据 const handleRetry = () => { loadAllData() } // 错误状态 if (error) { return ( {error} ) } // 加载状态 if (loading) { return } return (
{/**/} {/* */} {/* */} {/* 选择门店*/} {/* */} {/* )}*/} {/* extra={(*/} {/* */} {/* */} {/* {selectedStore?.name || '请选择门店'}*/} {/* */} {/* */} {/* */} {/* )}*/} {/* onClick={openStorePopup}*/} {/* />*/} {/**/} { address && ( 送至 {address.province} {address.city} {address.region} {address.address} {address.name} {address.phone} ) } {!address && ( 添加收货地址 )} {/* 配送方式选择(必选) */} 配送方式 * {[ { key: 'elevator', label: '电梯', icon: '🏛️' }, { key: 'stairs', label: '步梯', icon: '🚶' }, { key: 'groundFloor', label: '一楼商铺/其他', icon: '🏪' }, ].map(item => ( { setDeliveryMethod(item.key) setNeedCarryUpstairs(null) setDeliveryFloor(2) }} > {item.icon} {item.label} ))} {/* 步梯:是否需要送上楼 */} {deliveryMethod === 'stairs' && ( 是否需要送上楼? setNeedCarryUpstairs(true)} > 需要送上楼 { setNeedCarryUpstairs(false) setDeliveryFloor(2) }} > 不需要 )} {/* 步梯+送上楼:选择楼层 */} {deliveryMethod === 'stairs' && needCarryUpstairs === true && ( 送至楼层 setFloorPickerVisible(true)} > 1 ? 'text-gray-900' : 'text-gray-400'}> {deliveryFloor > 1 ? `${deliveryFloor}楼` : '请选择楼层'} {deliveryFloor > 1 && ( 配送费:{displayQty}桶 x {deliveryFloor - 1}层 = ¥{getDeliveryFee().toFixed(2)} )} )} { const v = (e as any)?.detail?.value const d = dayjs(v) if (!d.isValid()) return if (d.isBefore(dayjs().startOf('day'), 'day')) { Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' }) setSendTime(dayjs().startOf('day').toDate()) return } setSendTime(d.startOf('day').toDate()) }} > {sendTimeText} )} /> = 10 ? 10 : 1} readOnly disabled={!canStartOrder} onChange={handleQuantityChange} /> )} /> 水票明细 )} extra={( {ticketLoading ? '加载中...' : (ticketLoaded ? (noUsableTickets ? '暂无可用水票' : `可用合计 ${availableTicketTotal}(${usableTickets.length}组)` ) : '点击查看' ) } )} onClick={async () => { if (ticketLoading) return if (!ticketLoaded) { setTicketPopupVisible(true) await loadUserTickets() return } if (noUsableTickets && !isEditMode) { const r = await Taro.showModal({ title: '暂无可用水票', content: '您还没有可用水票,是否前往购买?', confirmText: '去购买', cancelText: '暂不' }) if (r.confirm) await goBuyTickets() return } setTicketPopupVisible(true) }} /> {(noUsableTickets && !isEditMode) && ( 还没有购买水票} 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 && ( 暂无门店数据} /> )} )} {/* 楼层选择弹窗 */} setFloorPickerVisible(false)} style={{height: '40vh'}} > 选择楼层 setFloorPickerVisible(false)} > 关闭 {Array.from({length: 32}, (_, i) => i + 2).map(f => ( { setDeliveryFloor(f) setFloorPickerVisible(false) }} > {f}楼 ))} {deliveryFloor > 1 && ( 配送费:{displayQty}桶 x {deliveryFloor - 1}层 = ¥{(displayQty * (deliveryFloor - 1)).toFixed(2)} )}
使用水票: {displayQty} {getDeliveryFee() > 0 && ( 配送费 ¥{getDeliveryFee().toFixed(2)}(到付) )}
{noUsableTickets && !isEditMode ? ( ) : ( )}
); }; export default OrderConfirm;