Files
glt-taro/src/user/ticket/use.tsx
赵忠林 338dc421db fix(ticket): 修复配送地址超出范围提示问题
- 仅在用户允许修改门票配送地址时显示提示
- 避免在冷却窗口期间显示冗余提示
- 添加对地址修改限制状态的检查
- 更新 useEffect 依赖数组以包含地址修改限制状态
2026-02-28 00:40:38 +08:00

1295 lines
48 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,
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<ShopGoods | null>(null);
const [address, setAddress] = useState<ShopUserAddress>()
const [quantity, setQuantity] = useState<number>(MIN_START_QTY)
const [orderRemark, setOrderRemark] = useState<string>('')
// Delivery date only (no hour/min selection).
const [sendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
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 storeAutoPickingRef = useRef(false)
const storeManualPickedRef = useRef(false)
// 水票:用于“立即送水”下单(用水票抵扣,无需支付)
const [tickets, setTickets] = useState<GltUserTicket[]>([])
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<ShopStoreFence[]>([])
const fencesLoadedRef = useRef(false)
const fencesPromiseRef = useRef<Promise<ShopStoreFence[]> | null>(null)
const fencesErrorRef = useRef<Error | null>(null)
const [deliveryRangeChecking, setDeliveryRangeChecking] = useState(false)
const deliveryRangeCheckingRef = useRef(false)
const [inDeliveryRange, setInDeliveryRange] = useState<boolean | undefined>(undefined)
// Prevent using stale `inDeliveryRange` from a previous address when user switches addresses.
const [deliveryRangeCheckedAddressId, setDeliveryRangeCheckedAddressId] = useState<number | undefined>(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<TicketAddressModifyLimit>({
loaded: false,
canModify: true,
})
const ticketAddressModifyLimitPromiseRef = useRef<Promise<TicketAddressModifyLimit> | 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<GltTicketOrder> | null) => {
return parseTime(o?.createTime) || parseTime(o?.updateTime)
}
const getOrderAddressKey = (o?: Partial<GltTicketOrder> | 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<TicketAddressModifyLimit> => {
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<GltUserTicket> | 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<ShopStoreFence[]> => {
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<boolean> => {
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<boolean> => {
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<ShopStore[]> => {
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<ShopStore> => {
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<ShopStore | null> => {
// 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<ShopStoreRider | null> => {
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<number | undefined>(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 (
<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={openAddressPage}
>
<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'}>
<View className={'text-gray-500'}>{address.name} {address.phone}</View>
{ticketAddressModifyLimit.loaded && !ticketAddressModifyLimit.canModify && (
<View className={'pt-1 text-xs text-orange-500 hidden'}>
{ADDRESS_CHANGE_COOLDOWN_DAYS}
{ticketAddressModifyLimit.nextAllowedText ? `${ticketAddressModifyLimit.nextAllowedText} 后可修改` : ''}
</View>
)}
</View>
</View>
</Space>
</Cell>
)
}
{!address && (
<Cell className={''} onClick={openAddressPage}>
<Space>
<Location/>
</Space>
</Cell>
)}
</CellGroup>
<CellGroup>
<Cell
title={'配送时间'}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
</View>
)}
/>
</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
? '加载中...'
: (ticketLoaded
? (noUsableTickets
? '暂无可用水票'
: `可用合计 ${availableTicketTotal}${usableTickets.length}组)`
)
: '点击查看'
)
}
</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
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 && (
<Cell
title={<Text className="text-gray-500"></Text>}
description="购买水票后即可在这里直接下单送水"
extra={(
<Button type="primary" size="small" onClick={goBuyTickets}>
</Button>
)}
/>
)}
<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>
{!!usableTickets.length && !ticketLoading && (
<View className="text-xs text-gray-500 mb-2">
<Text> {availableTicketTotal} 使</Text>
</View>
)}
{ticketLoading ? (
<View className="py-10 text-center text-gray-500">
<Text>...</Text>
</View>
) : (
<>
{!!usableTickets.length ? (
<CellGroup>
{ticketsToConsume.map((t) => {
return (
<Cell
key={t.id}
title={<Text> {t.id}</Text>}
description={t.orderNo ? `来源订单:${t.orderNo}` : ''}
extra={<Text className="text-gray-700"> {getTicketAvailableQty(t)}</Text>}
onClick={() => setTicketPopupVisible(false)}
/>
)
})}
</CellGroup>
) : (
<View className="py-10 text-center">
<Empty description="暂无可用水票" />
<View className="mt-4 flex justify-center">
<Button type="primary" onClick={goBuyTickets}>
</Button>
</View>
</View>
)}
</>
)}
</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)
storeManualPickedRef.current = true
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}/>
<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'}>
{noUsableTickets ? (
<Button type="primary" size="large" onClick={goBuyTickets}>
</Button>
) : (
<Button
type="success"
size="large"
loading={submitLoading || deliveryRangeChecking}
disabled={
deliveryRangeChecking ||
!address?.id ||
!addressHasCoords ||
(deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) ||
availableTicketTotal <= 0 ||
!canStartOrder
}
onClick={onSubmit}
>
{deliveryRangeChecking
? '校验配送范围...'
: (!address?.id
? '请选择地址'
: (!addressHasCoords
? '地址缺少定位'
: ((deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false)
? '不在配送范围'
: (submitLoading ? '提交中...' : '立即提交')
)
)
)
}
</Button>
)}
</div>
</View>
</div>
</div>
);
};
export default OrderConfirm;