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

253 lines
6.5 KiB
TypeScript
Raw Normal View History

2025-12-11 11:31:56 +00:00
import React, { ReactNode, useMemo } from 'react';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { cn } from '@/lib/utils';
2025-11-13 08:38:25 +00:00
interface InfiniteScrollListProps<T> {
/**
*
*/
2025-12-11 11:31:56 +00:00
items: T[];
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
renderItem: (item: T, index: number) => ReactNode;
2025-11-13 08:38:25 +00:00
/**
* key
*/
2025-12-11 11:31:56 +00:00
getItemKey: (item: T, index: number) => string | number;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
hasNextPage: boolean;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
isLoading: boolean;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
fetchNextPage: () => void;
2025-11-13 08:38:25 +00:00
/**
* className
*/
2025-12-11 11:31:56 +00:00
className?: string;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-11-28 06:31:36 +00:00
columns?:
| {
2025-12-11 11:31:56 +00:00
xs?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
2025-11-28 06:31:36 +00:00
}
2025-12-11 11:31:56 +00:00
| number;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
gap?: number;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
LoadingSkeleton?: React.ComponentType;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
LoadingMore?: React.ComponentType;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
EmptyComponent?: React.ComponentType;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
ErrorComponent?: React.ComponentType<{ onRetry: () => void }>;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
hasError?: boolean;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +00:00
onRetry?: () => void;
2025-11-13 08:38:25 +00:00
/**
* (px)
*/
2025-12-11 11:31:56 +00:00
threshold?: number;
2025-11-13 08:38:25 +00:00
/**
*
*/
2025-12-11 11:31:56 +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-12-11 11:31:56 +00:00
});
2025-11-13 08:38:25 +00:00
// 生成网格列数的CSS类名映射
2025-12-11 11:31:56 +00:00
const gridColsClass = useMemo(() => {
2025-11-13 08:38:25 +00:00
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-12-11 11:31:56 +00:00
};
return gridClassMap[columns] || 'grid-cols-4';
2025-11-13 08:38:25 +00:00
}
2025-12-11 11:31:56 +00:00
// 使用完整的类名字符串,让 Tailwind 能够正确识别
const classes: string[] = [];
2025-11-28 06:31:36 +00:00
2025-12-11 11:31:56 +00:00
// xs 断点
if (columns.xs === 1) classes.push('xs:grid-cols-1');
if (columns.xs === 2) classes.push('xs:grid-cols-2');
if (columns.xs === 3) classes.push('xs:grid-cols-3');
if (columns.xs === 4) classes.push('xs:grid-cols-4');
if (columns.xs === 5) classes.push('xs:grid-cols-5');
if (columns.xs === 6) classes.push('xs:grid-cols-6');
2025-11-13 08:38:25 +00:00
2025-12-11 11:31:56 +00:00
// sm 断点
if (columns.sm === 1) classes.push('sm:grid-cols-1');
if (columns.sm === 2) classes.push('sm:grid-cols-2');
if (columns.sm === 3) classes.push('sm:grid-cols-3');
if (columns.sm === 4) classes.push('sm:grid-cols-4');
if (columns.sm === 5) classes.push('sm:grid-cols-5');
if (columns.sm === 6) classes.push('sm:grid-cols-6');
// md 断点
if (columns.md === 1) classes.push('md:grid-cols-1');
if (columns.md === 2) classes.push('md:grid-cols-2');
if (columns.md === 3) classes.push('md:grid-cols-3');
if (columns.md === 4) classes.push('md:grid-cols-4');
if (columns.md === 5) classes.push('md:grid-cols-5');
if (columns.md === 6) classes.push('md:grid-cols-6');
// lg 断点
if (columns.lg === 1) classes.push('lg:grid-cols-1');
if (columns.lg === 2) classes.push('lg:grid-cols-2');
if (columns.lg === 3) classes.push('lg:grid-cols-3');
if (columns.lg === 4) classes.push('lg:grid-cols-4');
if (columns.lg === 5) classes.push('lg:grid-cols-5');
if (columns.lg === 6) classes.push('lg:grid-cols-6');
// xl 断点
if (columns.xl === 1) classes.push('xl:grid-cols-1');
if (columns.xl === 2) classes.push('xl:grid-cols-2');
if (columns.xl === 3) classes.push('xl:grid-cols-3');
if (columns.xl === 4) classes.push('xl:grid-cols-4');
if (columns.xl === 5) classes.push('xl:grid-cols-5');
if (columns.xl === 6) classes.push('xl:grid-cols-6');
return classes.join(' ');
}, [columns]);
2025-11-13 08:38:25 +00:00
// 生成间距类名
2025-12-11 11:31:56 +00:00
const gapClass = useMemo(() => {
2025-11-13 08:38:25 +00:00
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-12-11 11:31:56 +00:00
};
return gapClassMap[gap] || 'gap-4';
}, [gap]);
2025-11-13 08:38:25 +00:00
// 错误状态
if (hasError && ErrorComponent && onRetry) {
2025-12-11 11:31:56 +00:00
return <ErrorComponent onRetry={onRetry} />;
2025-11-13 08:38:25 +00:00
}
// 首次加载状态
if (isLoading && items.length === 0) {
if (LoadingSkeleton) {
return (
2025-12-11 11:31:56 +00:00
<div className={cn('grid', gridColsClass, gapClass, className)}>
2025-11-13 08:38:25 +00:00
{Array.from({ length: 8 }).map((_, index) => (
<LoadingSkeleton key={index} />
))}
</div>
2025-12-11 11:31:56 +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 (
2025-12-11 11:31:56 +00:00
<div className={cn('grid', gridColsClass, gapClass, className)}>
2025-11-13 08:38:25 +00:00
{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-12-11 11:31:56 +00:00
);
2025-11-13 08:38:25 +00:00
}
// 空状态
if (items.length === 0 && EmptyComponent) {
2025-12-11 11:31:56 +00:00
return <EmptyComponent />;
2025-11-13 08:38:25 +00:00
}
return (
<div className="w-full">
{/* 主要内容 */}
2025-12-11 11:31:56 +00:00
<div className={cn('grid', gridColsClass, gapClass, className)}>
2025-11-13 08:38:25 +00:00
{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-12-11 11:31:56 +00:00
);
2025-11-13 08:38:25 +00:00
}