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

246 lines
6.1 KiB
TypeScript

import React, { ReactNode } from 'react';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { cn } from '@/lib/utils';
interface InfiniteScrollListProps<T> {
/**
* 数据项数组
*/
items: T[];
/**
* 渲染每个数据项的函数
*/
renderItem: (item: T, index: number) => ReactNode;
/**
* 获取每个数据项的唯一key
*/
getItemKey: (item: T, index: number) => string | number;
/**
* 是否有更多数据可以加载
*/
hasNextPage: boolean;
/**
* 是否正在加载
*/
isLoading: boolean;
/**
* 加载下一页的函数
*/
fetchNextPage: () => void;
/**
* 列表容器的className
*/
className?: string;
/**
* 网格列数(支持响应式)
*/
columns?: {
default: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
} | number;
/**
* 网格间距
*/
gap?: number;
/**
* 加载状态的骨架屏组件
*/
LoadingSkeleton?: React.ComponentType;
/**
* 加载更多时显示的组件
*/
LoadingMore?: React.ComponentType;
/**
* 空状态组件
*/
EmptyComponent?: React.ComponentType;
/**
* 错误状态组件
*/
ErrorComponent?: React.ComponentType<{ onRetry: () => void }>;
/**
* 是否有错误
*/
hasError?: boolean;
/**
* 重试函数
*/
onRetry?: () => void;
/**
* 触发加载的阈值(px)
*/
threshold?: number;
/**
* 是否启用无限滚动
*/
enabled?: boolean;
}
/**
* 通用的无限滚动列表组件
* 支持网格布局、骨架屏、错误状态等功能
*/
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,
isError: hasError,
});
// 生成网格列数的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',
};
return gridClassMap[columns] || 'grid-cols-4';
}
const classes = [];
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',
};
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]}`);
return classes.join(' ');
};
// 生成间距类名
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',
};
return gapClassMap[gap] || 'gap-4';
};
// 错误状态
if (hasError && ErrorComponent && onRetry) {
return <ErrorComponent onRetry={onRetry} />;
}
// 首次加载状态
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>
);
}
return (
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="aspect-[3/4] bg-surface-nest-normal rounded-2xl animate-pulse"
/>
))}
</div>
);
}
// 空状态
if (items.length === 0 && EmptyComponent) {
return <EmptyComponent />;
}
return (
<div className="w-full">
{/* 主要内容 */}
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
{items.map((item, index) => (
<React.Fragment key={getItemKey(item, index)}>
{renderItem(item, index)}
</React.Fragment>
))}
</div>
{/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && (
<div ref={loadMoreRef} className="mt-8 flex justify-center">
{LoadingMore ? (
<LoadingMore />
) : (
<div className="flex items-center gap-2">
{(isFetching || isLoading) && (
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
)}
<span className="text-txt-tertiary-normal txt-label-s">
{isFetching || isLoading ? 'Loading...' : ''}
</span>
</div>
)}
</div>
)}
</div>
);
}
// 默认的加载骨架屏组件
export const DefaultAlbumSkeleton = () => (
<div className="relative pb-[134%] rounded-2xl overflow-hidden bg-gray-800 animate-pulse">
<div className="absolute inset-0">
<div className="w-full h-full bg-gray-700 rounded-2xl" />
{/* 模拟标签 */}
<div className="absolute top-2 left-2 w-12 h-6 bg-gray-600 rounded-full" />
{/* 模拟点赞按钮 */}
<div className="absolute bottom-2 left-2 w-16 h-6 bg-gray-600 rounded-full" />
</div>
</div>
);
// 默认的加载更多组件
export const DefaultLoadingMore = () => (
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-element-normal">
<div className="w-4 h-4 border-2 border-primary-normal/20 border-t-primary-normal rounded-full animate-spin" />
<span className="text-txt-secondary-normal text-sm">Loading...</span>
</div>
);