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

629 lines
24 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, useMemo } from 'react'
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
import { useCartContext } from '@/contexts/CartContext'
import { useAddress } from '@/hooks/useAddress'
import { useCoupon } from '@/hooks/useCoupon'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { getMyAvailableCoupons } from '@/api/shop/shopUserCoupon'
import AddressCard from '@/components/business/AddressCard'
import CouponCard from '@/components/business/CouponCard'
import { createOrder, type WxPayResult } from '@/api/shop/shopOrder'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
import { listShopGoods } from '@/api/shop/shopGoods'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
import type { OrderGoodsItem, OrderCreateRequest } from '@/api/shop/shopOrder/model'
// 满减门槛配置
const THRESHOLDS = [
{ target: 99, discount: 10 },
{ target: 199, discount: 20 },
{ target: 299, discount: 30 },
{ target: 499, discount: 50 },
]
// 解析可能为JSON字符串的规格值
const parseSpecValue = (value: string | undefined): string => {
if (!value) return ''
const trimmed = value.trim()
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
try {
return JSON.parse(trimmed)
} catch {
return trimmed.slice(1, -1)
}
}
return trimmed
}
// 格式化规格信息,展示规格名称和值
const formatSpecInfo = (item: any): { specList: Array<{ name: string; value: string }>; specText: string } => {
const specList: Array<{ name: string; value: string }> = []
// 防御性检查:确保 goodsSpecs 是数组
const goodsSpecs = item.product?.goodsSpecs
if (Array.isArray(goodsSpecs) && goodsSpecs.length > 0) {
const specGroups = new Map<number, { name: string; values: string[] }>()
goodsSpecs.forEach((spec: any) => {
const specId = spec?.specId || 0
if (!specGroups.has(specId)) {
specGroups.set(specId, {
name: spec?.specName || `规格${specGroups.size + 1}`,
values: []
})
}
const group = specGroups.get(specId)
if (group) {
group.values.push(parseSpecValue(spec?.specValue))
}
})
// 2. 如果选择了 SKU根据 sku.sku 匹配规格值
const skuStr = item.sku?.sku
if (typeof skuStr === 'string' && skuStr) {
const selectedValues = skuStr.split('|').map((v: string) => parseSpecValue(v.trim())).sort()
specGroups.forEach((group) => {
// 防御性检查:确保 values 是数组
if (!Array.isArray(group.values)) return
// 查找匹配的规格值
const matchedValue = group.values.find(v => selectedValues.includes(v))
if (matchedValue) {
specList.push({ name: group.name, value: matchedValue })
}
})
}
// 如果没有 SKU 但有 skuSpec 字符串
if (specList.length === 0 && item.skuSpec) {
const values = String(item.skuSpec).split(',').map(v => v.trim()).filter(Boolean)
let idx = 0
specGroups.forEach((group) => {
if (values[idx]) {
specList.push({ name: group.name, value: values[idx] })
}
idx++
})
}
}
// 3. 如果没有完整的规格定义,但有 skuSpec 字符串
if (specList.length === 0 && item.skuSpec) {
const parts = String(item.skuSpec).split(/[,]/).map(v => v.trim()).filter(Boolean)
if (parts.length > 0) {
specList.push({ name: '规格', value: parts.join(', ') })
}
}
return {
specList,
specText: specList.map(s => `${s.name}: ${s.value}`).join(' | ') || item.skuSpec || ''
}
}
definePageConfig({
navigationBarTitleText: '确认订单',
})
const CheckoutPage: React.FC = () => {
const router = useRouter()
const fromBuyNow = router.params.from === 'buyNow'
const { selectedItems, refresh: refreshCart, removeSelected } = useCartContext()
const { defaultAddress, loadAddresses } = useAddress()
const maxPopupHeight = useScrollHeight(0)
// 底部提交栏高度约 60px动态计算 ScrollView 可用高度
const scrollHeight = useScrollHeight(44)
const [coupons, setCoupons] = useState<ShopUserCoupon[]>([])
const [selectedCoupon, setSelectedCoupon] = useState<ShopUserCoupon | null>(null)
const [couponVisible, setCouponVisible] = useState(false)
const [remarks, setRemarks] = useState('')
const [submitting, setSubmitting] = useState(false)
const [rushBuyProducts, setRushBuyProducts] = useState<ShopGoods[]>([])
// 支付方式相关状态
// payType: 1=微信支付, 0=货到付款(默认)
const [payType, setPayType] = useState<number>(0)
// 从本地存储获取立即购买的数据
const buyNowItems = useMemo(() => {
if (fromBuyNow) {
const data = Taro.getStorageSync('buy_now')
if (data) {
try {
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : []
} catch { /* ignore */ }
}
}
return null
}, [fromBuyNow])
// 使用的商品列表
const items = buyNowItems || selectedItems
// 计算金额
const goodsPrice = useMemo(() => {
if (!items || items.length === 0) return 0
return items.reduce((sum, item) => {
return sum + Number(item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || 0) * (item.quantity || item.num || 1)
}, 0)
}, [buyNowItems, selectedItems])
const couponDiscount = selectedCoupon?.reducePrice ? Number(selectedCoupon.reducePrice) : 0
const totalPrice = Math.max(0, goodsPrice - couponDiscount).toFixed(2)
// 计算凑单信息
const rushBuyInfo = useMemo(() => {
if (goodsPrice === 0) return null
// 找出最近的满减门槛
for (const threshold of THRESHOLDS) {
if (goodsPrice < threshold.target) {
const needAmount = threshold.target - goodsPrice
// 只在需要凑的金额在 5-80 元之间时显示推荐
if (needAmount <= 80) {
return {
target: threshold.target,
discount: threshold.discount,
needAmount: needAmount.toFixed(2)
}
}
}
}
return null
}, [goodsPrice])
// 加载优惠券和凑单商品
useEffect(() => {
loadCoupons()
loadRushBuyProducts()
}, [])
// 每次页面显示时刷新地址(从地址列表选择后返回)
useDidShow(() => {
loadAddresses()
})
const loadCoupons = async () => {
try {
const data = await getMyAvailableCoupons()
setCoupons(data || [])
} catch (e) {
console.error('加载优惠券失败:', e)
// 不设置任何内容,让页面继续显示
}
}
// 加载凑单推荐商品
const loadRushBuyProducts = async () => {
try {
// 获取热销/推荐商品
const data = await listShopGoods({ page: 1, limit: 10, isShow: 1 })
// 防御性检查:确保返回的是数组
const safeData = Array.isArray(data) ? data : []
if (safeData.length > 0) {
// 过滤掉已在购物车的商品
const currentItems = buyNowItems || selectedItems
const cartGoodsIds = Array.isArray(currentItems) ? currentItems.map(item => item.goodsId) : []
const filtered = safeData.filter(p => !cartGoodsIds.includes(p.goodsId))
setRushBuyProducts(filtered.slice(0, 4))
}
} catch (e) {
console.error('加载凑单商品失败:', e)
// 不设置任何内容,让页面继续显示
}
}
// 加载凑单推荐商品
const handleSelectAddress = () => {
Taro.navigateTo({
url: '/pages/user/address-list?from=checkout',
events: {
refresh: () => loadAddresses()
}
})
}
// 选择优惠券
const handleSelectCoupon = () => {
setCouponVisible(true)
}
const handleCouponConfirm = (coupon: ShopUserCoupon | null) => {
setSelectedCoupon(coupon)
setCouponVisible(false)
}
// 提交订单
const handleSubmit = async () => {
if (!defaultAddress) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
if (items.length === 0) {
Taro.showToast({ title: '请选择商品', icon: 'none' })
return
}
setSubmitting(true)
try {
// 构建商品列表
const goodsItems: OrderGoodsItem[] = items.map(item => ({
goodsId: item.goodsId,
skuId: item.skuId && item.skuId > 0 ? item.skuId : undefined,
quantity: item.quantity || item.num || 1,
specInfo: item.skuSpec || item.sku?.sku || item.product?.specName,
}))
// 创建订单
const orderParams: OrderCreateRequest = {
goodsItems,
addressId: defaultAddress.id,
payType,
couponId: selectedCoupon?.id ? Number(selectedCoupon.id) : undefined,
comments: remarks,
deliveryType: 0, // 快递配送
}
const res = await createOrder(orderParams)
// 清理购物车数据(无论哪种支付方式都需清理)
if (fromBuyNow) {
Taro.removeStorageSync('buy_now')
} else {
await removeSelected()
}
// 根据支付方式处理
if (payType === 1) {
// 微信支付 - res 是 WxPayResult
await handleWxPay(res as WxPayResult)
}
// 货到付款(payType=0)无需在线支付,直接下单成功
Taro.showToast({ title: payType === 0 ? '下单成功' : '支付成功', icon: 'success' })
setTimeout(() => {
Taro.switchTab({ url: '/pages/order/list' })
}, 1500)
} catch (err: any) {
Taro.showToast({ title: err.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
// 微信支付
const handleWxPay = async (payData: WxPayResult & { paid?: string }) => {
// 后端返回已支付(回调丢失自动修复场景)
if (payData.paid === 'true') {
return // 直接视为支付成功
}
return new Promise<void>((resolve, reject) => {
Taro.requestPayment({
timeStamp: payData.timeStamp,
nonceStr: payData.nonceStr,
package: payData.package,
signType: payData.signType,
paySign: payData.paySign,
success: () => resolve(),
fail: (err) => reject(new Error('支付取消'))
})
})
}
// 安全的数字格式化
const formatPrice = (price: number | string): string => {
const num = typeof price === 'string' ? parseFloat(price) : price
if (isNaN(num)) return '0.00'
return num.toFixed(2)
}
return (
<View className='bg-gray-50 flex flex-col' style={{ height: '100vh' }}>
<ScrollView scrollY style={{ height: scrollHeight }}>
<View className='p-3'>
{/* 地址 */}
{defaultAddress ? (
<AddressCard
address={defaultAddress}
onClick={handleSelectAddress}
/>
) : (
<View className='bg-white rounded-lg p-3' onClick={handleSelectAddress}>
<View className='flex items-center justify-between'>
<View className='flex items-center gap-2'>
<Text className='text-gray-400 text-sm'>📍</Text>
<View>
<Text className='text-sm font-medium text-gray-800 block'></Text>
</View>
</View>
<Text className='text-gray-400 text-sm'></Text>
</View>
</View>
)}
{/* 商品列表 */}
<View className='bg-white rounded-lg mt-3 p-3'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'>
({(items || []).length})
</Text>
{items && items.length > 0 ? (
items.map((item, idx) => {
const { specList } = formatSpecInfo(item)
return (
<View key={`${item.goodsId}-${item.skuId}-${idx}`} className='flex gap-3 py-3 border-b border-gray-50'>
<Image
className='w-16 h-16 rounded-md bg-gray-100'
src={item.goodsImage || item.product?.image || item.sku?.image || ''}
mode='aspectFill'
/>
<View className='flex-1'>
<Text className='text-sm text-gray-700 block line-clamp-2'>
{item.goodsName || item.product?.name || item.product?.goodsName}
</Text>
{/* 多规格属性展示 */}
{specList.length > 0 ? (
<View className='mt-1'>
{specList.map((spec, specIdx) => (
<View key={specIdx} className='flex items-center text-xs'>
<Text className='text-gray-400'>{spec.name}:</Text>
<Text className='text-gray-600 ml-1'>{spec.value}</Text>
</View>
))}
</View>
) : item.skuSpec ? (
<Text className='text-xs text-gray-400 mt-1'>{item.skuSpec}</Text>
) : null}
<View className='flex justify-between items-center mt-1'>
<Text className='text-xs text-gray-500'>x{item.quantity || item.num || 1}</Text>
<Text className='text-sm font-medium text-gray-800'>
¥{item.skuPrice || item.sku?.salePrice || item.sku?.price || item.product?.salePrice || item.product?.price || '0'}
</Text>
</View>
</View>
</View>
)
})
) : (
<View className='py-10 text-center'>
<Text className='text-gray-400 text-sm'></Text>
<View
className='mt-3 px-6 py-2 rounded-full text-white text-sm inline-block'
style={{ backgroundColor: '#0e932e' }}
onClick={() => Taro.switchTab({ url: '/pages/shop/index' })}
>
</View>
</View>
)}
</View>
{/* 优惠券 */}
<View className='bg-white rounded-lg mt-3 p-3' onClick={handleSelectCoupon}>
<View className='flex justify-between items-center'>
<Text className='text-sm text-gray-700'></Text>
<View className='flex items-center gap-2'>
{selectedCoupon ? (
<Text className='text-sm text-red-500'>-{selectedCoupon.reducePrice}</Text>
) : coupons.length > 0 ? (
<Text className='text-sm text-gray-400'>{coupons.length}</Text>
) : (
<Text className='text-sm text-gray-400'></Text>
)}
<Text className='text-gray-400'></Text>
</View>
</View>
</View>
{/* 备注 */}
<View className='bg-white rounded-lg mt-3 p-3'>
<View className='flex justify-between items-center'>
<Text className='text-sm text-gray-700'></Text>
<Text className='text-xs text-gray-400'></Text>
</View>
<View className='mt-2'>
<Input
className='bg-gray-50 rounded-lg px-3 py-2 text-sm'
placeholder='点击添加备注...'
value={remarks}
onInput={(e: any) => setRemarks(e.detail.value)}
maxlength={200}
/>
</View>
</View>
{/* 金额明细 */}
<View className='bg-white rounded-lg mt-3 p-3'>
<View className='flex justify-between mb-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-700'>¥{formatPrice(goodsPrice)}</Text>
</View>
<View className='flex justify-between mb-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-green-600'></Text>
</View>
{couponDiscount > 0 && (
<View className='flex justify-between mb-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-red-500'>-¥{formatPrice(couponDiscount)}</Text>
</View>
)}
<View className='flex justify-between pt-2 border-t border-gray-100'>
<Text className='text-sm font-medium'></Text>
<Text className='text-lg font-bold text-red-500'>¥{totalPrice}</Text>
</View>
</View>
{/* 支付方式选择 */}
<View className='bg-white rounded-lg mt-3 p-3'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
{/* 微信支付 */}
{/*<View*/}
{/* className='flex items-center py-3 px-2 rounded-lg mb-2'*/}
{/* style={{ backgroundColor: payType === 1 ? '#f0fdf4' : '#f9fafb' }}*/}
{/* onClick={() => setPayType(1)}*/}
{/*>*/}
{/* <View className='w-8 h-8 rounded-full bg-green-50 flex items-center justify-center mr-3'>*/}
{/* <Text className='text-sm'>微</Text>*/}
{/* </View>*/}
{/* <View className='flex-1'>*/}
{/* <Text className='text-sm text-gray-800 block'>微信支付</Text>*/}
{/* <Text className='text-xs text-gray-400 block'>推荐使用</Text>*/}
{/* </View>*/}
{/* <View*/}
{/* className='w-5 h-5 rounded-full border-2 flex items-center justify-center'*/}
{/* style={{ borderColor: payType === 1 ? '#22c55e' : '#d1d5db' }}*/}
{/* >*/}
{/* {payType === 1 && (*/}
{/* <View className='w-2 h-2 rounded-full' style={{ backgroundColor: '#22c55e' }} />*/}
{/* )}*/}
{/* </View>*/}
{/*</View>*/}
{/* 货到付款 */}
<View
className='flex items-center py-3 px-2 rounded-lg'
style={{ backgroundColor: payType === 0 ? '#f0fdf4' : '#f9fafb' }}
onClick={() => setPayType(0)}
>
<View className='w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center mr-3'>
<Text className='text-sm'></Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'></Text>
<Text className='text-xs text-gray-400 block'></Text>
</View>
<View
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
style={{ borderColor: payType === 0 ? '#22c55e' : '#d1d5db' }}
>
{payType === 0 && (
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: '#22c55e' }} />
)}
</View>
</View>
</View>
{/* 凑单推荐 */}
{rushBuyInfo && rushBuyProducts.length > 0 && (
<View className='rounded-lg mt-3 p-3 border border-red-100' style={{ background: 'linear-gradient(to right, #fef2f2, #fff7ed)', display: 'none' }}>
<View className='flex items-center justify-between mb-3'>
<View className='flex items-center gap-2'>
<Text className='text-red-500 text-lg'>🎯</Text>
<Text className='text-sm font-medium text-red-600'>
¥{rushBuyInfo.needAmount} ¥{rushBuyInfo.discount}
</Text>
</View>
<Text className='text-xs text-gray-400'></Text>
</View>
<ScrollView scrollX className='flex-row'>
<View className='flex flex-row gap-2'>
{rushBuyProducts.map(product => (
<View
key={product.goodsId}
className='w-24 bg-white rounded-lg p-2'
onClick={() => {
Taro.navigateTo({
url: `/pages/shop/product-detail?id=${product.goodsId}&from=rushbuy`
})
}}
>
<Image
className='w-full h-20 rounded bg-gray-100'
src={product.image || ''}
mode='aspectFill'
/>
<Text className='text-xs text-gray-700 block line-clamp-1 mt-1'>
{product.name}
</Text>
<View className='flex items-center justify-between mt-1'>
<Text className='text-xs font-medium text-red-500'>
¥{product.salePrice || product.price}
</Text>
<Text className='text-xs text-gray-400'>+</Text>
</View>
</View>
))}
</View>
</ScrollView>
</View>
)}
{/* 底部留白(固定栏高度 + 安全区) */}
<View style={{ height: '80px' }} />
</View>
</ScrollView>
{/* 底部提交栏 - 固定吸底 */}
<View
className='bg-white border-t border-gray-100 px-3'
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
paddingBottom: 'env(safe-area-inset-bottom)',
zIndex: 100,
}}
>
<View className='flex items-center justify-between py-3'>
<View className='flex items-center gap-2'>
<Text className='text-sm text-gray-500'>:</Text>
<Text className='text-lg font-bold text-red-500'>¥{totalPrice}</Text>
</View>
<View
className='px-8 py-2 rounded-full text-white text-sm font-medium'
style={{ backgroundColor: submitting ? '#ccc' : '#0e932e' }}
onClick={submitting ? undefined : handleSubmit}
>
<Text>{submitting ? '提交中...' : '提交订单'}</Text>
</View>
</View>
</View>
{/* 优惠券选择弹窗 */}
{couponVisible && (
<View className='absolute flex items-end' style={{ top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 110 }}>
<View className='w-full bg-white rounded-t-xl overflow-hidden' style={{ maxHeight: maxPopupHeight }}>
<View className='p-4 border-b border-gray-100 flex justify-between items-center'>
<Text className='text-base font-medium'></Text>
<Text className='text-gray-400' onClick={() => setCouponVisible(false)}></Text>
</View>
<ScrollView scrollY className='flex-1'>
<View className='p-3'>
{/* 不使用优惠券 */}
<View
className='bg-white rounded-lg p-3 mb-3 border border-gray-200'
onClick={() => handleCouponConfirm(null)}
>
<Text className='text-sm text-gray-600'>使</Text>
</View>
{/* 可用优惠券列表 */}
{coupons.map(coupon => (
<CouponCard
key={coupon.id}
coupon={coupon}
selected={selectedCoupon?.id === coupon.id}
onClick={() => handleCouponConfirm(coupon)}
/>
))}
{coupons.length === 0 && (
<View className='py-10 text-center'>
<Text className='text-gray-400 text-sm'></Text>
</View>
)}
</View>
</ScrollView>
</View>
</View>
)}
</View>
)
}
export default CheckoutPage