Files
xinlong-shop-taro/src/pages/shop/checkout.tsx
赵忠林 994695c405 feat(store): 新增门店中心及VIP会员升级功能
- 个人信息页新增门店名称和门店地址展示,支持异步加载与只读显示
- 用户信息卡片整体改为绿色渐变背景,添加光晕和白色边框样式
- 新增升级VIP会员入口,突出展示并添加推荐标签
- 新建VIP会员升级页面,包含门店信息表单和VIP权益预览
- 提交升级申请调用新增api,支持状态检测表单只读
- 门店中心页面重构,简化订单管理,新增功能卡片及VIP审核入口
- 页面加载校验店员身份,非店员拒绝访问并提示
- 购物相关页面和组件全面支持VIP会员价格优先显示dealerPrice
- 新增ShopDealerApply模型门店相关字段,丰富会员申请数据记录
2026-07-04 10:26:47 +08:00

589 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, 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 { isVipMember } from '@/utils/vip'
// 满减门槛配置
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[]>([])
// 支付方式相关状态
const [payType, setPayType] = useState<number>(0) // 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
const vip = isVipMember()
return items.reduce((sum, item) => {
// VIP 会员优先使用 dealerPrice
const dealerPrice = vip ? (item.product as any)?.dealerPrice : null
const unitPrice = dealerPrice || item.skuPrice || item.sku?.price || item.product?.salePrice || item.product?.price || 0
return sum + Number(unitPrice) * (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,
}))
// 格式化当前时间yyyy-MM-dd HH:mm:ss与后端 LocalDateTime 兼容)
const now = new Date()
const pad = (n: number) => n.toString().padStart(2, '0')
const payTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
// 创建订单
// 货到付款:直接设置 payStatus=true + payTime防止后端定时任务自动删除未付款订单
const orderParams: OrderCreateRequest = {
goodsItems,
addressId: defaultAddress.id,
payType,
payStatus: true,
payTime,
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()
}
// 显示成功提示
Taro.showToast({ title: '订单已提交', icon: 'success' })
setTimeout(() => {
Taro.switchTab({ url: '/pages/order/list' })
}, 1500)
} catch (err: any) {
Taro.showToast({ title: err.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={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'>
¥{isVipMember() && (item.product as any)?.dealerPrice ? (item.product as any).dealerPrice : (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'
style={{ backgroundColor: '#f0fdf4' }}
>
<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: '#22c55e' }}
>
<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