forked from gxwebsoft/mp-10550
feat(ticket): 添加配送范围校验功能
- 集成电子围栏API,实现配送范围检查 - 添加地理围栏解析工具函数 - 实现坐标点在多边形内检测算法 - 添加位置权限检查和用户引导 - 优化订单提交流程,增加范围校验步骤 - 更新UI显示配送范围校验状态和结果
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user