Files
glt-taro/src/user/ticket/use.tsx
赵忠林 e40120138b refactor(ticket): 优化水票功能实现逻辑
- 移除手动选择水票功能,改为自动按数量少优先消耗
- 新增 ticketLoaded 状态跟踪水票加载完成情况
- 实现 getTicketAvailableQty 函数统一处理不同租户的可用数量字段差异
- 修改水票过滤逻辑,支持多种状态字段格式并改进商品ID匹配
- 更新下单流程,将单个订单拆分为多个水票订单以支持批量消耗
- 优化水票弹窗界面显示可用总数和消耗顺序说明
- 移除选中水票的相关状态管理和UI组件
- 更新下单确认提示显示优先使用数量少的水票策略
2026-02-26 13:23:17 +08:00

1060 lines
38 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 { 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 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 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)
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
const numericGoodsId = useMemo(() => {
const n = goodsId ? Number(goodsId) : undefined
return typeof n === 'number' && Number.isFinite(n) ? n : undefined
}, [goodsId])
const userId = useMemo(() => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}, [])
const 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 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 }> => {
// Prefer address coords (delivery location). Fallback to current GPS if address doesn't have coords.
const byAddress = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
if (byAddress) return byAddress
const loc = await Taro.getLocation({ type: 'gcj02' })
return { lng: loc.longitude, lat: loc.latitude }
}
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)
if (!ok) {
Taro.showToast({ title: '不在配送范围内,暂不支持下单', icon: 'none' })
}
return ok
} catch (e: any) {
console.error('配送范围校验失败:', e)
setInDeliveryRange(undefined)
const msg = String(e?.errMsg || e?.message || '')
const denied =
msg.includes('auth deny') ||
msg.includes('authorize') ||
msg.includes('permission') ||
msg.includes('denied') ||
msg.includes('scope.userLocation')
if (denied) {
const r = await Taro.showModal({
title: '需要定位权限',
content: '下单前需要校验是否在配送范围内,请在设置中开启定位权限后重试。',
confirmText: '去设置'
})
if (r.confirm) {
try {
await Taro.openSetting()
} catch (_e) {
// ignore
}
}
return false
}
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
}
// 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])
}
// 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 })
})
// 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 () => {
const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
if (!p) return
let ok = true
try {
ok = await isPointInFence(p)
} catch (_e) {
// Pre-check is best-effort; don't block UI here.
return
}
if (cancelled) return
setInDeliveryRange(ok)
})()
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address?.id, address?.lng, address?.lat])
// 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'}>
<Space>
<Location className={'text-gray-500'}/>
<View className={'flex flex-col w-full justify-between items-start'}>
<Space className={'flex flex-row w-full'}>
<View className={'flex-wrap text-nowrap whitespace-nowrap text-gray-500'}></View>
<View className={'font-medium text-sm flex items-center w-full'}>
<View
style={{width: '64%'}}>{address.province} {address.city} {address.region} {address.address}</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
</Space>
<View className={'pt-1 pb-3 text-gray-500'}>{address.name} {address.phone}</View>
</View>
</Space>
</Cell>
)
}
{!address && (
<Cell className={''} onClick={() => Taro.navigateTo({url: '/user/address/index'})}>
<Space>
<Location/>
</Space>
</Cell>
)}
</CellGroup>
<CellGroup>
<Cell
title={'配送时间'}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
</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 ||
inDeliveryRange === false ||
availableTicketTotal <= 0 ||
!canStartOrder
}
onClick={onSubmit}
>
{deliveryRangeChecking
? '校验配送范围...'
: (inDeliveryRange === false ? '不在配送范围' : (submitLoading ? '提交中...' : '立即提交'))
}
</Button>
)}
</div>
</View>
</div>
</div>
);
};
export default OrderConfirm;