crush-level-web/src/components/ui/infinite-scroll-list.tsx

226 lines
5.1 KiB
TypeScript
Raw Normal View History

2025-11-28 06:31:36 +00:00
import React, { ReactNode } from 'react'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { cn } from '@/lib/utils'
2025-11-13 08:38:25 +00:00
interface InfiniteScrollListProps<T> {
/**
*
*/
2025-11-28 06:31:36 +00:00
items: T[]
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
renderItem: (item: T, index: number) => ReactNode
2025-11-13 08:38:25 +00:00
/**
* key
*/
2025-11-28 06:31:36 +00:00
getItemKey: (item: T, index: number) => string | number
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
hasNextPage: boolean
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
isLoading: boolean
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
fetchNextPage: () => void
2025-11-13 08:38:25 +00:00
/**
* className
*/
2025-11-28 06:31:36 +00:00
className?: string
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
columns?:
| {
default: number
sm?: number
md?: number
lg?: number
xl?: number
}
| number
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
gap?: number
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
LoadingSkeleton?: React.ComponentType
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
LoadingMore?: React.ComponentType
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
EmptyComponent?: React.ComponentType
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
ErrorComponent?: React.ComponentType<{ onRetry: () => void }>
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
hasError?: boolean
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
onRetry?: () => void
2025-11-13 08:38:25 +00:00
/**
* (px)
*/
2025-11-28 06:31:36 +00:00
threshold?: number
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
enabled?: boolean
2025-11-13 08:38:25 +00:00
}
/**
*
*
*/
export function InfiniteScrollList<T>({
items,
renderItem,
getItemKey,
hasNextPage,
isLoading,
fetchNextPage,
className,
columns = 4,
gap = 4,
LoadingSkeleton,
LoadingMore,
EmptyComponent,
ErrorComponent,
hasError = false,
onRetry,
threshold = 200,
enabled = true,
}: InfiniteScrollListProps<T>) {
const { loadMoreRef, isFetching } = useInfiniteScroll({
hasNextPage,
isLoading,
fetchNextPage,
threshold,
enabled,
2025-11-24 03:47:20 +00:00
isError: hasError,
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
// 生成网格列数的CSS类名映射
const getGridColsClass = () => {
if (typeof columns === 'number') {
const gridClassMap: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
6: 'grid-cols-6',
2025-11-28 06:31:36 +00:00
}
return gridClassMap[columns] || 'grid-cols-4'
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
const classes = []
2025-11-13 08:38:25 +00:00
const colsClassMap: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
6: 'grid-cols-6',
2025-11-28 06:31:36 +00:00
}
classes.push(colsClassMap[columns.default] || 'grid-cols-4')
if (columns.sm) classes.push(`sm:${colsClassMap[columns.sm]}`)
if (columns.md) classes.push(`md:${colsClassMap[columns.md]}`)
if (columns.lg) classes.push(`lg:${colsClassMap[columns.lg]}`)
if (columns.xl) classes.push(`xl:${colsClassMap[columns.xl]}`)
2025-11-13 08:38:25 +00:00
2025-11-28 06:31:36 +00:00
return classes.join(' ')
}
2025-11-13 08:38:25 +00:00
// 生成间距类名
const getGapClass = () => {
const gapClassMap: Record<number, string> = {
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
8: 'gap-8',
2025-11-28 06:31:36 +00:00
}
return gapClassMap[gap] || 'gap-4'
}
2025-11-13 08:38:25 +00:00
// 错误状态
if (hasError && ErrorComponent && onRetry) {
2025-11-28 06:31:36 +00:00
return <ErrorComponent onRetry={onRetry} />
2025-11-13 08:38:25 +00:00
}
// 首次加载状态
if (isLoading && items.length === 0) {
if (LoadingSkeleton) {
return (
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
{Array.from({ length: 8 }).map((_, index) => (
<LoadingSkeleton key={index} />
))}
</div>
2025-11-28 06:31:36 +00:00
)
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
return (
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
2025-11-28 06:31:36 +00:00
className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl"
2025-11-13 08:38:25 +00:00
/>
))}
</div>
2025-11-28 06:31:36 +00:00
)
2025-11-13 08:38:25 +00:00
}
// 空状态
if (items.length === 0 && EmptyComponent) {
2025-11-28 06:31:36 +00:00
return <EmptyComponent />
2025-11-13 08:38:25 +00:00
}
return (
<div className="w-full">
{/* 主要内容 */}
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
{items.map((item, index) => (
2025-11-28 06:31:36 +00:00
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
2025-11-13 08:38:25 +00:00
))}
</div>
2025-11-24 03:47:20 +00:00
{/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && (
2025-11-13 08:38:25 +00:00
<div ref={loadMoreRef} className="mt-8 flex justify-center">
{LoadingMore ? (
<LoadingMore />
) : (
<div className="flex items-center gap-2">
{(isFetching || isLoading) && (
2025-11-28 06:31:36 +00:00
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white" />
2025-11-13 08:38:25 +00:00
)}
<span className="text-txt-tertiary-normal txt-label-s">
{isFetching || isLoading ? 'Loading...' : ''}
</span>
</div>
)}
</div>
)}
</div>
2025-11-28 06:31:36 +00:00
)
2025-11-13 08:38:25 +00:00
}