diff --git a/src/user/address/add.tsx b/src/user/address/add.tsx index 9ec01f5..0f5c1e9 100644 --- a/src/user/address/add.tsx +++ b/src/user/address/add.tsx @@ -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 // 新增或编辑的地址都设为默认地址 }; diff --git a/src/user/address/index.tsx b/src/user/address/index.tsx index 9d1a10e..c2f2023 100644 --- a/src/user/address/index.tsx +++ b/src/user/address/index.tsx @@ -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([]) @@ -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() diff --git a/src/user/address/wxAddress.tsx b/src/user/address/wxAddress.tsx index 30fed0a..8fb32a3 100644 --- a/src/user/address/wxAddress.tsx +++ b/src/user/address/wxAddress.tsx @@ -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..') diff --git a/src/user/ticket/use.tsx b/src/user/ticket/use.tsx index 2fad2dd..f26f4d2 100644 --- a/src/user/ticket/use.tsx +++ b/src/user/ticket/use.tsx @@ -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([]) const [storeLoading, setStoreLoading] = useState(false) const [selectedStore, setSelectedStore] = useState(getSelectedStoreFromStorage()) + const storeAutoPickingRef = useRef(false) + const storeManualPickedRef = useRef(false) // 水票:用于“立即送水”下单(用水票抵扣,无需支付) const [tickets, setTickets] = useState([]) @@ -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 => { 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 => { + 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 => { + 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 => { + // 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 => { + 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'}) diff --git a/src/utils/location.ts b/src/utils/location.ts new file mode 100644 index 0000000..42bfd8d --- /dev/null +++ b/src/utils/location.ts @@ -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 { + 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 + } +}