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

245 lines
6.0 KiB
TypeScript
Raw Normal View History

2025-11-13 08:38:25 +00:00
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,
});
// 生成网格列数的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 && (
<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>
);