feat(ticket): 添加配送范围校验功能

- 集成电子围栏API,实现配送范围检查
- 添加地理围栏解析工具函数
- 实现坐标点在多边形内检测算法
- 添加位置权限检查和用户引导
- 优化订单提交流程,增加范围校验步骤
- 更新UI显示配送范围校验状态和结果
This commit is contained in:
2026-02-09 11:16:23 +08:00
parent 1ce6381248
commit 94ed969d2d
2 changed files with 302 additions and 3 deletions

View File

@@ -27,6 +27,9 @@ import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/s
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
import { listGltUserTicket } from '@/api/glt/gltUserTicket'
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder'
import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
import { listShopStoreFence } from '@/api/shop/shopStoreFence'
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon } from '@/utils/geofence'
const MIN_START_QTY = 10
@@ -66,6 +69,15 @@ const OrderConfirm = () => {
const [ticketPopupVisible, setTicketPopupVisible] = useState(false)
const [ticketLoading, setTicketLoading] = useState(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(() => {
@@ -123,6 +135,114 @@ const OrderConfirm = () => {
return dayjs(sendTime).format('YYYY-MM-DD')
}, [sendTime])
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 () => {
if (storeLoading) return
try {
@@ -179,6 +299,7 @@ const OrderConfirm = () => {
const onSubmit = async () => {
if (submitLoading) return
if (deliveryRangeCheckingRef.current) return
if (!goods?.goodsId) return
// 基础校验
@@ -225,6 +346,10 @@ const OrderConfirm = () => {
return
}
// 配送范围校验(电子围栏)
const ok = await ensureInDeliveryRange()
if (!ok) return
const confirmRes = await Taro.showModal({
title: '确认下单',
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单,送水 ${finalQty} 桶,是否确认?`
@@ -307,6 +432,28 @@ const OrderConfirm = () => {
loadAllData({ silent: hasInitialLoadedRef.current })
})
// 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 => {
@@ -618,11 +765,20 @@ const OrderConfirm = () => {
<Button
type="success"
size="large"
loading={submitLoading}
disabled={!selectedTicket?.id || availableTicketTotal <= 0 || !canStartOrder}
loading={submitLoading || deliveryRangeChecking}
disabled={
deliveryRangeChecking ||
inDeliveryRange === false ||
!selectedTicket?.id ||
availableTicketTotal <= 0 ||
!canStartOrder
}
onClick={onSubmit}
>
{submitLoading ? '提交中...' : '立即提交'}
{deliveryRangeChecking
? '校验配送范围...'
: (inDeliveryRange === false ? '不在配送范围' : (submitLoading ? '提交中...' : '立即提交'))
}
</Button>
</div>
</View>