Files
xinlong-shop-taro/src_bak/pages/user/address-edit.tsx
赵忠林 1fa58040f3 feat(user): 新增收货地址管理及售后申请页面
- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
2026-07-01 12:11:56 +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