feat(address): 添加用户地址管理页面的定位功能 - 集成 Taro 地图选点功能,支持用户手动选择精确位置 - 新增地理位置权限处理逻辑,包括授权失败和用户取消的情况 - 实现经纬度信息在地址表单中的存储和回显功能 - 添加定位选择界面,展示已选择的位置信息 - 移除原有的实时定位功能,改为手动选择模式 - 更新地址表单提交逻辑以支持新的定位数据结构 ```
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
import {useEffect, useState} from "react";
|
||
import Taro from '@tarojs/taro';
|
||
import {Button, Popup, Cell, CellGroup} from '@nutui/nutui-react-taro'
|
||
// import {TriangleDown} from '@nutui/icons-react-taro'
|
||
import { NavBar} from '@nutui/nutui-react-taro'
|
||
import {getUserInfo, getWxOpenId} from "@/api/layout";
|
||
import {TenantId, TenantName} from "@/config/app";
|
||
import {getOrganization} from "@/api/system/organization";
|
||
import {myUserVerify} from "@/api/system/userVerify";
|
||
// import { useShopInfo } from '@/hooks/useShopInfo';
|
||
import {handleInviteRelation, getStoredInviteParams} from "@/utils/invite";
|
||
import {View,Text} from '@tarojs/components'
|
||
import MySearch from "./MySearch";
|
||
import './Header.scss';
|
||
import {User} from "@/api/system/user/model";
|
||
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
|
||
import type {ShopStore} from "@/api/shop/shopStore/model";
|
||
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
|
||
|
||
const Header = (_: any) => {
|
||
// 使用新的useShopInfo Hook
|
||
// const {
|
||
// getWebsiteLogo
|
||
// } = useShopInfo();
|
||
|
||
const [IsLogin, setIsLogin] = useState<boolean>(true)
|
||
const [statusBarHeight, setStatusBarHeight] = useState<number>()
|
||
const [userInfo] = useState<User>()
|
||
|
||
// 门店选择:用于首页展示“最近门店”,并在下单时写入订单 storeId
|
||
const [storePopupVisible, setStorePopupVisible] = useState(false)
|
||
const [stores, setStores] = useState<ShopStore[]>([])
|
||
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
|
||
const [userLocation, setUserLocation] = useState<{lng: number; lat: number} | null>(null)
|
||
|
||
const getTenantName = () => {
|
||
return userInfo?.tenantName || TenantName
|
||
}
|
||
|
||
const parseStoreCoords = (s: ShopStore): {lng: number; lat: number} | null => {
|
||
const raw = (s.lngAndLat || s.location || '').trim()
|
||
if (!raw) return null
|
||
|
||
const parts = raw.split(/[,\s]+/).filter(Boolean)
|
||
if (parts.length < 2) return null
|
||
|
||
const a = parseFloat(parts[0])
|
||
const b = parseFloat(parts[1])
|
||
if (Number.isNaN(a) || Number.isNaN(b)) return null
|
||
|
||
// 常见格式是 "lng,lat";这里做一个简单兜底(经度范围更宽)
|
||
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
|
||
}
|
||
|
||
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 formatDistance = (meters?: number) => {
|
||
if (meters === undefined || Number.isNaN(meters)) return ''
|
||
if (meters < 1000) return `${Math.round(meters)}m`
|
||
return `${(meters / 1000).toFixed(1)}km`
|
||
}
|
||
|
||
const getStoreDistance = (s: ShopStore) => {
|
||
if (!userLocation) return undefined
|
||
const coords = parseStoreCoords(s)
|
||
if (!coords) return undefined
|
||
return distanceMeters(userLocation, coords)
|
||
}
|
||
|
||
const initStoreSelection = async () => {
|
||
// 先读取本地已选门店,避免页面首屏抖动
|
||
const stored = getSelectedStoreFromStorage()
|
||
if (stored?.id) {
|
||
setSelectedStore(stored)
|
||
}
|
||
|
||
// 拉取门店列表(失败时允许用户手动重试/继续使用本地门店)
|
||
let list: ShopStore[] = []
|
||
try {
|
||
list = await listShopStore()
|
||
} catch (e) {
|
||
console.error('获取门店列表失败:', e)
|
||
list = []
|
||
}
|
||
const usable = (list || []).filter(s => s?.isDelete !== 1)
|
||
setStores(usable)
|
||
|
||
// 尝试获取定位,用于计算最近门店
|
||
let loc: {lng: number; lat: number} | null = null
|
||
try {
|
||
const r = await Taro.getLocation({type: 'gcj02'})
|
||
loc = {lng: r.longitude, lat: r.latitude}
|
||
} catch (e) {
|
||
// 不强制定位授权;无定位时仍允许用户手动选择
|
||
console.warn('获取定位失败,将不显示最近门店距离:', e)
|
||
}
|
||
setUserLocation(loc)
|
||
|
||
const ensureStoreDetail = async (s: ShopStore) => {
|
||
if (!s?.id) return s
|
||
// 如果后端已经返回默认仓库等字段,就不额外请求
|
||
if (s.warehouseId) return s
|
||
try {
|
||
const full = await getShopStore(s.id)
|
||
return full || s
|
||
} catch (_e) {
|
||
return s
|
||
}
|
||
}
|
||
|
||
// 若用户没有选过门店,则自动选择最近门店(或第一个)
|
||
const alreadySelected = stored?.id
|
||
if (alreadySelected || usable.length === 0) return
|
||
|
||
let autoPick: ShopStore | undefined
|
||
if (loc) {
|
||
autoPick = [...usable]
|
||
.map(s => {
|
||
const coords = parseStoreCoords(s)
|
||
const d = coords ? distanceMeters(loc, coords) : undefined
|
||
return {s, d}
|
||
})
|
||
.sort((x, y) => (x.d ?? Number.POSITIVE_INFINITY) - (y.d ?? Number.POSITIVE_INFINITY))[0]?.s
|
||
} else {
|
||
autoPick = usable[0]
|
||
}
|
||
|
||
if (autoPick?.id) {
|
||
const full = await ensureStoreDetail(autoPick)
|
||
setSelectedStore(full)
|
||
saveSelectedStoreToStorage(full)
|
||
}
|
||
}
|
||
|
||
const reload = async () => {
|
||
Taro.getSystemInfo({
|
||
success: (res) => {
|
||
setStatusBarHeight(res.statusBarHeight)
|
||
},
|
||
})
|
||
// 注意:商店信息现在通过useShopInfo自动管理,不需要手动获取
|
||
// 获取用户信息
|
||
getUserInfo().then((data) => {
|
||
if (data) {
|
||
setIsLogin(true);
|
||
console.log('用户信息>>>', data.phone)
|
||
// 保存userId
|
||
Taro.setStorageSync('UserId', data.userId)
|
||
// 获取openId
|
||
if (!data.openid) {
|
||
Taro.login({
|
||
success: (res) => {
|
||
getWxOpenId({code: res.code}).then(() => {
|
||
})
|
||
}
|
||
})
|
||
}
|
||
// 是否已认证
|
||
if (data.certification) {
|
||
Taro.setStorageSync('Certification', '1')
|
||
}
|
||
// 机构ID
|
||
Taro.setStorageSync('OrganizationId', data.organizationId)
|
||
// 父级机构ID
|
||
if (Number(data.organizationId) > 0) {
|
||
getOrganization(Number(data.organizationId)).then(res => {
|
||
Taro.setStorageSync('OrganizationParentId', res.parentId)
|
||
})
|
||
}
|
||
// 管理员
|
||
const isKdy = data.roles?.findIndex(item => item.roleCode == 'admin')
|
||
if (isKdy != -1) {
|
||
Taro.setStorageSync('RoleName', '管理')
|
||
Taro.setStorageSync('RoleCode', 'admin')
|
||
return false;
|
||
}
|
||
// 注册用户
|
||
const isUser = data.roles?.findIndex(item => item.roleCode == 'user')
|
||
if (isUser != -1) {
|
||
Taro.setStorageSync('RoleName', '注册用户')
|
||
Taro.setStorageSync('RoleCode', 'user')
|
||
return false;
|
||
}
|
||
}
|
||
}).catch(() => {
|
||
setIsLogin(false);
|
||
console.log('未登录')
|
||
});
|
||
// 认证信息
|
||
myUserVerify({status: 1}).then(data => {
|
||
if (data?.realName) {
|
||
Taro.setStorageSync('RealName', data.realName)
|
||
}
|
||
})
|
||
}
|
||
|
||
/* 获取用户手机号 */
|
||
const handleGetPhoneNumber = ({detail}: {detail: {code?: string, encryptedData?: string, iv?: string}}) => {
|
||
const {code, encryptedData, iv} = detail
|
||
|
||
// 防重复登录检查
|
||
const loginKey = 'login_in_progress'
|
||
const loginInProgress = Taro.getStorageSync(loginKey)
|
||
|
||
if (loginInProgress && Date.now() - loginInProgress < 5000) { // 5秒内防重
|
||
return
|
||
}
|
||
|
||
// 标记登录开始
|
||
Taro.setStorageSync(loginKey, Date.now())
|
||
|
||
// 获取存储的邀请参数
|
||
const inviteParams = getStoredInviteParams()
|
||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter) : 0
|
||
|
||
Taro.login({
|
||
success: function () {
|
||
if (code) {
|
||
Taro.request({
|
||
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
|
||
method: 'POST',
|
||
data: {
|
||
code,
|
||
encryptedData,
|
||
iv,
|
||
notVerifyPhone: true,
|
||
refereeId: refereeId, // 使用解析出的推荐人ID
|
||
sceneType: 'save_referee',
|
||
tenantId: TenantId
|
||
},
|
||
header: {
|
||
'content-type': 'application/json',
|
||
TenantId
|
||
},
|
||
success: async function (res) {
|
||
// 清除登录防重标记
|
||
Taro.removeStorageSync('login_in_progress')
|
||
|
||
if (res.data.code == 1) {
|
||
Taro.showToast({
|
||
title: res.data.message,
|
||
icon: 'error',
|
||
duration: 2000
|
||
})
|
||
return false;
|
||
}
|
||
// 登录成功
|
||
Taro.setStorageSync('access_token', res.data.data.access_token)
|
||
Taro.setStorageSync('UserId', res.data.data.user.userId)
|
||
setIsLogin(true)
|
||
|
||
// 处理邀请关系
|
||
if (res.data.data.user?.userId) {
|
||
try {
|
||
await handleInviteRelation(res.data.data.user.userId)
|
||
} catch (error) {
|
||
console.error('处理邀请关系失败:', error)
|
||
}
|
||
}
|
||
|
||
// 重新加载小程序
|
||
Taro.reLaunch({
|
||
url: '/pages/index/index'
|
||
})
|
||
},
|
||
fail: function() {
|
||
// 清除登录防重标记
|
||
Taro.removeStorageSync('login_in_progress')
|
||
}
|
||
})
|
||
} else {
|
||
console.log('登录失败!')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 获取小程序系统信息
|
||
// const getSystemInfo = () => {
|
||
// const systemInfo = Taro.getSystemInfoSync()
|
||
// // 状态栏高度 + 导航栏高度 (一般为44px)
|
||
// return (systemInfo.statusBarHeight || 0) + 44
|
||
// }
|
||
|
||
useEffect(() => {
|
||
reload().then()
|
||
initStoreSelection().then()
|
||
}, [])
|
||
|
||
return (
|
||
<>
|
||
<View
|
||
className={'header-bg'}
|
||
style={{
|
||
height: '180px',
|
||
paddingBottom: '12px',
|
||
}}
|
||
>
|
||
<MySearch statusBarHeight={statusBarHeight} />
|
||
</View>
|
||
|
||
<NavBar
|
||
style={{
|
||
marginTop: `${statusBarHeight}px`,
|
||
marginBottom: '0px',
|
||
backgroundColor: 'transparent'
|
||
}}
|
||
onBackClick={() => {
|
||
}}
|
||
// left={
|
||
// <View
|
||
// style={{display: 'flex', alignItems: 'center', gap: '8px'}}
|
||
// onClick={() => setStorePopupVisible(true)}
|
||
// >
|
||
// <Avatar
|
||
// size="22"
|
||
// src={getWebsiteLogo()}
|
||
// />
|
||
// <Text className={'text-white'}>
|
||
// {selectedStore?.name || '请选择门店'}
|
||
// </Text>
|
||
// <TriangleDown className={'text-white'} size={9}/>
|
||
// </View>
|
||
// }
|
||
right={
|
||
!IsLogin ? (
|
||
<Button
|
||
size="small"
|
||
fill="none"
|
||
style={{color: '#ffffff'}}
|
||
open-type="getPhoneNumber"
|
||
onGetPhoneNumber={handleGetPhoneNumber}
|
||
>
|
||
登录
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
<Text className={'text-white'}>{getTenantName()}</Text>
|
||
</NavBar>
|
||
|
||
<Popup
|
||
visible={storePopupVisible}
|
||
position="bottom"
|
||
style={{height: '70vh'}}
|
||
onClose={() => setStorePopupVisible(false)}
|
||
>
|
||
<View className="p-4">
|
||
<View className="flex justify-between items-center mb-3">
|
||
<Text className="text-base font-medium">选择门店</Text>
|
||
<Text
|
||
className="text-sm text-gray-500"
|
||
onClick={() => setStorePopupVisible(false)}
|
||
>
|
||
关闭
|
||
</Text>
|
||
</View>
|
||
|
||
<View className="text-xs text-gray-500 mb-2">
|
||
{userLocation ? '已获取定位,按距离排序' : '未获取定位,可手动选择门店'}
|
||
</View>
|
||
|
||
<CellGroup>
|
||
{[...stores]
|
||
.sort((a, b) => (getStoreDistance(a) ?? Number.POSITIVE_INFINITY) - (getStoreDistance(b) ?? Number.POSITIVE_INFINITY))
|
||
.map((s) => {
|
||
const d = getStoreDistance(s)
|
||
const isActive = !!selectedStore?.id && selectedStore.id === s.id
|
||
return (
|
||
<Cell
|
||
key={s.id}
|
||
title={
|
||
<View className="flex items-center justify-between">
|
||
<Text className={isActive ? 'text-green-600' : ''}>{s.name || `门店${s.id}`}</Text>
|
||
{d !== undefined && <Text className="text-xs text-gray-500">{formatDistance(d)}</Text>}
|
||
</View>
|
||
}
|
||
description={s.address || ''}
|
||
onClick={async () => {
|
||
let storeToSave = s
|
||
if (s?.id) {
|
||
try {
|
||
const full = await getShopStore(s.id)
|
||
if (full) storeToSave = full
|
||
} catch (_e) {
|
||
// keep base item
|
||
}
|
||
}
|
||
setSelectedStore(storeToSave)
|
||
saveSelectedStoreToStorage(storeToSave)
|
||
setStorePopupVisible(false)
|
||
Taro.showToast({title: '门店已切换', icon: 'success'})
|
||
}}
|
||
/>
|
||
)
|
||
})}
|
||
</CellGroup>
|
||
</View>
|
||
</Popup>
|
||
</>
|
||
)
|
||
}
|
||
export default Header
|