Files
xinlong-shop-taro/src/pages/shop/checkout.tsx
赵忠林 fbaa5c32bc fix(tabBar): 修正“订单”页改为非tabBar页面的导航链接
- 将原来 switchTab 跳转“订单”页改为 navigateTo,以适配非 tabBar 页面
- 统一更新多个文件中关于订单页的跳转方式共计 6 处
- 更新 tabBar 配置,从包含“订单”页改为实际的四个 tabBar 页面
- 修正 isTabBarUrl 相关判断,去除不存在或非 tabBar 路径
- 调整多个 tabBar 判断列表,保证逻辑一致性与正确性
- 修改购物车页图片压缩宽度加入限制,优化资源加载
- 更新订单列表页注释,去除“tabBar 页”描述
- 备注遗留问题,部分积分页跳转仍用错误方式,待进一步修复
2026-07-15 23:42:27 +08:00

626 lines
25 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 } from '@/api/shop/shopOrder'
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'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getCompressedImageUrl } from '@/utils/image'
// 满减门槛配置
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[]>([])
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
// 支付方式相关状态
const [payType, setPayType] = useState<number>(9) // 9: 线下付款默认8: 货到付款(已注释)
// 支付方式选项
const PAYMENT_OPTIONS = [
// { id: 8, name: '货到付款', desc: '送达时支付', icon: '货', bgColor: '#f0fdf4', iconBg: '#dbeafe', iconColor: '#2563eb', borderColor: '#22c55e' },
{ id: 9, name: '线下付款', desc: '微信转账,商家确认后发货', icon: '线', bgColor: '#fffbeb', iconBg: '#fef3c7', iconColor: '#d97706', borderColor: '#f59e0b' },
]
// 从本地存储获取立即购买的数据
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
const vip = isVip
return items.reduce((sum, item) => {
// VIP 会员优先使用 dealerPrice后端关联字段 > product 嵌套对象)
const dealerPrice = vip ? (item.dealerPrice || (item.product as any)?.dealerPrice) : null
const unitPrice = dealerPrice || item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || 0
return sum + Number(unitPrice) * (item.quantity || item.num || 1)
}, 0)
}, [buyNowItems, selectedItems, isVip])
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 => {
// VIP 会员优先使用 dealerPrice 作为下单单价
const dealerPrice = isVip ? (item.dealerPrice || (item.product as any)?.dealerPrice) : null
const unitPrice = dealerPrice || undefined
return {
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,
price: unitPrice ? String(unitPrice) : undefined,
}
})
// 货到付款(8):后端会自动设置 payStatus=true无需前端传递 payStatus/payTime
// 线下付款(9):后端会设置 payStatus=false等待商家确认收款后才设为 true
const orderParams: OrderCreateRequest = {
goodsItems,
addressId: defaultAddress.id,
payType,
couponId: selectedCoupon?.id ? Number(selectedCoupon.id) : undefined,
buyerRemarks: remarks,
deliveryType: 0, // 快递配送
}
const res = await createOrder(orderParams)
// 清理数据
if (fromBuyNow) {
Taro.removeStorageSync('buy_now')
} else {
await removeSelected()
}
// 显示成功提示
Taro.showToast({ title: '订单已提交', icon: 'success' })
setTimeout(() => {
Taro.navigateTo({ url: '/pages/order/list' })
}, 1500)
} catch (err: any) {
const message = err.message || '提交失败'
// 线下付款(payType=9)下单时,后端如果仍尝试创建微信支付订单,
// 会因支付配置缺失应用ID而报错。这里给出更明确的提示。
if (payType === 9 && /应用ID|appid|appId|支付配置|微信支付订单|创建支付订单失败/.test(message)) {
Taro.showToast({ title: '线下支付暂不可用:微信支付配置缺失', icon: 'none', duration: 3000 })
console.error('[checkout] 线下付款下单失败,后端支付配置问题:', message)
} else {
Taro.showToast({ title: message, icon: 'none' })
}
} finally {
setSubmitting(false)
}
}
// 安全的数字格式化
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={getCompressedImageUrl(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'>
¥{isVip && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.salePrice || item.sku?.price || item.salePrice || 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>
{PAYMENT_OPTIONS.map((option) => {
const selected = payType === option.id
return (
<View
key={option.id}
className='flex items-center py-3 px-2 rounded-lg mb-2'
style={{ backgroundColor: selected ? option.bgColor : '#f9fafb' }}
onClick={() => setPayType(option.id)}
>
<View
className='w-8 h-8 rounded-full flex items-center justify-center mr-3'
style={{ backgroundColor: option.iconBg }}
>
<Text className='text-sm' style={{ color: option.iconColor }}>{option.icon}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'>{option.name}</Text>
<Text className='text-xs text-gray-400 block'>{option.desc}</Text>
</View>
<View
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
style={{ borderColor: selected ? option.borderColor : '#d1d5db' }}
>
{selected && (
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: option.borderColor }} />
)}
</View>
</View>
)
})}
{/* 线下付款选中时显示提示 */}
{payType === 9 && (
<View className='mt-2 p-2 rounded-lg' style={{ backgroundColor: '#fef3c7' }}>
<Text className='text-xs text-orange-600'>
</Text>
</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={getCompressedImageUrl(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