- 新增地址类型定义,增强前端地址数据结构 - 新增地址编辑页面,支持地址智能识别和定位选点功能 - 地址编辑支持省市区选择及默认地址设置 - 新增地址列表页面,支持地址展示、删除、编辑和选择功能 - 实现售后申请页面,支持选择售后类型和退款原因 - 售后申请支持商品选择、退款金额计算和凭证上传 - 新增售后详情页面,支持售后状态展示及申请取消 - 优化页面加载和用户交互体验,增加错误提示和权限处理
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
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
|