Files
xinlong-shop-taro/src/pages/user/address-edit.tsx
赵忠林 1077b16f58 fix(user): 修复地址和购物车相关的多个问题
- 解决鸿蒙手机首次打开地图选择不显示 POI 列表,先获取当前位置传入地图
- 修正收货地址编辑接口路径,改为无 id 的 PUT /shop/shop-user-address
- 防御性合并旧地址数据,避免 null 字段覆盖初始值
- 新用户注册和登录后同步更新 UserContext 状态,确保页面刷新购物车数据
- 各页面 useDidShow 内增加从 storage 同步用户状态,解决状态不同步问题
- 优化地图选择初始化逻辑,提升用户体验
- 保证购物车、商品详情和首页登录状态及时同步更新
2026-07-17 01:07:54 +08:00

686 lines
29 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 React, {useState, useEffect, useRef} from 'react'
import {View, Text, Input, Textarea, ScrollView, Picker} from '@tarojs/components'
import Taro, {useRouter} from '@tarojs/taro'
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress, setDefaultAddress} from '@/api/shop/shopUserAddress'
import type {ShopUserAddress} from '@/api/shop/shopUserAddress/model'
import {parseLngLatFromText} from '@/utils/geofence'
import RegionData from '@/api/json/regions-data.json'
definePageConfig({
navigationBarTitleText: '新增收货地址',
})
type SelectedLocation = { lng: string; lat: string; name?: string; address?: string }
const isUserCancel = (e: any) => {
const msg = String(e?.errMsg || e?.message || e || '')
return msg.includes('cancel')
}
/** 检查经纬度是否有效 */
const hasValidLngLat = (addr?: Partial<ShopUserAddress> | null) => {
if (!addr) return false
const p = parseLngLatFromText(`${(addr as any)?.lng ?? ''},${(addr as any)?.lat ?? ''}`)
if (!p) return false
if (p.lng === 0 && p.lat === 0) return false
return true
}
/** 确保已通过微信隐私授权(定位类接口前置要求);旧基础库无该接口时直接放行 */
const ensurePrivacyAuthorized = (): Promise<void> => {
return new Promise((resolve) => {
const wxAny: any = Taro
if (typeof wxAny.requirePrivacyAuthorize !== 'function') {
resolve()
return
}
if (typeof wxAny.getPrivacySetting === 'function') {
wxAny.getPrivacySetting({
success: (res: any) => {
if (res && res.needAuthorization) {
wxAny.requirePrivacyAuthorize({success: () => resolve(), fail: () => resolve()})
} else {
resolve()
}
},
fail: () => resolve(),
})
} else {
wxAny.requirePrivacyAuthorize({success: () => resolve(), fail: () => resolve()})
}
})
}
/** 预检并申请定位权限;已拒绝则引导去设置,返回最终是否拥有权限 */
const ensureLocationPermission = async (): Promise<boolean> => {
try {
const setting: any = await Taro.getSetting()
const auth = setting?.authSetting?.['scope.userLocation']
if (auth === true) return true
if (auth === false) {
const modal = await Taro.showModal({
title: '需要定位权限',
content: '选择定位需要开启定位权限,请在设置中开启后重试。',
confirmText: '去设置',
})
if (modal.confirm) {
await Taro.openSetting()
const setting2: any = await Taro.getSetting()
return setting2?.authSetting?.['scope.userLocation'] === true
}
return false
}
try {
await Taro.authorize({scope: 'scope.userLocation'})
return true
} catch {
const modal = await Taro.showModal({
title: '需要定位权限',
content: '选择定位需要开启定位权限,请在设置中开启后重试。',
confirmText: '去设置',
})
if (modal.confirm) {
await Taro.openSetting()
const setting2: any = await Taro.getSetting()
return setting2?.authSetting?.['scope.userLocation'] === true
}
return false
}
} catch {
return false
}
}
const AddressEditPage: React.FC = () => {
const {id, fromWx} = useRouter().params
const isEditMode = !!id
const addressId = id ? Number(id) : undefined
const fromWxMode = fromWx === '1' || fromWx === 'true'
const [loading, setLoading] = useState(true)
const [formData, setFormData] = useState<Partial<ShopUserAddress>>({
name: '',
phone: '',
country: '',
province: '',
city: '',
region: '',
address: '',
isDefault: false,
})
const [regionText, setRegionText] = useState('')
/** 所在地区微信原生 Picker 的 value由 formData 派生) */
const regionValue = [formData.province, formData.city, formData.region].map(v => String(v || ''))
const [inputText, setInputText] = useState('')
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null)
const [regionLocked, setRegionLocked] = useState(false)
const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null)
const wxDraftPatchedRef = useRef(false)
/** 解析省市区 */
const parseRegion = (text: string) => {
for (const province of RegionData as any[]) {
if (text.includes(province.label)) {
const result: any = {province: province.label}
if (province.children) {
for (const city of province.children) {
if (text.includes(city.label)) {
result.city = city.label
if (city.children) {
for (const region of city.children) {
if (text.includes(region.label)) {
result.region = region.label
return result
}
}
}
return result
}
}
}
return result
}
}
return null
}
/** 解析地址文本(智能识别) */
const parseAddressText = (text: string) => {
const result: any = {}
// 手机号正则11 位国内手机号)
const phoneRegex = /1[3-9]\d{9}/
const phoneMatch = text.match(phoneRegex)
if (phoneMatch) result.phone = phoneMatch[0]
// 姓名正则:优先匹配“收件人/收货人/姓名/联系人:”后面的 2-4 个中文字符
const nameWithPrefixRegex = /(?:收件人|收货人|姓名|联系人)[:]\s*([\u4e00-\u9fa5]{2,4})/
const nameWithPrefixMatch = text.match(nameWithPrefixRegex)
if (nameWithPrefixMatch) {
result.name = nameWithPrefixMatch[1]
} else {
// 兜底:文本开头 2-4 个中文字符
const nameRegex = /^[\u4e00-\u9fa5]{2,4}/
const nameMatch = text.match(nameRegex)
if (nameMatch) result.name = nameMatch[0]
}
// 省市区识别
const regionResult = parseRegion(text)
if (regionResult) {
result.province = regionResult.province
result.city = regionResult.city
result.region = regionResult.region
}
// 详细地址提取:先把常见的标签字段和已知字段去掉,再清洗标点
let addressText = text
// 去除已提取的姓名、手机号
if (result.name) addressText = addressText.replace(result.name, '')
if (result.phone) addressText = addressText.replace(result.phone, '')
// 去除常见标签文案(如“收件人:”、“手机号码:”、“所在地区:”、“详细地址:”)
addressText = addressText
.replace(/(?:收件人|收货人|姓名|联系人)[:]\s*/g, '')
.replace(/(?:手机号码|手机号|电话|联系电话)[:]\s*/g, '')
.replace(/(?:所在地区|地区|省市区)[:]\s*/g, '')
.replace(/(?:详细地址|地址|街道)[:]\s*/g, '')
// 去除已识别出的省市区
if (result.province) addressText = addressText.replace(result.province, '')
if (result.city) addressText = addressText.replace(result.city, '')
if (result.region) addressText = addressText.replace(result.region, '')
// 去除零散的标签关键字(如“手机号码”、“所在地区”、“详细地址”本身)
addressText = addressText
.replace(/(?:收件人|收货人|手机号码|手机号|所在地区|详细地址)/g, '')
result.address = addressText.replace(/[,。\s]+/g, '').trim()
return result
}
/** 地址智能识别 */
const recognizeAddress = () => {
if (!inputText.trim()) {
Taro.showToast({title: '请输入要识别的文本', icon: 'none'})
return
}
try {
const result = parseAddressText(inputText)
const newFormData: any = {
...formData,
name: result.name || formData.name,
phone: result.phone || formData.phone,
address: result.address || formData.address,
}
if (!regionLocked) {
newFormData.province = result.province || formData.province
newFormData.city = result.city || formData.city
newFormData.region = result.region || formData.region
}
setFormData(newFormData)
if (!regionLocked && result.province && result.city && result.region) {
setRegionText(`${result.province} ${result.city} ${result.region}`)
}
Taro.showToast({title: regionLocked ? '识别成功(所在地区以定位为准)' : '识别成功', icon: 'success'})
setInputText('')
} catch {
Taro.showToast({title: '识别失败,请检查文本格式', icon: 'none'})
}
}
/** 将地图选点结果应用到表单 */
const applyChosenLocation = (res: any) => {
if (!res) return
if (res.latitude === undefined || res.longitude === undefined) {
Taro.showToast({title: '定位信息获取失败', icon: 'none'})
return
}
const next: SelectedLocation = {
lng: String(res.longitude),
lat: String(res.latitude),
name: res.name,
address: res.address,
}
// 尝试从地图返回的地址文本解析省市区
const regionResult = res?.provinceName || res?.cityName || res?.adName
? {
province: String(res.provinceName || ''),
city: String(res.cityName || ''),
region: String(res.adName || '')
}
: parseRegion(String(res.address || ''))
const province = String(regionResult?.province || '').trim()
const city = String(regionResult?.city || '').trim()
const region = String(regionResult?.region || '').trim()
if (!province || !city || !region) {
Taro.showToast({title: '定位未识别到所在地区,请重新选择定位', icon: 'none'})
return
}
setSelectedLocation(next)
setRegionLocked(true)
// 将地图选点的地址同步到"收货地址"(剥离省市区,避免重复)
const rawAddr = String(res.address || '').trim()
const name = String(res.name || '').trim()
let detail = rawAddr
for (const part of [province, city, region]) {
if (part) detail = detail.replace(part, '')
}
detail = detail.replace(/[,]+/g, ' ').replace(/\s+/g, ' ').trim()
const base = detail || rawAddr
const nextDetailAddress = (() => {
if (!base && !name) return ''
if (!base) return name
if (!name) return base
return base.includes(name) ? base : `${base} ${name}`
})()
setFormData(prev => ({
...prev,
lng: next.lng,
lat: next.lat,
address: nextDetailAddress || prev.address,
province,
city,
region,
}))
setRegionText(`${province} ${city} ${region}`)
}
/** 选择定位 */
const chooseGeoLocation = async () => {
try {
// 1. 微信隐私授权(新版基础库强制,未同意会直接拒绝定位接口)
await ensurePrivacyAuthorized()
// 2. 定位权限预检 / 申请
const granted = await ensureLocationPermission()
if (!granted) {
Taro.showToast({title: '未获得定位权限,请在下方手动选择所在地区', icon: 'none'})
return
}
// 3. 优先使用已有经纬度;没有则先调 getLocation 获取当前位置
// 解决鸿蒙等机型首次打开地图时 POI 列表不显示的问题
let latitude: number | undefined
let longitude: number | undefined
if (selectedLocation?.lat && selectedLocation?.lng) {
const initLat = Number(selectedLocation.lat)
const initLng = Number(selectedLocation.lng)
if (Number.isFinite(initLat) && Number.isFinite(initLng)) {
latitude = initLat
longitude = initLng
}
}
// 没有已有定位时,主动获取当前位置传给地图,确保地图打开就在用户位置附近
// 这样地图的 POI 列表能立即加载,不需要用户手动拖动
if (latitude === undefined || longitude === undefined) {
try {
const loc: any = await Taro.getLocation({ type: 'gcj02' })
if (loc && Number.isFinite(loc.latitude) && Number.isFinite(loc.longitude)) {
latitude = loc.latitude
longitude = loc.longitude
}
} catch (locErr) {
// getLocation 失败不阻断流程chooseLocation 仍可打开(只是默认定位到北京)
console.warn('getLocation 失败,地图将以默认位置打开:', locErr)
}
}
const params: any = {}
if (typeof latitude === 'number') params.latitude = latitude
if (typeof longitude === 'number') params.longitude = longitude
const res = await Taro.chooseLocation(params)
applyChosenLocation(res)
} catch (e: any) {
if (isUserCancel(e)) return
// 4. 地图失败兜底:原生 Picker 无法用代码调起,提示用户去下方手动选择所在地区;
// 同时提供"重试"可再次尝试地图定位
Taro.showModal({
title: '地图打开失败',
content: '无法调起地图定位,您可以在下方手动选择所在地区并填写详细地址,或稍后重试。',
confirmText: '重试',
cancelText: '手动填写',
}).then((modal) => {
if (modal.confirm) {
Taro.chooseLocation({})
.then((res: any) => applyChosenLocation(res))
.catch(() => {
Taro.showToast({title: '仍无法打开地图,请手动选择所在地区', icon: 'none'})
})
} else {
Taro.showToast({title: '请在下方手动选择所在地区', icon: 'none'})
}
}).catch(() => {
Taro.showToast({title: '请在下方手动选择所在地区', icon: 'none'})
})
}
}
/** 地区选择器确认(微信原生 Picker mode=region */
const handleRegionChange = (e: any) => {
const val = e?.detail?.value as string[]
if (!val || val.length < 3) return
const [province, city, region] = val
setFormData(prev => ({...prev, province, city, region}))
setRegionText(`${province} ${city} ${region}`)
// 用户手动修改了所在地区,解除"由地图定位锁定"状态,后续可继续手动调整
setRegionLocked(false)
}
/** 保存地址 */
const handleSave = async () => {
// 表单校验
if (!formData.name?.trim()) {
Taro.showToast({title: '请输入收货人姓名', icon: 'none'})
return
}
if (!formData.phone?.trim()) {
Taro.showToast({title: '请输入手机号', icon: 'none'})
return
}
if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
Taro.showToast({title: '手机号格式不正确', icon: 'none'})
return
}
if (!formData.province || !formData.city || !formData.region) {
Taro.showToast({title: '请选择所在地区', icon: 'none'})
return
}
if (!formData.address?.trim()) {
Taro.showToast({title: '请输入详细地址', icon: 'none'})
return
}
// 经纬度检查(可选,不阻断保存流程)
const loc = selectedLocation || (hasValidLngLat(formData) ? {
lng: String(formData.lng),
lat: String(formData.lat)
} : null)
try {
const submitData: any = {
name: formData.name,
phone: formData.phone,
country: formData.country || '中国',
province: formData.province,
city: formData.city,
region: formData.region,
address: formData.address,
isDefault: formData.isDefault ?? true,
...(loc ? {lng: loc.lng, lat: loc.lat} : {}),
}
if (isEditMode && addressId) {
submitData.id = addressId
}
// 执行新增或更新
let savedId: number | undefined = addressId
if (isEditMode) {
await updateShopUserAddress(submitData)
} else {
const res: any = await addShopUserAddress(submitData)
// 后端新增返回可能带有 id 字段
savedId = (res && (res.data?.id || res.id)) || addressId
}
// 兜底:勾选默认时主动调用 set-default让后端走统一的"取消其他默认"逻辑
// 这样即使 save/update 在某些老版本后端上漏处理,前端也能保证唯一默认
if (submitData.isDefault && savedId) {
try {
await setDefaultAddress(savedId)
} catch (e) {
console.warn('setDefaultAddress fallback failed:', e)
}
}
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}成功`, icon: 'success'})
setTimeout(() => Taro.navigateBack(), 1000)
} catch (error) {
console.error('保存失败:', error)
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}失败`, icon: 'none'})
}
}
const updateField = (field: string, value: any) => {
setFormData(prev => ({...prev, [field]: value}))
}
/** 初始化加载 */
useEffect(() => {
const init = async () => {
// 动态设置页面标题
Taro.setNavigationBarTitle({
title: isEditMode ? '编辑收货地址' : (fromWxMode ? '完善收货地址' : '新增收货地址'),
})
// 微信地址导入
if (!isEditMode && fromWxMode && !wxDraftPatchedRef.current) {
try {
const draft = Taro.getStorageSync('WxAddressDraft')
if (draft) {
wxDraftPatchedRef.current = true
wxDraftRef.current = draft as any
Taro.removeStorageSync('WxAddressDraft')
setFormData(prev => ({...prev, ...(draft as any)}))
const p = String((draft as any)?.province || '').trim()
const c = String((draft as any)?.city || '').trim()
const r = String((draft as any)?.region || '').trim()
const rText = [p, c, r].filter(Boolean).join(' ')
if (rText) setRegionText(rText)
}
} catch (_e) { /* ignore */
}
}
// 编辑模式:加载已有地址数据
if (isEditMode && addressId) {
try {
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
if (addr) {
// 合并到 formData保证初始字段不被 null 覆盖为 undefined
setFormData(prev => ({
...prev,
...addr,
name: addr.name || prev.name || '',
phone: addr.phone || prev.phone || '',
address: addr.address || prev.address || '',
province: addr.province || prev.province || '',
city: addr.city || prev.city || '',
region: addr.region || prev.region || '',
country: addr.country || prev.country || '中国',
isDefault: addr.isDefault ?? false,
}))
const p = String(addr?.province || '').trim()
const c = String(addr?.city || '').trim()
const r = String(addr?.region || '').trim()
setRegionText([p, c, r].filter(Boolean).join(' '))
if (hasValidLngLat(addr)) {
setSelectedLocation({lng: String((addr as any).lng), lat: String((addr as any).lat)})
setRegionLocked(true)
}
}
} catch (error) {
console.error('加载地址失败:', error)
Taro.showToast({title: '加载地址失败', icon: 'none'})
}
}
setLoading(false)
}
init()
}, [])
if (loading) {
return (
<View className="flex items-center justify-center min-h-screen">
<Text className="text-gray-400 text-sm">...</Text>
</View>
)
}
return (
<View className="min-h-screen bg-gray-50 flex flex-col">
<ScrollView scrollY className="flex-1">
{/* 地址智能识别区 */}
<View className="bg-white px-3 pt-3 pb-2">
<View
className="rounded-lg p-2"
style={{border: '1px dashed #0e932e'}}
>
<Textarea
style={{height: '80px', width: '100%', fontSize: '13px'}}
value={inputText}
onInput={e => setInputText(e.detail.value)}
placeholder="粘贴文本,点击「识别」自动识别收货人、地址、电话"
maxlength={200}
/>
<View className="flex justify-end mt-2">
<View
style={{
backgroundColor: '#0e932e',
borderRadius: '12px',
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '4px',
paddingBottom: '4px'
}}
onClick={recognizeAddress}
>
<Text className="text-white text-xs"></Text>
</View>
</View>
</View>
</View>
<View className="bg-gray-100" style={{height: '8px'}}/>
{/* 选择定位 */}
<View className="bg-white px-4 mb-2">
<View className="flex items-center py-3" onClick={chooseGeoLocation}>
<View className="flex-1">
<View className="flex items-center gap-1 mb-1">
<Text className="text-sm text-gray-700"></Text>
<Text className="text-gray-300" style={{fontSize: '10px'}}></Text>
</View>
<Text className="text-xs text-gray-400">
{selectedLocation?.address || (selectedLocation ? `经度:${selectedLocation.lng}, 纬度:${selectedLocation.lat}` : '用于判断是否超出配送范围')}
</Text>
{selectedLocation?.name && (
<Text className="text-xs text-green-600 mt-1">{selectedLocation.name}</Text>
)}
</View>
<Text className="text-gray-300 text-xs"></Text>
</View>
</View>
{/* 表单区 */}
<View className="bg-white px-4 py-2">
{/* 所在地区(微信原生地区选择器,地图不可用时也可手动选择) */}
<Picker mode='region' value={regionValue} onChange={handleRegionChange}>
<View className="flex items-center py-3 border-b border-gray-50">
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<View className="flex-1 flex items-center justify-between">
<Text className={`text-sm ${regionText ? 'text-gray-800' : 'text-gray-400'}`}>
{regionText || '请选择省市区'}
</Text>
<Text className="text-gray-300 text-xs"></Text>
</View>
</View>
</Picker>
{/* 详细地址 */}
<View className="flex items-center py-3 border-b border-gray-50">
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<Input
className="flex-1 text-sm"
placeholder="请输入详细收货地址"
maxLength={50}
value={formData.address || ''}
onInput={e => updateField('address', e.detail.value)}
/>
</View>
{/* 收货人 */}
<View className="flex items-center py-3 border-b border-gray-50">
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<Input
className="flex-1 text-sm"
placeholder="请输入收货人姓名"
maxLength={10}
value={formData.name || ''}
onInput={e => updateField('name', e.detail.value)}
/>
</View>
{/* 手机号 */}
<View className="flex items-center py-3 border-b border-gray-50">
<Text className="text-sm text-gray-700" style={{minWidth: '60px'}}></Text>
<Input
className="flex-1 text-sm"
placeholder="请输入手机号"
type="number"
maxlength={11}
value={formData.phone || ''}
onInput={e => updateField('phone', e.detail.value)}
/>
</View>
</View>
{/* 默认地址开关 */}
<View className="bg-white px-4 mt-2">
<View className="flex items-center justify-between py-3">
<Text className="text-sm text-gray-700"></Text>
<View
className={`rounded-full relative ${formData.isDefault ? 'bg-green-500' : 'bg-gray-300'}`}
style={{width: '40px', height: '22px'}}
onClick={() => updateField('isDefault', !formData.isDefault)}
>
<View
className={`absolute top-1 rounded-full bg-white ${formData.isDefault ? 'right-1' : 'left-1'}`}
style={{width: '16px', height: '16px'}}
/>
</View>
</View>
</View>
{/* 底部占位 */}
<View style={{height: '100px'}}/>
</ScrollView>
{/* 底部保存按钮 - 使用 flex 布局而非 fixed */}
<View className="bg-white border-t border-gray-200 px-4 py-3" style={{paddingBottom: '20px'}}>
<View
className="py-3 rounded-full text-center"
style={{backgroundColor: '#0e932e'}}
onClick={handleSave}
>
<Text className="text-white font-medium text-sm">{isEditMode ? '更新地址' : '保存并使用'}</Text>
</View>
</View>
</View>
)
}
export default AddressEditPage