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>

143
src/utils/geofence.ts Normal file
View File

@@ -0,0 +1,143 @@
export type LngLat = { lng: number; lat: number };
function normalizeLngLat(a: number, b: number): LngLat | null {
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
const looksLikeLngLat = Math.abs(a) <= 180 && Math.abs(b) <= 90;
const looksLikeLatLng = Math.abs(a) <= 90 && Math.abs(b) <= 180;
if (looksLikeLngLat) return { lng: a, lat: b };
if (looksLikeLatLng) return { lng: b, lat: a };
return null;
}
function parsePointLike(v: any): LngLat | null {
if (!v) return null;
if (Array.isArray(v) && v.length >= 2) {
return normalizeLngLat(Number(v[0]), Number(v[1]));
}
if (typeof v === 'object') {
// Try common field names from map libs / backends.
const a = v.lng ?? v.lon ?? v.longitude ?? v.x;
const b = v.lat ?? v.latitude ?? v.y;
if (a !== undefined && b !== undefined) {
return normalizeLngLat(Number(a), Number(b));
}
}
if (typeof v === 'string') {
return parseLngLatFromText(v);
}
return null;
}
export function parseLngLatFromText(raw: string | undefined): LngLat | null {
const text = (raw || '').trim();
if (!text) return null;
const parts = text.split(/[,\s]+/).filter(Boolean);
if (parts.length < 2) return null;
const a = parts[0];
const b = parts[1];
if (!a || !b) return null;
return normalizeLngLat(parseFloat(a), parseFloat(b));
}
/**
* Parse fence "points" into a polygon point list.
*
* Supported formats (best-effort):
* - JSON: [[lng,lat], ...] or [{lng,lat}, ...]
* - Delimited: "lng,lat;lng,lat;..." or "lng,lat|lng,lat|..."
* - Flat numbers: "lng,lat,lng,lat,..." (even count)
*/
export function parseFencePoints(pointsRaw: string | undefined): LngLat[] {
const text = (pointsRaw || '').trim();
if (!text) return [];
// 1) JSON-like.
if (text.startsWith('[') || text.startsWith('{')) {
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
const list = parsed.map(parsePointLike).filter(Boolean) as LngLat[];
if (list.length) return list;
// Some systems wrap coordinates like [[[lng,lat],...]].
if (Array.isArray(parsed[0])) {
const inner = (parsed[0] as any[]).map(parsePointLike).filter(Boolean) as LngLat[];
if (inner.length) return inner;
}
}
} catch (_e) {
// fall through
}
}
// 2) Split by common point separators.
const segments = text.split(/[;|\n\r]+/).map(s => s.trim()).filter(Boolean);
if (segments.length > 1) {
const list = segments.map(seg => {
const nums = seg.match(/-?\d+(\.\d+)?/g) || [];
if (nums.length < 2) return null;
const a = nums[0];
const b = nums[1];
if (!a || !b) return null;
return normalizeLngLat(parseFloat(a), parseFloat(b));
}).filter(Boolean) as LngLat[];
if (list.length) return list;
}
// 3) Fallback: grab all numbers and pair them.
const nums = text.match(/-?\d+(\.\d+)?/g) || [];
if (nums.length >= 6 && nums.length % 2 === 0) {
const list: LngLat[] = [];
for (let i = 0; i < nums.length; i += 2) {
const a = nums[i];
const b = nums[i + 1];
if (!a || !b) continue;
const p = normalizeLngLat(parseFloat(a), parseFloat(b));
if (p) list.push(p);
}
if (list.length) return list;
}
return [];
}
function pointOnSegment(p: LngLat, a: LngLat, b: LngLat, eps = 1e-9): boolean {
// Cross product must be near 0 and dot product within [0, |ab|^2]
const cross = (b.lat - a.lat) * (p.lng - a.lng) - (b.lng - a.lng) * (p.lat - a.lat);
if (Math.abs(cross) > eps) return false;
const dot = (p.lng - a.lng) * (b.lng - a.lng) + (p.lat - a.lat) * (b.lat - a.lat);
if (dot < -eps) return false;
const lenSq = (b.lng - a.lng) ** 2 + (b.lat - a.lat) ** 2;
return dot <= lenSq + eps;
}
// Ray-casting with boundary check; treats points on edges as inside.
export function pointInPolygon(p: LngLat, polygon: LngLat[]): boolean {
if (!polygon || polygon.length < 3) return false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[j];
const b = polygon[i];
if (pointOnSegment(p, a, b)) return true;
}
let inside = false;
const x = p.lng;
const y = p.lat;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].lng;
const yi = polygon[i].lat;
const xj = polygon[j].lng;
const yj = polygon[j].lat;
const intersect = (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
export function pointInAnyPolygon(p: LngLat, polygons: LngLat[][]): boolean {
for (const poly of polygons) {
if (pointInPolygon(p, poly)) return true;
}
return false;
}