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
|
|
|
|
}
|