feat(user): 新增收货地址管理及售后申请页面
- 新增地址类型定义,增强前端地址数据结构 - 新增地址编辑页面,支持地址智能识别和定位选点功能 - 地址编辑支持省市区选择及默认地址设置 - 新增地址列表页面,支持地址展示、删除、编辑和选择功能 - 实现售后申请页面,支持选择售后类型和退款原因 - 售后申请支持商品选择、退款金额计算和凭证上传 - 新增售后详情页面,支持售后状态展示及申请取消 - 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
121
src_bak/components/business/AddressCard/index.tsx
Normal file
121
src_bak/components/business/AddressCard/index.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model'
|
||||
|
||||
interface AddressCardProps {
|
||||
address: ShopUserAddress
|
||||
/** 是否选中状态(用于选择地址场景,如结算页) */
|
||||
selected?: boolean
|
||||
/** 是否为选择模式(显示radio圆圈) */
|
||||
selectMode?: boolean
|
||||
onClick?: () => void
|
||||
showActions?: boolean
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
onDefault?: () => void
|
||||
}
|
||||
|
||||
const AddressCard: React.FC<AddressCardProps> = ({
|
||||
address,
|
||||
selected = false,
|
||||
selectMode = false,
|
||||
onClick,
|
||||
showActions = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDefault,
|
||||
}) => {
|
||||
const fullAddress = [address.province, address.city, address.region, address.address].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-white rounded-xl p-3 mb-3 border-2 ${selected ? 'border-green-500' : 'border-transparent'}`}
|
||||
style={{
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<View className="flex gap-3">
|
||||
{/* 选择模式下的radio圈 */}
|
||||
{selectMode && (
|
||||
<View className="flex items-center justify-center" style={{ minWidth: '20px' }}>
|
||||
<View
|
||||
className={`rounded-full ${selected ? 'bg-green-500' : 'border-2 border-gray-300'}`}
|
||||
style={{ width: '18px', height: '18px' }}
|
||||
>
|
||||
{selected && (
|
||||
<View className="flex items-center justify-center h-full">
|
||||
<Text className="text-white text-xs" style={{ fontSize: '10px', lineHeight: '18px' }}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="flex-1">
|
||||
{/* 姓名 + 手机号 + 默认标签 */}
|
||||
<View className="flex items-center gap-2 mb-1">
|
||||
<Text className="text-sm font-medium text-gray-800">{address.name}</Text>
|
||||
<Text className="text-sm text-gray-600">{address.phone}</Text>
|
||||
{address.isDefault && (
|
||||
<View className="bg-green-50 rounded px-1 py-0">
|
||||
<Text className="text-green-600" style={{ fontSize: '10px' }}>默认</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 完整地址 */}
|
||||
<Text className="text-xs text-gray-500 leading-5">
|
||||
{fullAddress || address.fullAddress || address.address || '暂无地址'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作栏 */}
|
||||
{showActions && (
|
||||
<View className="flex justify-between items-center mt-2 pt-2 border-t border-gray-100">
|
||||
{/* 默认地址切换 */}
|
||||
<View
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => { e.stopPropagation(); onDefault?.() }}
|
||||
>
|
||||
<View
|
||||
className={`rounded-full ${address.isDefault ? 'bg-green-500' : 'border-2 border-gray-300'}`}
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
>
|
||||
{address.isDefault && (
|
||||
<View className="flex items-center justify-center h-full">
|
||||
<Text className="text-white" style={{ fontSize: '9px', lineHeight: '16px' }}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className={`text-xs ${address.isDefault ? 'text-green-600' : 'text-gray-400'}`}>默认地址</Text>
|
||||
</View>
|
||||
|
||||
{/* 编辑/删除 */}
|
||||
<View className="flex items-center gap-3">
|
||||
{onEdit && (
|
||||
<Text
|
||||
className="text-xs text-gray-400"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.() }}
|
||||
>
|
||||
编辑
|
||||
</Text>
|
||||
)}
|
||||
<Text className="text-xs text-gray-200">|</Text>
|
||||
{onDelete && (
|
||||
<Text
|
||||
className="text-xs text-gray-400"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.() }}
|
||||
>
|
||||
删除
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressCard
|
||||
113
src_bak/components/business/CouponCard/index.tsx
Normal file
113
src_bak/components/business/CouponCard/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
|
||||
import { getCouponTypeText, formatCouponValue, isExpiringSoon } from '@/hooks/useCoupon'
|
||||
|
||||
interface CouponCardProps {
|
||||
coupon: ShopUserCoupon
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
showDelete?: boolean
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
const CouponCard: React.FC<CouponCardProps> = ({
|
||||
coupon,
|
||||
disabled = false,
|
||||
onClick,
|
||||
showDelete = false,
|
||||
onDelete,
|
||||
}) => {
|
||||
const isUsed = coupon.status === 1
|
||||
const isExpired = coupon.status === 2 || coupon.isExpire === 1
|
||||
const expiringSoon = isExpiringSoon(coupon)
|
||||
|
||||
const getValidTime = () => {
|
||||
if (coupon.startTime && coupon.endTime) {
|
||||
return `${coupon.startTime.slice(0, 10)} - ${coupon.endTime.slice(0, 10)}`
|
||||
}
|
||||
return '永久有效'
|
||||
}
|
||||
|
||||
const getRangeText = () => {
|
||||
switch (coupon.applyRange) {
|
||||
case 10: return '全场通用'
|
||||
case 20: return '指定商品'
|
||||
case 30: return '指定分类'
|
||||
default: return '全场通用'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-white rounded-lg mb-3 overflow-hidden ${disabled ? 'opacity-60' : ''}`}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
<View className='flex'>
|
||||
{/* 左侧金额区域 */}
|
||||
<View className={`w-24 py-4 flex flex-col items-center justify-center ${isUsed || isExpired ? 'bg-gray-200' : (
|
||||
coupon.type === 40 ? 'bg-gradient-to-br from-blue-500 to-blue-600' :
|
||||
coupon.type === 50 ? 'bg-gradient-to-br from-cyan-500 to-teal-500' :
|
||||
'bg-gradient-to-br from-red-500 to-orange-500'
|
||||
)}`}>
|
||||
<Text className='text-white text-lg font-bold'>{formatCouponValue(coupon)}</Text>
|
||||
{(() => {
|
||||
if (coupon.type === 50) {
|
||||
// 场地使用券
|
||||
const parts: string[] = []
|
||||
if (coupon.useDuration && coupon.useDuration > 0) parts.push(`${coupon.useDuration}分钟`)
|
||||
return parts.length > 0 ? (
|
||||
<Text className='text-white text-xs mt-1'>{parts.join(' | ')}</Text>
|
||||
) : null
|
||||
}
|
||||
if (coupon.type === 40 || !coupon.minPrice || Number(coupon.minPrice) <= 0) return null
|
||||
return <Text className='text-white text-xs mt-1'>满{coupon.minPrice}可用</Text>
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* 右侧信息区域 */}
|
||||
<View className='flex-1 p-3'>
|
||||
<View className='flex justify-between items-start'>
|
||||
<View className='flex-1'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-sm font-medium text-gray-800'>{coupon.name || getCouponTypeText(coupon.type)}</Text>
|
||||
{expiringSoon && !isUsed && !isExpired && (
|
||||
<Text className='text-xs text-orange-500 bg-orange-50 px-1 py-0 rounded'>即将过期</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-1'>{getRangeText()}</Text>
|
||||
<Text className='text-xs text-gray-400 mt-1'>{getValidTime()}</Text>
|
||||
{coupon.description && (
|
||||
<Text className='text-xs text-gray-500 mt-1 line-clamp-1'>{coupon.description}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<View className='ml-2'>
|
||||
{isUsed && (
|
||||
<Text className='text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded'>已使用</Text>
|
||||
)}
|
||||
{isExpired && (
|
||||
<Text className='text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded'>已过期</Text>
|
||||
)}
|
||||
{!isUsed && !isExpired && (
|
||||
<View className='w-4 h-4 rounded-full border border-gray-300' />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
{showDelete && (isUsed || isExpired) && (
|
||||
<View className='flex justify-end mt-2 pt-2 border-t border-gray-100'>
|
||||
<Text className='text-xs text-red-500' onClick={(e) => { e.stopPropagation(); onDelete?.() }}>
|
||||
删除
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CouponCard
|
||||
185
src_bak/components/business/CouponSelect/index.tsx
Normal file
185
src_bak/components/business/CouponSelect/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
|
||||
|
||||
interface CouponSelectProps {
|
||||
visible: boolean
|
||||
coupons: ShopUserCoupon[]
|
||||
minAmount?: number
|
||||
selectedCouponId?: string
|
||||
onClose: () => void
|
||||
onSelect: (coupon: ShopUserCoupon | null) => void
|
||||
}
|
||||
|
||||
const CouponSelect: React.FC<CouponSelectProps> = ({
|
||||
visible,
|
||||
coupons,
|
||||
minAmount = 0,
|
||||
selectedCouponId,
|
||||
onClose,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [showOverlay, setShowOverlay] = React.useState(false)
|
||||
const [slideUp, setSlideUp] = React.useState(false)
|
||||
|
||||
// 控制动画
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setShowOverlay(true)
|
||||
setTimeout(() => setSlideUp(true), 10)
|
||||
} else {
|
||||
setSlideUp(false)
|
||||
setTimeout(() => setShowOverlay(false), 300)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleClose = () => {
|
||||
setSlideUp(false)
|
||||
setTimeout(() => {
|
||||
setShowOverlay(false)
|
||||
onClose()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSelect = (coupon: ShopUserCoupon | null) => {
|
||||
handleClose()
|
||||
onSelect(coupon)
|
||||
}
|
||||
|
||||
const availableCoupons = useMemo(() => {
|
||||
return coupons.filter(c => {
|
||||
if (c.status !== 0 && c.status !== undefined) return false
|
||||
if (c.endTime && new Date(c.endTime) < new Date()) return false
|
||||
return (Number(c.minPrice) || 0) <= minAmount
|
||||
})
|
||||
}, [coupons, minAmount])
|
||||
|
||||
if (!showOverlay) return null
|
||||
|
||||
return (
|
||||
<View className='coupon-select-overlay'>
|
||||
{/* 遮罩层 */}
|
||||
<View
|
||||
className={`absolute inset-0 bg-black/50 z-50 ${slideUp ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<View
|
||||
className={`absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl z-50 ${slideUp ? '' : 'hidden'}`}
|
||||
style={{ maxHeight: '60vh' }}
|
||||
>
|
||||
<View className='p-4'>
|
||||
{/* 关闭按钮 */}
|
||||
<View className='flex justify-end mb-2'>
|
||||
<View
|
||||
className='w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Text className='text-gray-400 text-sm'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='flex justify-between items-center mb-4'>
|
||||
<Text className='text-base font-medium'>选择优惠券</Text>
|
||||
<Text
|
||||
className='text-sm text-green-600'
|
||||
onClick={() => handleSelect(null)}
|
||||
>
|
||||
不使用优惠券
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{availableCoupons.length === 0 ? (
|
||||
<View className='py-8 text-center'>
|
||||
<Text className='text-sm text-gray-400'>暂无可用优惠券</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView scrollY style={{ maxHeight: '50vh' }}>
|
||||
{availableCoupons.map((coupon) => {
|
||||
const isSelected = selectedCouponId === coupon.id
|
||||
return (
|
||||
<View
|
||||
key={coupon.id}
|
||||
className='flex rounded-lg border mb-2 overflow-hidden'
|
||||
style={{
|
||||
borderColor: isSelected ? (
|
||||
coupon.type === 40 ? '#3b82f6' :
|
||||
coupon.type === 50 ? '#06b6d4' :
|
||||
'#0e932e'
|
||||
) : '#f0f0f0',
|
||||
backgroundColor: isSelected ? (
|
||||
coupon.type === 40 ? '#eff6ff' :
|
||||
coupon.type === 50 ? '#ecfeff' :
|
||||
'#f0fdf4'
|
||||
) : '#fff',
|
||||
}}
|
||||
onClick={() => handleSelect(coupon)}
|
||||
>
|
||||
<View className='w-24 flex flex-col items-center justify-center py-3' style={{
|
||||
backgroundColor: coupon.type === 40 ? '#eff6ff' :
|
||||
coupon.type === 50 ? '#ecfeff' :
|
||||
'#f0fdf4'
|
||||
}}>
|
||||
{coupon.type === 20 ? (
|
||||
<View className='text-center'>
|
||||
<Text className='text-lg font-bold text-green-600 block'>
|
||||
{coupon.discount || 0}折
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500'>
|
||||
{coupon.minPrice && Number(coupon.minPrice) > 0 ? `满${coupon.minPrice}可用` : '无门槛'}
|
||||
</Text>
|
||||
</View>
|
||||
) : coupon.type === 50 ? (
|
||||
<View className='text-center'>
|
||||
<Text className='text-lg font-bold text-teal-600 block'>
|
||||
{coupon.useCount && coupon.useCount > 0 ? `${coupon.useCount}次` : '不限次'}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500'>
|
||||
{coupon.useDuration && coupon.useDuration > 0 ? `${coupon.useDuration}分钟` : '场地使用'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='text-center'>
|
||||
<Text className='text-xs font-medium block' style={{
|
||||
color: coupon.type === 40 ? '#3b82f6' : '#16a34a'
|
||||
}}>¥</Text>
|
||||
<Text className='text-xl font-bold block' style={{
|
||||
color: coupon.type === 40 ? '#3b82f6' : '#16a34a'
|
||||
}}>
|
||||
{coupon.reducePrice || 0}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500'>
|
||||
{coupon.type === 40 ? '无门槛' :
|
||||
(coupon.minPrice && Number(coupon.minPrice) > 0 ? `满${coupon.minPrice}可用` : '无门槛')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex-1 p-3 flex flex-col justify-between'>
|
||||
<Text className='text-sm text-gray-800 font-medium'>
|
||||
{coupon.name || coupon.description || '优惠券'}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{coupon.startTime?.slice(0, 10)} ~ {coupon.endTime?.slice(0, 10)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<View className='flex items-center pr-3'>
|
||||
<Text className='text-green-500 text-lg'>{'✓'}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CouponSelect
|
||||
27
src_bak/components/business/MemberBadge/index.tsx
Normal file
27
src_bak/components/business/MemberBadge/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
|
||||
interface MemberBadgeProps {
|
||||
levelName?: string
|
||||
size?: 'small' | 'normal'
|
||||
}
|
||||
|
||||
const MemberBadge: React.FC<MemberBadgeProps> = ({ levelName, size = 'normal' }) => {
|
||||
if (!levelName) return null
|
||||
|
||||
const sizeClass = size === 'small' ? 'text-xs px-1 py-0' : 'text-sm px-2 py-1'
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`inline-flex items-center rounded-full ${sizeClass}`}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
color: '#78350f',
|
||||
}}
|
||||
>
|
||||
<Text className='font-medium'>{levelName}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default MemberBadge
|
||||
123
src_bak/components/business/PayModal/index.tsx
Normal file
123
src_bak/components/business/PayModal/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Price from '@/components/common/Price'
|
||||
|
||||
interface PayModalProps {
|
||||
visible: boolean
|
||||
amount: string
|
||||
onClose: () => void
|
||||
onConfirm: (payType: number) => void
|
||||
}
|
||||
|
||||
const PAY_TYPES = [
|
||||
{ id: 1, name: '微信支付', desc: '推荐使用', icon: 'pay-wechat' },
|
||||
{ id: 0, name: '货到付款', desc: '使用账户余额', icon: 'pay-balance' },
|
||||
]
|
||||
|
||||
const PayModal: React.FC<PayModalProps> = ({ visible, amount, onClose, onConfirm }) => {
|
||||
const [selected, setSelected] = useState<number>(1)
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
const [slideUp, setSlideUp] = useState(false)
|
||||
|
||||
// 控制动画
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShowOverlay(true)
|
||||
setTimeout(() => setSlideUp(true), 10)
|
||||
} else {
|
||||
setSlideUp(false)
|
||||
setTimeout(() => setShowOverlay(false), 300)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleClose = () => {
|
||||
setSlideUp(false)
|
||||
setTimeout(() => {
|
||||
setShowOverlay(false)
|
||||
onClose()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
handleClose()
|
||||
onConfirm(selected)
|
||||
}
|
||||
|
||||
if (!showOverlay) return null
|
||||
|
||||
return (
|
||||
<View className='pay-modal-overlay'>
|
||||
{/* 遮罩层 */}
|
||||
<View
|
||||
className={`absolute inset-0 bg-black/50 z-50 ${slideUp ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<View
|
||||
className={`absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl z-50 ${slideUp ? '' : 'hidden'}`}
|
||||
>
|
||||
<View className='p-4'>
|
||||
{/* 关闭按钮 */}
|
||||
<View className='flex justify-end mb-2'>
|
||||
<View
|
||||
className='w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Text className='text-gray-400 text-sm'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='text-center py-3 border-b border-gray-100'>
|
||||
<Text className='text-base font-medium'>选择支付方式</Text>
|
||||
<View className='mt-2'>
|
||||
<Price price={amount} size='large' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='py-3'>
|
||||
{PAY_TYPES.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='flex items-center justify-between py-3 px-2 rounded-lg mb-1'
|
||||
style={{ backgroundColor: selected === item.id ? '#f0fdf4' : 'transparent' }}
|
||||
onClick={() => setSelected(item.id)}
|
||||
>
|
||||
<View className='flex items-center gap-3'>
|
||||
<View className='w-8 h-8 rounded-full bg-green-50 flex items-center justify-center'>
|
||||
<Text className='text-sm'>
|
||||
{item.id === 1 ? '微' : '余'}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className='text-sm text-gray-800'>{item.name}</Text>
|
||||
<Text className='text-xs text-gray-400'>{item.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selected === item.id ? 'border-green-500' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selected === item.id && (
|
||||
<View className='w-2 h-2 rounded-full bg-green-500' />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View
|
||||
className='w-full py-3 rounded-full text-center text-white text-sm font-medium mt-2'
|
||||
style={{ backgroundColor: '#0e932e' }}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Text className='text-white'>确认支付</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PayModal
|
||||
346
src_bak/components/business/SkuSelector/index.tsx
Normal file
346
src_bak/components/business/SkuSelector/index.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model'
|
||||
import Price from '@/components/common/Price'
|
||||
|
||||
interface SkuSelectorProps {
|
||||
visible: boolean
|
||||
product: ShopGoods | null
|
||||
mode?: 'cart' | 'buy'
|
||||
onClose: () => void
|
||||
onConfirm: (sku: ShopGoodsSku, quantity: number) => void
|
||||
}
|
||||
|
||||
// 将平铺的规格值列表转换为按规格ID分组的格式
|
||||
interface SpecGroup {
|
||||
specId: number
|
||||
specName: string
|
||||
values: Array<{ specValueId: number; specValue: string }>
|
||||
}
|
||||
|
||||
// 解析可能为JSON字符串的规格值
|
||||
const parseSpecValue = (value: string | undefined): string => {
|
||||
if (!value) return ''
|
||||
const trimmed = value.trim()
|
||||
// 处理带引号的JSON字符串,如 "红色" -> 红色
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const SkuSelector: React.FC<SkuSelectorProps> = ({
|
||||
visible,
|
||||
product,
|
||||
mode = 'cart',
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [selectedSpecs, setSelectedSpecs] = useState<Record<number, number>>({})
|
||||
const [selectedSku, setSelectedSku] = useState<ShopGoodsSku | null>(null)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
const [slideUp, setSlideUp] = useState(false)
|
||||
|
||||
// 控制动画
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShowOverlay(true)
|
||||
// 延迟一帧触发滑入动画
|
||||
setTimeout(() => setSlideUp(true), 10)
|
||||
} else {
|
||||
setSlideUp(false)
|
||||
setTimeout(() => setShowOverlay(false), 300)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 将平铺的goodsSpecs转换为按规格分组的格式
|
||||
const specGroups = useMemo<SpecGroup[]>(() => {
|
||||
if (!product?.goodsSpecs?.length) return []
|
||||
|
||||
const groupMap = new Map<number, SpecGroup>()
|
||||
|
||||
product.goodsSpecs.forEach((spec: ShopGoodsSpec, idx: number) => {
|
||||
if (!groupMap.has(spec.specId!)) {
|
||||
groupMap.set(spec.specId!, {
|
||||
specId: spec.specId!,
|
||||
specName: spec.specName || `规格${groupMap.size + 1}`,
|
||||
values: []
|
||||
})
|
||||
}
|
||||
groupMap.get(spec.specId!)!.values.push({
|
||||
specValueId: spec.id || idx, // 优先使用规格值ID,否则用索引
|
||||
specValue: parseSpecValue(spec.specValue)
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(groupMap.values())
|
||||
}, [product?.goodsSpecs])
|
||||
|
||||
// 当弹窗重新打开时,重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedSpecs({})
|
||||
setSelectedSku(null)
|
||||
setQuantity(1)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 根据已选规格查找匹配的 SKU
|
||||
useEffect(() => {
|
||||
if (!product?.goodsSkus?.length) {
|
||||
// 单规格商品,使用商品本身的价格
|
||||
setSelectedSku(null)
|
||||
return
|
||||
}
|
||||
|
||||
const specValues = Object.values(selectedSpecs)
|
||||
const specCount = specGroups.length
|
||||
|
||||
if (specValues.length < specCount) {
|
||||
setSelectedSku(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据选中的 specValueId 找到对应的中文值,拼接后排序匹配
|
||||
const selectedValues: string[] = []
|
||||
specGroups.forEach(group => {
|
||||
const valueId = selectedSpecs[group.specId]
|
||||
if (valueId !== undefined) {
|
||||
const value = group.values.find(v => v.specValueId === valueId)
|
||||
if (value) selectedValues.push(value.specValue)
|
||||
}
|
||||
})
|
||||
const selectedStr = selectedValues.sort().join('|')
|
||||
|
||||
const matched = product.goodsSkus.find(sku => {
|
||||
const skuValue = sku.sku || ''
|
||||
const skuParts = skuValue.split('|').map(s => parseSpecValue(s.trim()))
|
||||
const skuStr = skuParts.sort().join('|')
|
||||
return skuStr === selectedStr
|
||||
})
|
||||
|
||||
setSelectedSku(matched || null)
|
||||
}, [selectedSpecs, product, specGroups])
|
||||
|
||||
const handleSpecClick = (specId: number, valueId: number) => {
|
||||
setSelectedSpecs(prev => {
|
||||
const next = { ...prev }
|
||||
if (next[specId] === valueId) {
|
||||
delete next[specId]
|
||||
} else {
|
||||
next[specId] = valueId
|
||||
}
|
||||
return next
|
||||
})
|
||||
setQuantity(1)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSlideUp(false)
|
||||
setTimeout(() => {
|
||||
setShowOverlay(false)
|
||||
onClose()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
try {
|
||||
// 单规格商品(无SKU列表 或 SKU列表为空数组)
|
||||
if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) {
|
||||
const fakeSku: ShopGoodsSku = {
|
||||
id: 0,
|
||||
goodsId: product.goodsId!,
|
||||
price: product.price || product.salePrice,
|
||||
salePrice: product.salePrice || product.price,
|
||||
stock: product.stock,
|
||||
image: product.image,
|
||||
}
|
||||
handleClose()
|
||||
onConfirm(fakeSku, quantity)
|
||||
return
|
||||
}
|
||||
|
||||
// 多规格商品 - 必须已选择 SKU
|
||||
if (selectedSku) {
|
||||
handleClose()
|
||||
onConfirm(selectedSku, quantity)
|
||||
return
|
||||
}
|
||||
|
||||
// 有规格但未选择完整
|
||||
if (specGroups.length > 0) {
|
||||
Taro.showToast({ title: '请选择完整的规格', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 其他情况
|
||||
Taro.showToast({ title: '无法添加商品', icon: 'none' })
|
||||
} catch (err) {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 价格优先级:SKU售价 > SKU原价 > 商品售价 > 商品原价 > 0
|
||||
// 注意:ShopGoods 模型中 price=商品价格(低), salePrice=销售/市场价(高)
|
||||
// ShopGoodsSku 模型中 price=商品价格, salePrice=市场价格(高)
|
||||
// 统一以 price 为准,salePrice 仅作兜底展示
|
||||
const currentPrice = selectedSku?.price || selectedSku?.salePrice || product?.price || product?.salePrice || '0'
|
||||
const currentStock = selectedSku?.stock ?? product?.stock ?? 0
|
||||
const currentImage = selectedSku?.image || product?.image || (product?.files?.split(',')[0]) || ''
|
||||
|
||||
// 单规格商品可以确认,或者多规格商品已选择了 SKU
|
||||
const canConfirm = (!product?.goodsSkus || product.goodsSkus.length === 0) || selectedSku !== null
|
||||
|
||||
// 获取未选择完整的规格提示
|
||||
const getUnselectedSpec = () => {
|
||||
if (specGroups.length === 0) return null
|
||||
const unselected = specGroups.find(
|
||||
spec => !selectedSpecs[spec.specId]
|
||||
)
|
||||
return unselected?.specName
|
||||
}
|
||||
|
||||
// 获取选中的规格文字描述
|
||||
const getSelectedText = () => {
|
||||
const selected: string[] = []
|
||||
specGroups.forEach(group => {
|
||||
const valueId = selectedSpecs[group.specId]
|
||||
if (valueId !== undefined) {
|
||||
const value = group.values.find(v => v.specValueId === valueId)
|
||||
if (value) selected.push(value.specValue)
|
||||
}
|
||||
})
|
||||
return selected.length > 0 ? selected.join(', ') : '默认'
|
||||
}
|
||||
|
||||
// 如果不需要显示,直接返回null
|
||||
if (!showOverlay) return null
|
||||
|
||||
return (
|
||||
<View className='sku-selector-overlay'>
|
||||
{/* 遮罩层 */}
|
||||
<View
|
||||
className={`absolute inset-0 bg-black/50 ${slideUp ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ zIndex: 110 }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<View
|
||||
className={`absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl ${slideUp ? '' : 'hidden'}`}
|
||||
style={{ maxHeight: '70vh', zIndex: 110, paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<View className='p-4'>
|
||||
{/* 关闭按钮 */}
|
||||
<View className='flex justify-end mb-2'>
|
||||
<View
|
||||
className='w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Text className='text-gray-400 text-sm'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品信息 */}
|
||||
<View className='flex gap-3 pb-4 border-b border-gray-100'>
|
||||
<Image className='w-20 h-20 rounded-md bg-gray-100' src={currentImage} mode='aspectFill' />
|
||||
<View className='flex-1'>
|
||||
<Price price={currentPrice} size='large' />
|
||||
<Text className='text-xs text-gray-500 block' style={{ marginTop: '4px' }}>库存: {currentStock}</Text>
|
||||
<Text className='text-xs text-gray-400 block' style={{ marginTop: '2px' }}>
|
||||
已选: {getSelectedText()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 规格选择 */}
|
||||
<ScrollView scrollY style={{ maxHeight: '40vh' }}>
|
||||
{specGroups.map((group) => (
|
||||
<View key={group.specId} className='py-3 border-b border-gray-50'>
|
||||
<Text className='text-sm font-medium text-gray-700 block' style={{ marginBottom: '8px' }}>
|
||||
{group.specName}
|
||||
</Text>
|
||||
<View className='flex flex-wrap' style={{ gap: '8px' }}>
|
||||
{group.values.map((value) => {
|
||||
const isActive = selectedSpecs[group.specId] === value.specValueId
|
||||
return (
|
||||
<View
|
||||
key={value.specValueId}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
isActive
|
||||
? 'bg-green-50 text-green-600 border border-green-500'
|
||||
: 'bg-gray-50 text-gray-600 border border-gray-200'
|
||||
}`}
|
||||
onClick={() => handleSpecClick(group.specId, value.specValueId)}
|
||||
>
|
||||
<Text className={isActive ? 'text-green-600' : 'text-gray-600'}>{value.specValue}</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 无规格时显示提示 */}
|
||||
{specGroups.length === 0 && product?.goodsSkus?.length === 0 && (
|
||||
<View className='py-4 text-center'>
|
||||
<Text className='text-sm text-gray-400'>单规格商品</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 数量 */}
|
||||
<View className='py-3 flex items-center justify-between'>
|
||||
<Text className='text-sm text-gray-700'>数量</Text>
|
||||
<View className='flex items-center' style={{ gap: '12px' }}>
|
||||
<View
|
||||
className={`w-7 h-7 rounded-full border flex items-center justify-center ${
|
||||
quantity <= 1 ? 'border-gray-200 text-gray-300' : 'border-gray-300 text-gray-500'
|
||||
}`}
|
||||
onClick={() => quantity > 1 && setQuantity(prev => prev - 1)}
|
||||
>
|
||||
<Text className={quantity <= 1 ? 'text-gray-300' : 'text-gray-500'}>-</Text>
|
||||
</View>
|
||||
<Text className='text-sm font-medium w-8 text-center'>{quantity}</Text>
|
||||
<View
|
||||
className={`w-7 h-7 rounded-full border flex items-center justify-center ${
|
||||
quantity >= currentStock ? 'border-gray-200 text-gray-300' : 'border-gray-300 text-gray-500'
|
||||
}`}
|
||||
onClick={() => quantity < currentStock && setQuantity(prev => prev + 1)}
|
||||
>
|
||||
<Text className={quantity >= currentStock ? 'text-gray-300' : 'text-gray-500'}>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<View className='pt-3'>
|
||||
<View
|
||||
className={`w-full py-3 rounded-full text-center text-sm font-medium ${
|
||||
canConfirm ? 'text-white' : 'text-white/60'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: canConfirm ? '#0e932e' : '#ccc',
|
||||
opacity: canConfirm ? 1 : 0.7
|
||||
}}
|
||||
onClick={canConfirm ? handleConfirm : undefined}
|
||||
>
|
||||
<Text className={canConfirm ? 'text-white' : 'text-white/60'}>
|
||||
{getUnselectedSpec() ? `请选择${getUnselectedSpec()}` : (mode === 'cart' ? '加入购物车' : '立即购买')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default SkuSelector
|
||||
Reference in New Issue
Block a user