feat(user): 新增收货地址管理及售后申请页面
- 新增地址类型定义,增强前端地址数据结构 - 新增地址编辑页面,支持地址智能识别和定位选点功能 - 地址编辑支持省市区选择及默认地址设置 - 新增地址列表页面,支持地址展示、删除、编辑和选择功能 - 实现售后申请页面,支持选择售后类型和退款原因 - 售后申请支持商品选择、退款金额计算和凭证上传 - 新增售后详情页面,支持售后状态展示及申请取消 - 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
59
src_bak/components/common/BottomButton/index.scss
Normal file
59
src_bak/components/common/BottomButton/index.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
/* 底部悬浮按钮样式 */
|
||||
|
||||
.bottom-button-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
background-color: #ffffff;
|
||||
padding: 12PX 16PX;
|
||||
padding-bottom: 24PX;
|
||||
border-top: 1PX solid #f0f0f0;
|
||||
}
|
||||
|
||||
.bottom-btn-subtitle {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.subtitle-price {
|
||||
color: #ff6600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bottom-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background-color: #0e932e;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
border-radius: 44rpx;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 88rpx;
|
||||
}
|
||||
|
||||
.bottom-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.bottom-btn[disabled] {
|
||||
background-color: #cccccc;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bottom-btn[loading] {
|
||||
background-color: #0e932e;
|
||||
opacity: 0.8;
|
||||
}
|
||||
42
src_bak/components/common/BottomButton/index.tsx
Normal file
42
src_bak/components/common/BottomButton/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { View, Button, Text } from '@tarojs/components'
|
||||
import styles from './index.scss'
|
||||
|
||||
interface BottomButtonProps {
|
||||
text?: string
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
color?: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const BottomButton: React.FC<BottomButtonProps> = ({
|
||||
text = '确认',
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
color = '#0e932e',
|
||||
subtitle
|
||||
}) => {
|
||||
return (
|
||||
<View className='px-4 fixed bottom-5 w-full bg-white'>
|
||||
{subtitle && (
|
||||
<View className='bottom-btn-subtitle'>
|
||||
<Text className='text-sm text-gray-500'>{subtitle}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
className='bottom-btn text-white rounded-full'
|
||||
disabled={disabled || loading}
|
||||
loading={loading}
|
||||
onClick={onClick}
|
||||
style={{ backgroundColor: color, borderColor: color }}
|
||||
>
|
||||
{loading ? '加载中...' : text}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default BottomButton
|
||||
48
src_bak/components/common/EmptyState/index.tsx
Normal file
48
src_bak/components/common/EmptyState/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Button } from '@nutui/nutui-react-taro'
|
||||
|
||||
interface EmptyStateProps {
|
||||
image?: string
|
||||
text?: string
|
||||
description?: string
|
||||
actionText?: string
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LXNpemU9IjE0IiBmaWxsPSIjY2NjIj7or77nqIvliY3liIY8L3RleHQ+PC9zdmc+'
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
image = DEFAULT_IMAGE,
|
||||
text = '暂无数据',
|
||||
description,
|
||||
actionText,
|
||||
onAction,
|
||||
}) => {
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center py-12'>
|
||||
<View className='w-24 h-24 mb-4 rounded-full overflow-hidden'>
|
||||
<View
|
||||
className='w-full h-full bg-cover bg-center bg-no-repeat'
|
||||
style={{ backgroundImage: `url(${image})` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className='text-sm text-gray-500'>{text}</Text>
|
||||
{description && (
|
||||
<Text className='text-xs text-gray-400 mt-1'>{description}</Text>
|
||||
)}
|
||||
{actionText && onAction && (
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
className='mt-4 rounded-full px-6'
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
43
src_bak/components/common/FixedButton/index.tsx
Normal file
43
src_bak/components/common/FixedButton/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { View } from '@tarojs/components'
|
||||
import { Button } from '@nutui/nutui-react-taro'
|
||||
|
||||
interface FixedButtonProps {
|
||||
text?: string
|
||||
onClick?: () => void
|
||||
icon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
background?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部固定按钮组件
|
||||
* 用于地址列表"新增地址"、地址编辑"保存"等底部固定操作
|
||||
*/
|
||||
const FixedButton: React.FC<FixedButtonProps> = ({ text, onClick, icon, disabled, background }) => {
|
||||
return (
|
||||
<>
|
||||
{/* 底部安全区域占位 */}
|
||||
<View className="h-20 w-full" />
|
||||
<View
|
||||
className="z-50 bg-white border-t border-gray-200 px-4 py-3"
|
||||
style={{ paddingBottom: '12px', flexShrink: 0 }}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ background }}
|
||||
size="large"
|
||||
block
|
||||
icon={icon}
|
||||
disabled={disabled}
|
||||
className="px-6"
|
||||
onClick={onClick}
|
||||
>
|
||||
{text || '确定'}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FixedButton
|
||||
57
src_bak/components/common/LazyImage/index.tsx
Normal file
57
src_bak/components/common/LazyImage/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { View, Image, Text } from '@tarojs/components';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntersectionObserver } from '@tarojs/taro';
|
||||
|
||||
interface LazyImageProps {
|
||||
src: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
mode?: 'scaleToFill' | 'aspectFit' | 'aspectFill' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'center' | 'left' | 'right' | 'top left' | 'top right' | 'bottom left' | 'bottom right';
|
||||
}
|
||||
|
||||
export default function LazyImage({ src, placeholder, className, style, mode = 'aspectFill' }: LazyImageProps) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [inView, setInView] = useState(false);
|
||||
const { intersectionObserver } = useIntersectionObserver();
|
||||
|
||||
useEffect(() => {
|
||||
if (!intersectionObserver) return;
|
||||
|
||||
intersectionObserver.relativeToViewport();
|
||||
intersectionObserver.observe({
|
||||
type: 'relativeToViewport',
|
||||
success: (res) => {
|
||||
if (res.intersectionRatio > 0) {
|
||||
setInView(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [intersectionObserver]);
|
||||
|
||||
return (
|
||||
<View className={className} style={style}>
|
||||
{!loaded && (
|
||||
<View className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
{placeholder ? (
|
||||
<Image src={placeholder} className="w-full h-full" mode={mode} />
|
||||
) : (
|
||||
<Text className="text-gray-400 text-sm">加载中...</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{(inView || loaded) && (
|
||||
<Image
|
||||
src={src}
|
||||
className={`w-full h-full ${loaded ? 'block' : 'hidden'}`}
|
||||
mode={mode}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
29
src_bak/components/common/LoadMore/index.tsx
Normal file
29
src_bak/components/common/LoadMore/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
|
||||
interface LoadMoreProps {
|
||||
loading?: boolean
|
||||
finished?: boolean
|
||||
}
|
||||
|
||||
const LoadMore: React.FC<LoadMoreProps> = ({ loading = false, finished = false }) => {
|
||||
if (finished) {
|
||||
return (
|
||||
<View className='py-4 flex justify-center'>
|
||||
<Text className='text-xs text-gray-400'>- 没有更多了 -</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='py-4 flex justify-center'>
|
||||
<Text className='text-xs text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default LoadMore
|
||||
48
src_bak/components/common/Loading/index.tsx
Normal file
48
src_bak/components/common/Loading/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
|
||||
interface LoadingProps {
|
||||
/** 提示文字,默认"加载中..." */
|
||||
text?: string
|
||||
/** 是否全屏居中显示 */
|
||||
fullscreen?: boolean
|
||||
/** 自定义样式类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一加载状态组件
|
||||
* - 小程序兼容(无 CSS transition / animation 依赖)
|
||||
* - 支持内嵌和全屏两种模式
|
||||
*/
|
||||
const Loading: React.FC<LoadingProps> = ({
|
||||
text = '加载中...',
|
||||
fullscreen = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const content = (
|
||||
<View
|
||||
className={`flex flex-col items-center justify-center py-8 ${fullscreen ? 'fixed inset-0 z-50 bg-white/80' : ''} ${className}`}
|
||||
>
|
||||
{/* 简单的旋转加载指示器 */}
|
||||
<View
|
||||
className='w-7 h-7 rounded-full border-2 border-gray-200 mb-2'
|
||||
style={{
|
||||
borderTopColor: '#0e932e',
|
||||
borderRightColor: '#0e932e',
|
||||
animation: 'spin 0.8s linear infinite' as unknown as string,
|
||||
}}
|
||||
/>
|
||||
<Text className='text-xs text-gray-400'>{text}</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
// 全屏模式需要外层容器
|
||||
if (fullscreen) {
|
||||
return <>{content}</>
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
export default Loading
|
||||
137
src_bak/components/common/OrderCard/index.tsx
Normal file
137
src_bak/components/common/OrderCard/index.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { ShopOrder } from '@/api/shop/shopOrder/model'
|
||||
import Price from '../Price'
|
||||
|
||||
interface OrderCardProps {
|
||||
order: ShopOrder
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/** 综合 payStatus + deliveryStatus + orderStatus 计算列表卡片状态文案 */
|
||||
const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
|
||||
const { payStatus, deliveryStatus, orderStatus } = order
|
||||
|
||||
if (orderStatus === 2) return { text: '已取消', color: '#999' }
|
||||
if (orderStatus === 3) return { text: '取消中', color: '#ff7d00' }
|
||||
if (orderStatus === 6) return { text: '已退款', color: '#999' }
|
||||
if (orderStatus === 4 || orderStatus === 7) return { text: '退款申请中', color: '#ee0a24' }
|
||||
if (orderStatus === 5) return { text: '退款被拒绝', color: '#ee0a24' }
|
||||
|
||||
if (!payStatus) return { text: '待付款', color: '#ee0a24' }
|
||||
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 20 || deliveryStatus === 30) return { text: '待收货', color: '#4b9cf5' }
|
||||
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
|
||||
|
||||
return { text: '已付款', color: '#0e932e' }
|
||||
}
|
||||
|
||||
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/order/detail?id=${order.orderId}` })
|
||||
}
|
||||
|
||||
const { text: statusText, color: statusColor } = getCardStatus(order)
|
||||
|
||||
// 获取首个商品图片作为封面,或显示默认占位
|
||||
const coverImage = order.orderGoods?.[0]?.image || ''
|
||||
const hasGoods = (order.orderGoods?.length || 0) > 0
|
||||
|
||||
return (
|
||||
<View className='bg-white rounded-lg p-3 mb-3 shadow-sm' onClick={handleClick}>
|
||||
{/* 头部:订单号 + 状态 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-sm font-medium text-gray-800'>
|
||||
订单号: {order.orderNo}
|
||||
</Text>
|
||||
<Text className='text-sm font-medium' style={{ color: statusColor }}>
|
||||
{statusText}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 商品信息区域 */}
|
||||
{hasGoods ? (
|
||||
<View className='mb-3'>
|
||||
{order.orderGoods?.map((item, idx) => (
|
||||
<View key={idx} className='flex flex-row items-start mb-2 last:mb-0'>
|
||||
{/* 商品缩略图 */}
|
||||
<View className='w-16 h-16 rounded-md bg-gray-100 flex-shrink-0 overflow-hidden'>
|
||||
{item.image ? (
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={item.image}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-xs text-gray-400'>暂无图片</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 商品信息 */}
|
||||
<View className='flex-1 ml-2 flex flex-col justify-between h-16'>
|
||||
<View>
|
||||
<Text className='text-sm text-gray-800 font-medium line-clamp-1'>
|
||||
{item.goodsName || '未知商品'}
|
||||
</Text>
|
||||
{item.spec ? (
|
||||
<Text className='text-xs text-gray-500 mt-1'>
|
||||
规格: {item.spec}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex justify-between items-center'>
|
||||
<Price price={item.price || '0'} size='small' />
|
||||
<Text className='text-xs text-gray-500'>
|
||||
x{item.totalNum || 1}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
/* 无商品信息时的 fallback */
|
||||
<View className='flex flex-row items-start mb-3'>
|
||||
<View className='w-16 h-16 rounded-md bg-gray-100 flex-shrink-0 flex items-center justify-center overflow-hidden'>
|
||||
{coverImage ? (
|
||||
<Image className='w-full h-full' src={coverImage} mode='aspectFill' />
|
||||
) : (
|
||||
<Text className='text-xs text-gray-400'>暂无图片</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1 ml-2 flex flex-col justify-center h-16'>
|
||||
<Text className='text-sm text-gray-800 font-medium line-clamp-2'>
|
||||
{order.title || '订单商品'}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500 mt-1'>
|
||||
共{order.totalNum || 0}件
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 底部:日期 + 金额汇总 */}
|
||||
<View className='flex justify-between items-center pt-2 border-t border-gray-50'>
|
||||
<Text className='text-xs text-gray-500'>
|
||||
{order.createTime?.slice(0, 10)}
|
||||
</Text>
|
||||
<View className='flex items-center'>
|
||||
<Text className='text-xs text-gray-500 mr-1'>
|
||||
共{order.totalNum || order.orderGoods?.reduce((sum, g) => sum + (g.totalNum || 1), 0) || 0}件
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500 mr-1'>实付</Text>
|
||||
<Price price={order.payPrice || order.totalPrice || '0'} size='small' />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderCard
|
||||
67
src_bak/components/common/Price/index.tsx
Normal file
67
src_bak/components/common/Price/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
|
||||
interface PriceProps {
|
||||
price: string | number
|
||||
original?: string | number
|
||||
size?: 'small' | 'normal' | 'large'
|
||||
color?: string
|
||||
symbol?: string
|
||||
/**
|
||||
* 游客脱敏模式:
|
||||
* - 当用户未登录时,**不显示价格**,改成"登录后查看"占位文案
|
||||
* - 用于过微信审核:游客可浏览商品列表/详情,但价格需登录后可见
|
||||
* - 不传则维持原行为(游客也显示价格)
|
||||
*/
|
||||
loginMask?: boolean
|
||||
/**
|
||||
* 自定义脱敏文案(仅在 loginMask=true 且游客态下生效)
|
||||
*/
|
||||
maskText?: string
|
||||
}
|
||||
|
||||
const Price: React.FC<PriceProps> = ({
|
||||
price,
|
||||
original,
|
||||
size = 'normal',
|
||||
color = '#ee0a24',
|
||||
symbol = '¥',
|
||||
loginMask = false,
|
||||
maskText = '登录后查看价格',
|
||||
}) => {
|
||||
const fmt = (p: string | number) => {
|
||||
const num = typeof p === 'string' ? parseFloat(p) : p
|
||||
return isNaN(num) ? '0.00' : num.toFixed(2)
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: { mask: 'text-xs', symbol: 'text-xs', integer: 'text-sm font-medium', decimal: 'text-xs' },
|
||||
normal: { mask: 'text-sm', symbol: 'text-sm', integer: 'text-lg font-bold', decimal: 'text-sm' },
|
||||
large: { mask: 'text-base', symbol: 'text-lg', integer: 'text-2xl font-bold', decimal: 'text-lg' },
|
||||
}
|
||||
|
||||
const cls = sizeMap[size]
|
||||
|
||||
// 游客态 + 开启脱敏 -> 显示占位文案
|
||||
if (loginMask && isGuest()) {
|
||||
return (
|
||||
<View className='flex items-baseline'>
|
||||
<Text className={`${cls.mask} text-gray-400`}>{maskText}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='flex items-baseline'>
|
||||
<Text className={`${cls.symbol} font-medium`} style={{ color }}>{symbol}</Text>
|
||||
<Text className={cls.integer} style={{ color }}>{fmt(price).split('.')[0]}</Text>
|
||||
<Text className={cls.decimal} style={{ color }}>.{fmt(price).split('.')[1]}</Text>
|
||||
{original && (
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>¥{fmt(original)}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default Price
|
||||
59
src_bak/components/common/ProductCard/index.tsx
Normal file
59
src_bak/components/common/ProductCard/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import Price from '../Price'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ShopGoods
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${product.goodsId}` })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='bg-white rounded-lg overflow-hidden shadow-sm' onClick={handleClick}>
|
||||
<View className='w-full' style={{ paddingTop: '100%', position: 'relative' }}>
|
||||
<Image
|
||||
className='absolute top-0 left-0 w-full h-full'
|
||||
src={product.image || product.files || ''}
|
||||
mode='aspectFit'
|
||||
lazyLoad
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
|
||||
/>
|
||||
</View>
|
||||
<View className='p-2'>
|
||||
<Text className='text-sm text-gray-800 line-clamp-2 leading-5'>
|
||||
{product.name || product.goodsName}
|
||||
</Text>
|
||||
<View className='flex items-end justify-between mt-2'>
|
||||
<View className='flex-1'>
|
||||
<Price price={product.price || '0'} size='small' loginMask />
|
||||
{product.salePrice && product.salePrice !== product.price && (
|
||||
isGuest() ? null : (
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||
¥{product.salePrice}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
{product.sales !== undefined && product.sales > 0 && (
|
||||
<Text className='text-xs text-gray-400'>
|
||||
已售{product.sales}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductCard
|
||||
69
src_bak/components/common/VirtualList/index.tsx
Normal file
69
src_bak/components/common/VirtualList/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { View, ScrollView } from '@tarojs/components';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface VirtualListProps<T> {
|
||||
data: T[];
|
||||
itemHeight: number;
|
||||
containerHeight: number;
|
||||
renderItem: (item: T, index: number) => JSX.Element;
|
||||
keyExtractor: (item: T, index: number) => string | number;
|
||||
onEndReached?: () => void;
|
||||
onEndReachedThreshold?: number;
|
||||
}
|
||||
|
||||
export default function VirtualList<T>({
|
||||
data,
|
||||
itemHeight,
|
||||
containerHeight,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
onEndReachedThreshold = 0
|
||||
}: VirtualListProps<T>) {
|
||||
const [startIndex, setStartIndex] = useState(0);
|
||||
const [endIndex, setEndIndex] = useState(Math.ceil(containerHeight / itemHeight) + 5);
|
||||
const scrollViewRef = useRef<any>(null);
|
||||
|
||||
const visibleItemCount = Math.ceil(containerHeight / itemHeight) + 10;
|
||||
const offsetY = startIndex * itemHeight;
|
||||
|
||||
const handleScroll = useCallback((e: any) => {
|
||||
const scrollTop = e.detail.scrollTop;
|
||||
const newStartIndex = Math.floor(scrollTop / itemHeight);
|
||||
const newEndIndex = newStartIndex + visibleItemCount;
|
||||
|
||||
setStartIndex(Math.max(0, newStartIndex - 5));
|
||||
setEndIndex(Math.min(data.length, newEndIndex + 5));
|
||||
|
||||
// 触底加载
|
||||
if (onEndReached && onEndReachedThreshold > 0) {
|
||||
const { scrollHeight, clientHeight } = e.detail;
|
||||
if (scrollHeight - scrollTop - clientHeight < onEndReachedThreshold) {
|
||||
onEndReached();
|
||||
}
|
||||
}
|
||||
}, [itemHeight, visibleItemCount, data.length, onEndReached, onEndReachedThreshold]);
|
||||
|
||||
const visibleData = data.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
className="w-full"
|
||||
style={{ height: containerHeight }}
|
||||
scrollY
|
||||
onScroll={handleScroll}
|
||||
scrollWithAnimation
|
||||
>
|
||||
<View style={{ height: data.length * itemHeight, position: 'relative' }}>
|
||||
<View style={{ transform: `translateY(${offsetY}px)` }}>
|
||||
{visibleData.map((item, index) => (
|
||||
<View key={keyExtractor(item, startIndex + index)} style={{ height: itemHeight }}>
|
||||
{renderItem(item, startIndex + index)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user