Files
xinlong-shop-taro/src/pages/user/address-edit.tsx
赵忠林 f3886664f7 fix(api): 替换所有接口请求的域名为 guilixu-api.websoft.top
- 将获取 STS Token 的接口地址由 paopao-api 改为 guilixu-api
- 更新图片上传接口地址为新的 guilixu-api 域名
- 修改用户推广页面中邀请码链接和二维码接口的域名
- 更改注册页微信登录接口请求的域名为 guilixu-api
2026-06-16 17:15:59 +08:00

562 lines
23 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} from '@tarojs/components'
import Taro, {useRouter} from '@tarojs/taro'
import {Address} from '@nutui/nutui-react-taro'
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress} 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 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')
}
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 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: '',
province: '',
city: '',
region: '',
address: '',
isDefault: false,
})
const [regionText, setRegionText] = useState('')
const [inputText, setInputText] = useState('')
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null)
const [regionLocked, setRegionLocked] = useState(false)
const [regionPickerVisible, setRegionPickerVisible] = useState(false)
const [regionOptions, setRegionOptions] = useState<any[]>([])
const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null)
const wxDraftPatchedRef = useRef(false)
/** 初始化省市区选择器数据 */
useEffect(() => {
const options = (RegionData as any[]).map((province) => ({
value: province.label,
text: province.label,
children: province.children?.map((city: any) => ({
value: city.label,
text: city.label,
children: city.children?.map((region: any) => ({
value: region.label,
text: region.label,
})) || [],
})) || [],
}))
setRegionOptions(options)
}, [])
/** 解析省市区 */
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 = {}
// 手机号正则
const phoneRegex = /1[3-9]\d{9}/
const phoneMatch = text.match(phoneRegex)
if (phoneMatch) result.phone = phoneMatch[0]
// 姓名正则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, '')
if (result.province) addressText = addressText.replace(result.province, '')
if (result.city) addressText = addressText.replace(result.city, '')
if (result.region) addressText = addressText.replace(result.region, '')
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 chooseGeoLocation = async () => {
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}`)
}
try {
const initLat = selectedLocation?.lat ? Number(selectedLocation.lat) : undefined
const initLng = selectedLocation?.lng ? Number(selectedLocation.lng) : undefined
const latitude = typeof initLat === 'number' && Number.isFinite(initLat) ? initLat : undefined
const longitude = typeof initLng === 'number' && Number.isFinite(initLng) ? initLng : undefined
const res = await Taro.chooseLocation({latitude, longitude})
applyChosenLocation(res)
} catch (e: any) {
if (isUserCancel(e)) return
if (isLocationDenied(e)) {
try {
const modal = await Taro.showModal({
title: '需要定位权限',
content: '选择定位需要开启定位权限,请在设置中开启后重试。',
confirmText: '去设置',
})
if (modal.confirm) {
await Taro.openSetting()
const res = await Taro.chooseLocation({})
applyChosenLocation(res)
}
} catch (_e) { /* ignore */
}
return
}
Taro.showToast({title: '打开地图失败,请重试', icon: 'none'})
}
}
/** 打开地区选择器 */
const openRegionPicker = () => {
if (regionLocked) {
Taro.showToast({title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none'})
return
}
setRegionPickerVisible(true)
}
/** 地区选择器确认 */
const handleRegionChange = (value: string[]) => {
if (regionLocked) {
Taro.showToast({title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none'})
return
}
const [province, city, region] = value
setFormData(prev => ({...prev, province, city, region}))
setRegionText(value.join(' '))
}
/** 保存地址 */
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
}
// 执行新增或更新
if (isEditMode) {
await updateShopUserAddress(submitData)
} else {
await addShopUserAddress(submitData)
}
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)
setFormData(addr)
setRegionText(`${addr.province || ''} ${addr.city || ''} ${addr.region || ''}`)
if (hasValidLngLat(addr)) {
setSelectedLocation({lng: String(addr.lng), lat: String(addr.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 relative"
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="absolute right-2 bottom-2"
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 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">
{/* 所在地区 */}
<View className="flex items-center py-3 border-b border-gray-50" onClick={openRegionPicker}>
<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>
{/* 详细地址 */}
<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>
{/* 省市区选择器 */}
<Address
visible={regionPickerVisible}
options={regionOptions}
title="选择所在地区"
onChange={(value) => handleRegionChange(value as string[])}
onClose={() => setRegionPickerVisible(false)}
/>
</View>
)
}
export default AddressEditPage