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

338 lines
8.7 KiB
TypeScript
Raw Normal View History

import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
2025-12-11 11:31:56 +00:00
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { cn } from '@/lib/utils';
2025-12-15 11:31:18 +00:00
import { useSize } from 'ahooks';
2025-11-13 08:38:25 +00:00
/**
2025-12-29 09:18:11 +00:00
*
*
*/
2025-12-29 09:18:11 +00:00
interface VirtualItemProps {
children: ReactNode;
rootMargin?: string;
placeholder?: ReactNode;
}
2025-12-29 09:18:11 +00:00
function VirtualItem({ children, rootMargin = '300px', placeholder }: VirtualItemProps) {
const ref = useRef<HTMLDivElement>(null);
2025-12-29 09:18:11 +00:00
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
2025-12-29 09:18:11 +00:00
setIsVisible(entry.isIntersecting);
},
{
rootMargin,
threshold: 0,
}
);
observer.observe(element);
return () => {
observer.disconnect();
};
2025-12-29 09:18:11 +00:00
}, [rootMargin]);
2025-12-29 09:18:11 +00:00
return <div ref={ref}>{isVisible ? children : placeholder}</div>;
}
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-15 11:31:18 +00:00
| number
| ((width: number) => 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-12-29 09:18:11 +00:00
*
*/
enableVirtualRender?: boolean;
/**
* px开始渲染300px
*/
2025-12-29 09:18:11 +00:00
virtualRenderMargin?: string;
/**
2025-12-29 09:18:11 +00:00
*
*/
2025-12-29 09:18:11 +00:00
VirtualPlaceholder?: React.ComponentType;
2025-11-13 08:38:25 +00:00
}
2025-12-29 09:18:11 +00:00
const defaultVirtualPlaceholder = () => (
<div className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl" />
);
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,
2025-12-29 09:18:11 +00:00
enableVirtualRender = true,
virtualRenderMargin = '300px',
VirtualPlaceholder = defaultVirtualPlaceholder,
2025-11-13 08:38:25 +00:00
}: InfiniteScrollListProps<T>) {
2025-12-15 11:31:18 +00:00
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
2025-11-13 08:38:25 +00:00
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-12-15 11:31:18 +00:00
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-13 08:38:25 +00:00
if (typeof columns === 'number') {
2025-12-11 11:31:56 +00:00
return gridClassMap[columns] || 'grid-cols-4';
2025-11-13 08:38:25 +00:00
}
2025-12-15 11:31:18 +00:00
if (typeof columns === 'function') {
const col = columns(size?.width || 0);
return gridClassMap[col] || '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(' ');
2025-12-15 11:31:18 +00:00
}, [columns, size?.width]);
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
2025-12-15 11:31:18 +00:00
let finalDom = (
<>
{/* 主要内容 */}
<div className={cn('grid', gridColsClass, gapClass, className)}>
{items.map((item, index) => {
const key = getItemKey(item, index);
const content = renderItem(item, index);
2025-12-29 09:18:11 +00:00
if (enableVirtualRender) {
return (
2025-12-29 09:18:11 +00:00
<VirtualItem
key={key}
2025-12-29 09:18:11 +00:00
rootMargin={virtualRenderMargin}
placeholder={<VirtualPlaceholder />}
>
{content}
2025-12-29 09:18:11 +00:00
</VirtualItem>
);
}
return <React.Fragment key={key}>{content}</React.Fragment>;
})}
2025-12-15 11:31:18 +00:00
</div>
{/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && (
2025-12-17 10:13:47 +00:00
<div ref={loadMoreRef} className="h-8 flex justify-center">
2025-12-15 11:31:18 +00:00
{LoadingMore ? (
<LoadingMore />
) : (
<div className="flex items-center gap-2">
{(isFetching || isLoading) && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white" />
)}
<span className="text-txt-tertiary-normal txt-label-s">
{isFetching || isLoading ? 'Loading...' : ''}
</span>
</div>
)}
</div>
)}
</>
);
2025-11-13 08:38:25 +00:00
// 错误状态
if (hasError && ErrorComponent && onRetry) {
2025-12-15 11:31:18 +00:00
finalDom = <ErrorComponent onRetry={onRetry} />;
2025-11-13 08:38:25 +00:00
}
// 首次加载状态
if (isLoading && items.length === 0) {
if (LoadingSkeleton) {
2025-12-15 11:31:18 +00:00
finalDom = (
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-12-15 11:31:18 +00:00
finalDom = (
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-15 11:31:18 +00:00
finalDom = <EmptyComponent />;
2025-11-13 08:38:25 +00:00
}
return (
2025-12-15 11:31:18 +00:00
<div className="w-full" ref={ref}>
{finalDom}
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
}