Files
glt-taro/src/pages/index/Header.tsx
赵忠林 8d2188b928 ```
feat(address): 添加用户地址管理页面的定位功能

- 集成 Taro 地图选点功能,支持用户手动选择精确位置
- 新增地理位置权限处理逻辑,包括授权失败和用户取消的情况
- 实现经纬度信息在地址表单中的存储和回显功能
- 添加定位选择界面,展示已选择的位置信息
- 移除原有的实时定位功能,改为手动选择模式
- 更新地址表单提交逻辑以支持新的定位数据结构
```
2026-02-09 17:15:18 +08:00

419 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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