From 94ed969d2d603d0774326818a716a7f9323c8ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Mon, 9 Feb 2026 11:16:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(ticket):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E9=80=81=E8=8C=83=E5=9B=B4=E6=A0=A1=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 集成电子围栏API,实现配送范围检查 - 添加地理围栏解析工具函数 - 实现坐标点在多边形内检测算法 - 添加位置权限检查和用户引导 - 优化订单提交流程,增加范围校验步骤 - 更新UI显示配送范围校验状态和结果 --- src/user/ticket/use.tsx | 162 +++++++++++++++++++++++++++++++++++++++- src/utils/geofence.ts | 143 +++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 src/utils/geofence.ts diff --git a/src/user/ticket/use.tsx b/src/user/ticket/use.tsx index 02e0b13..2fad2dd 100644 --- a/src/user/ticket/use.tsx +++ b/src/user/ticket/use.tsx @@ -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([]) + const fencesLoadedRef = useRef(false) + const fencesPromiseRef = useRef | null>(null) + const fencesErrorRef = useRef(null) + const [deliveryRangeChecking, setDeliveryRangeChecking] = useState(false) + const deliveryRangeCheckingRef = useRef(false) + const [inDeliveryRange, setInDeliveryRange] = useState(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 => { + 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 => { + 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 => { + 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 = () => { diff --git a/src/utils/geofence.ts b/src/utils/geofence.ts new file mode 100644 index 0000000..0dc11fe --- /dev/null +++ b/src/utils/geofence.ts @@ -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; +}