feat(address): 添加地理位置获取功能并优化门店自动分配逻辑
- 集成 getCurrentLngLat 工具函数用于获取用户当前位置 - 在添加地址时自动获取并存储经纬度信息 - 在设置默认地址时更新位置信息 - 实现基于地理位置的门店自动分配算法 - 添加距离计算和多边形区域判断功能 - 优化送水订单的门店和配送员自动匹配逻辑 - 在微信地址导入时集成位置信息获取 - 添加位置权限处理和用户引导机制
This commit is contained in:
@@ -9,6 +9,7 @@ import {ShopUserAddress} from "@/api/shop/shopUserAddress/model";
|
||||
import {getShopUserAddress, listShopUserAddress, updateShopUserAddress, addShopUserAddress} from "@/api/shop/shopUserAddress";
|
||||
import RegionData from '@/api/json/regions-data.json';
|
||||
import FixedButton from "@/components/FixedButton";
|
||||
import { getCurrentLngLat } from "@/utils/location";
|
||||
|
||||
const AddUserAddress = () => {
|
||||
const {params} = useRouter();
|
||||
@@ -212,6 +213,9 @@ const AddUserAddress = () => {
|
||||
|
||||
// 提交表单
|
||||
const submitSucceed = async (values: any) => {
|
||||
const loc = await getCurrentLngLat()
|
||||
if (!loc) return
|
||||
|
||||
try {
|
||||
// 准备提交的数据
|
||||
const submitData = {
|
||||
@@ -219,6 +223,8 @@ const AddUserAddress = () => {
|
||||
province: FormData.province,
|
||||
city: FormData.city,
|
||||
region: FormData.region,
|
||||
lng: loc.lng,
|
||||
lat: loc.lat,
|
||||
isDefault: true // 新增或编辑的地址都设为默认地址
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {ShopUserAddress} from "@/api/shop/shopUserAddress/model";
|
||||
import {listShopUserAddress, removeShopUserAddress, updateShopUserAddress} from "@/api/shop/shopUserAddress";
|
||||
import FixedButton from "@/components/FixedButton";
|
||||
import dayjs from "dayjs";
|
||||
import { getCurrentLngLat } from "@/utils/location";
|
||||
|
||||
const Address = () => {
|
||||
const [list, setList] = useState<ShopUserAddress[]>([])
|
||||
@@ -58,6 +59,9 @@ const Address = () => {
|
||||
}
|
||||
|
||||
const onDefault = async (item: ShopUserAddress) => {
|
||||
const loc = await getCurrentLngLat()
|
||||
if (!loc) return
|
||||
|
||||
if (address) {
|
||||
await updateShopUserAddress({
|
||||
...address,
|
||||
@@ -65,8 +69,10 @@ const Address = () => {
|
||||
})
|
||||
}
|
||||
await updateShopUserAddress({
|
||||
id: item.id,
|
||||
isDefault: true
|
||||
...item,
|
||||
isDefault: true,
|
||||
lng: loc.lng,
|
||||
lat: loc.lat,
|
||||
})
|
||||
Taro.showToast({
|
||||
title: '设置成功',
|
||||
@@ -85,6 +91,9 @@ const Address = () => {
|
||||
}
|
||||
|
||||
const selectAddress = async (item: ShopUserAddress) => {
|
||||
const loc = await getCurrentLngLat()
|
||||
if (!loc) return
|
||||
|
||||
if (address) {
|
||||
await updateShopUserAddress({
|
||||
...address,
|
||||
@@ -92,8 +101,10 @@ const Address = () => {
|
||||
})
|
||||
}
|
||||
await updateShopUserAddress({
|
||||
id: item.id,
|
||||
isDefault: true
|
||||
...item,
|
||||
isDefault: true,
|
||||
lng: loc.lng,
|
||||
lat: loc.lat,
|
||||
})
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useEffect} from "react";
|
||||
import Taro from '@tarojs/taro'
|
||||
import {addShopUserAddress} from "@/api/shop/shopUserAddress";
|
||||
import { getCurrentLngLat } from "@/utils/location";
|
||||
|
||||
const WxAddress = () => {
|
||||
/**
|
||||
@@ -9,7 +10,14 @@ const WxAddress = () => {
|
||||
*/
|
||||
const getWeChatAddress = () => {
|
||||
Taro.chooseAddress()
|
||||
.then(res => {
|
||||
.then(async res => {
|
||||
const loc = await getCurrentLngLat()
|
||||
if (!loc) {
|
||||
// Avoid leaving the user on an empty page.
|
||||
setTimeout(() => Taro.navigateBack(), 300)
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化微信返回的地址数据为后端所需格式
|
||||
const addressData = {
|
||||
name: res.userName,
|
||||
@@ -20,6 +28,8 @@ const WxAddress = () => {
|
||||
region: res.countyName,
|
||||
address: res.detailInfo,
|
||||
postalCode: res.postalCode,
|
||||
lng: loc.lng,
|
||||
lat: loc.lat,
|
||||
isDefault: false
|
||||
}
|
||||
console.log(res, 'addrs..')
|
||||
|
||||
@@ -27,9 +27,11 @@ 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 { ShopStoreRider } from '@/api/shop/shopStoreRider/model'
|
||||
import { listShopStoreRider } from '@/api/shop/shopStoreRider'
|
||||
import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
|
||||
import { listShopStoreFence } from '@/api/shop/shopStoreFence'
|
||||
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon } from '@/utils/geofence'
|
||||
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence'
|
||||
|
||||
const MIN_START_QTY = 10
|
||||
|
||||
@@ -62,6 +64,8 @@ const OrderConfirm = () => {
|
||||
const [stores, setStores] = useState<ShopStore[]>([])
|
||||
const [storeLoading, setStoreLoading] = useState(false)
|
||||
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
|
||||
const storeAutoPickingRef = useRef(false)
|
||||
const storeManualPickedRef = useRef(false)
|
||||
|
||||
// 水票:用于“立即送水”下单(用水票抵扣,无需支付)
|
||||
const [tickets, setTickets] = useState<GltUserTicket[]>([])
|
||||
@@ -135,6 +139,23 @@ const OrderConfirm = () => {
|
||||
return dayjs(sendTime).format('YYYY-MM-DD')
|
||||
}, [sendTime])
|
||||
|
||||
const distanceMeters = (a: { lng: number; lat: number }, b: { lng: number; lat: number }) => {
|
||||
const toRad = (x: number) => (x * Math.PI) / 180
|
||||
const R = 6371000 // meters
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLng = toRad(b.lng - a.lng)
|
||||
const lat1 = toRad(a.lat)
|
||||
const lat2 = toRad(b.lat)
|
||||
const sin1 = Math.sin(dLat / 2)
|
||||
const sin2 = Math.sin(dLng / 2)
|
||||
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)))
|
||||
}
|
||||
|
||||
const parseStoreCoords = (s: ShopStore) => {
|
||||
return parseLngLatFromText((s.lngAndLat || s.location || '').trim())
|
||||
}
|
||||
|
||||
const loadFences = async (): Promise<ShopStoreFence[]> => {
|
||||
if (fencesLoadedRef.current) return fences
|
||||
if (fencesPromiseRef.current) return fencesPromiseRef.current
|
||||
@@ -243,21 +264,133 @@ const OrderConfirm = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadStores = async () => {
|
||||
if (storeLoading) return
|
||||
const loadStores = async (): Promise<ShopStore[]> => {
|
||||
if (storeLoading) return stores
|
||||
try {
|
||||
setStoreLoading(true)
|
||||
const list = await listShopStore()
|
||||
setStores((list || []).filter(s => s?.isDelete !== 1))
|
||||
const usable = (list || []).filter(s => s?.isDelete !== 1)
|
||||
setStores(usable)
|
||||
return usable
|
||||
} catch (e) {
|
||||
console.error('获取门店列表失败:', e)
|
||||
setStores([])
|
||||
Taro.showToast({title: '获取门店列表失败', icon: 'none'})
|
||||
return []
|
||||
} finally {
|
||||
setStoreLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ensureStoreDetail = async (s: ShopStore): Promise<ShopStore> => {
|
||||
if (!s?.id) return s
|
||||
// If backend already returned "delivery area polygon"/warehouse info, skip extra request.
|
||||
if (s.points || s.warehouseId) return s
|
||||
try {
|
||||
const full = await getShopStore(s.id)
|
||||
return full || s
|
||||
} catch (_e) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
const pickNearestStore = async (p: { lng: number; lat: number }, list: ShopStore[]) => {
|
||||
const usable = (list || []).filter(s => s?.isDelete !== 1)
|
||||
if (!usable.length) return null
|
||||
|
||||
// 1) If a store has a polygon (points) and the delivery point is inside, prefer those stores.
|
||||
const inside: { s: ShopStore; d?: number }[] = []
|
||||
const outside: { s: ShopStore; d?: number }[] = []
|
||||
for (const s of usable) {
|
||||
const poly = parseFencePoints(s?.points)
|
||||
const isInside = poly.length >= 3 ? pointInPolygon(p, poly) : false
|
||||
const coords = parseStoreCoords(s)
|
||||
const d = coords ? distanceMeters(p, coords) : undefined
|
||||
;(isInside ? inside : outside).push({ s, d })
|
||||
}
|
||||
|
||||
const sortByDistanceThenSortNo = (a: { s: ShopStore; d?: number }, b: { s: ShopStore; d?: number }) => {
|
||||
const da = a.d ?? Number.POSITIVE_INFINITY
|
||||
const db = b.d ?? Number.POSITIVE_INFINITY
|
||||
if (da !== db) return da - db
|
||||
const sa = a.s.sortNumber ?? Number.POSITIVE_INFINITY
|
||||
const sb = b.s.sortNumber ?? Number.POSITIVE_INFINITY
|
||||
if (sa !== sb) return sa - sb
|
||||
return (a.s.id || 0) - (b.s.id || 0)
|
||||
}
|
||||
|
||||
const best = (inside.length ? inside.sort(sortByDistanceThenSortNo) : outside.sort(sortByDistanceThenSortNo))[0]?.s
|
||||
if (!best) return null
|
||||
return ensureStoreDetail(best)
|
||||
}
|
||||
|
||||
const resolveStoreForOrder = async (opts?: { silent?: boolean }): Promise<ShopStore | null> => {
|
||||
// If user already picked a store on this page, don't auto override.
|
||||
if (storeManualPickedRef.current && selectedStore?.id) return selectedStore
|
||||
|
||||
const p = parseLngLatFromText(`${address?.lng || ''},${address?.lat || ''}`)
|
||||
if (!p) {
|
||||
// If we already have a selected store, allow ordering to continue (can't auto-pick though).
|
||||
if (!opts?.silent && !selectedStore?.id) {
|
||||
Taro.showToast({ title: '该地址缺少经纬度,无法自动分配门店,请在地址里选择地图定位后重试', icon: 'none' })
|
||||
}
|
||||
return selectedStore?.id ? selectedStore : null
|
||||
}
|
||||
|
||||
if (storeAutoPickingRef.current) return selectedStore?.id ? selectedStore : null
|
||||
storeAutoPickingRef.current = true
|
||||
try {
|
||||
const list = stores.length ? stores : await loadStores()
|
||||
|
||||
// If a store is already selected and its polygon contains the point, keep it.
|
||||
if (selectedStore?.id) {
|
||||
const full = await ensureStoreDetail(selectedStore)
|
||||
const poly = parseFencePoints(full?.points)
|
||||
if (poly.length >= 3 && pointInPolygon(p, poly)) {
|
||||
if (full !== selectedStore) {
|
||||
setSelectedStore(full)
|
||||
saveSelectedStoreToStorage(full)
|
||||
}
|
||||
return full
|
||||
}
|
||||
}
|
||||
|
||||
const best = await pickNearestStore(p, list)
|
||||
if (best?.id) {
|
||||
setSelectedStore(best)
|
||||
saveSelectedStoreToStorage(best)
|
||||
return best
|
||||
}
|
||||
return selectedStore?.id ? selectedStore : null
|
||||
} finally {
|
||||
storeAutoPickingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveAutoRiderForStore = async (storeId: number): Promise<ShopStoreRider | null> => {
|
||||
try {
|
||||
const list = await listShopStoreRider({ storeId, status: 1 })
|
||||
const usable = (list || [])
|
||||
.filter(r => r?.isDelete !== 1)
|
||||
// Prefer enabled/online/auto-dispatch riders; fallback to any usable rider.
|
||||
.sort((a, b) => {
|
||||
const score = (r: ShopStoreRider) => {
|
||||
const enabled = r.status === 1 ? 1000 : 0
|
||||
const online = r.workStatus === 1 ? 100 : (r.workStatus === 2 ? 50 : 0)
|
||||
const auto = r.autoDispatchEnabled === 1 ? 10 : 0
|
||||
const prio = Number(r.dispatchPriority || 0)
|
||||
return enabled + online + auto + prio
|
||||
}
|
||||
return score(b) - score(a)
|
||||
})
|
||||
|
||||
return usable[0] || null
|
||||
} catch (e) {
|
||||
console.warn('自动获取配送员失败,将由后台/门店人工派单:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const openStorePopup = async () => {
|
||||
setStorePopupVisible(true)
|
||||
@@ -307,14 +440,16 @@ const OrderConfirm = () => {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!selectedStore?.id) {
|
||||
Taro.showToast({ title: '请选择门店', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!address?.id) {
|
||||
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const storeForOrder = await resolveStoreForOrder()
|
||||
if (!storeForOrder?.id) {
|
||||
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!selectedTicket?.id) {
|
||||
Taro.showToast({ title: '请选择水票', icon: 'none' })
|
||||
return
|
||||
@@ -360,15 +495,21 @@ const OrderConfirm = () => {
|
||||
setSubmitLoading(true)
|
||||
Taro.showLoading({ title: '提交中...' })
|
||||
|
||||
// Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
|
||||
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
|
||||
|
||||
await addGltTicketOrder({
|
||||
userTicketId: selectedTicket.id,
|
||||
storeId: selectedStore.id,
|
||||
storeId: storeForOrder.id,
|
||||
addressId: address.id,
|
||||
totalNum: finalQty,
|
||||
buyerRemarks: orderRemark,
|
||||
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
// Backend may take userId from token; pass-through is harmless if backend ignores it.
|
||||
userId,
|
||||
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined,
|
||||
riderName: autoRider?.realName,
|
||||
riderPhone: autoRider?.mobile,
|
||||
comments: goods.name ? `立即送水:${goods.name}` : '立即送水'
|
||||
})
|
||||
|
||||
@@ -432,6 +573,14 @@ const OrderConfirm = () => {
|
||||
loadAllData({ silent: hasInitialLoadedRef.current })
|
||||
})
|
||||
|
||||
// Auto-pick nearest store by delivery address (best-effort, won't override manual selection).
|
||||
useEffect(() => {
|
||||
if (!address?.id) return
|
||||
if (storeManualPickedRef.current) return
|
||||
resolveStoreForOrder({ silent: true })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address?.id, address?.lng, address?.lat])
|
||||
|
||||
// Background pre-check when we already have an address coordinate (no permission prompt).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -716,6 +865,7 @@ const OrderConfirm = () => {
|
||||
}
|
||||
}
|
||||
setSelectedStore(storeToSave)
|
||||
storeManualPickedRef.current = true
|
||||
saveSelectedStoreToStorage(storeToSave)
|
||||
setStorePopupVisible(false)
|
||||
Taro.showToast({title: '门店已切换', icon: 'success'})
|
||||
|
||||
53
src/utils/location.ts
Normal file
53
src/utils/location.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
export type LngLat = { lng: string; lat: string }
|
||||
|
||||
const isLocationDenied = (e: any) => {
|
||||
const msg = String(e?.errMsg || e?.message || e || '')
|
||||
return (
|
||||
msg.includes('auth deny') ||
|
||||
msg.includes('authorize') ||
|
||||
msg.includes('permission') ||
|
||||
msg.includes('denied') ||
|
||||
msg.includes('scope.userLocation')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: tries to fetch current GPS location (gcj02).
|
||||
* - Returns null on failure.
|
||||
* - If denied, it prompts user to open settings.
|
||||
*/
|
||||
export async function getCurrentLngLat(purpose = '保存地址需要获取您的定位信息,请在设置中开启定位权限后重试。'): Promise<LngLat | null> {
|
||||
try {
|
||||
const r = await Taro.getLocation({ type: 'gcj02' })
|
||||
return { lng: String(r.longitude), lat: String(r.latitude) }
|
||||
} catch (e: any) {
|
||||
console.warn('获取定位失败:', e)
|
||||
if (isLocationDenied(e)) {
|
||||
try {
|
||||
const modal = await Taro.showModal({
|
||||
title: '需要定位权限',
|
||||
content: purpose,
|
||||
confirmText: '去设置'
|
||||
})
|
||||
if (modal.confirm) {
|
||||
await Taro.openSetting()
|
||||
// User may have toggled permission; try once again.
|
||||
const r = await Taro.getLocation({ type: 'gcj02' })
|
||||
return { lng: String(r.longitude), lat: String(r.latitude) }
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
await Taro.showToast({ title: '获取定位失败', icon: 'none' })
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user