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>
|
||||
|
||||
143
src/utils/geofence.ts
Normal file
143
src/utils/geofence.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user