feat(address): 添加地理位置获取功能并优化门店自动分配逻辑

- 集成 getCurrentLngLat 工具函数用于获取用户当前位置
- 在添加地址时自动获取并存储经纬度信息
- 在设置默认地址时更新位置信息
- 实现基于地理位置的门店自动分配算法
- 添加距离计算和多边形区域判断功能
- 优化送水订单的门店和配送员自动匹配逻辑
- 在微信地址导入时集成位置信息获取
- 添加位置权限处理和用户引导机制
This commit is contained in:
2026-02-09 15:09:27 +08:00
parent 94ed969d2d
commit 231723e960
5 changed files with 244 additions and 14 deletions

View File

@@ -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 // 新增或编辑的地址都设为默认地址
};

View File

@@ -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()

View File

@@ -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..')

View File

@@ -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'})