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

342 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { cn } from '@/lib/utils';
import { useSize } from 'ahooks';
/**
* 懒渲染项组件
* 使用 IntersectionObserver 监控元素可见性,只渲染可见的内容
*/
interface LazyItemProps {
children: ReactNode;
rootMargin?: string;
placeholder?: ReactNode;
}
function LazyItem({ children, rootMargin = '300px', placeholder }: LazyItemProps) {
const ref = useRef<HTMLDivElement>(null);
const [hasBeenVisible, setHasBeenVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
// 如果已经渲染过,不需要再观察
if (hasBeenVisible) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHasBeenVisible(true);
// ✅ 渲染后立即断开观察,释放资源
observer.disconnect();
}
},
{
rootMargin,
threshold: 0,
}
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [rootMargin, hasBeenVisible]);
return (
<div ref={ref}>
{hasBeenVisible ? children : placeholder || <div className="aspect-[3/4]" />}
</div>
);
}
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?:
| {
xs?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
}
| number
| ((width: 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;
/**
* 是否启用懒渲染(只渲染可见区域的内容)
*/
enableLazyRender?: boolean;
/**
* 懒渲染的根边距提前多少px开始渲染
*/
lazyRenderMargin?: string;
}
/**
* 通用的无限滚动列表组件
* 支持网格布局、骨架屏、错误状态等功能
*/
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,
enableLazyRender = false,
lazyRenderMargin = '300px',
}: InfiniteScrollListProps<T>) {
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
const { loadMoreRef, isFetching } = useInfiniteScroll({
hasNextPage,
isLoading,
fetchNextPage,
threshold,
enabled,
isError: hasError,
});
// 生成网格列数的CSS类名映射
const gridColsClass = useMemo(() => {
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',
};
if (typeof columns === 'number') {
return gridClassMap[columns] || 'grid-cols-4';
}
if (typeof columns === 'function') {
const col = columns(size?.width || 0);
return gridClassMap[col] || 'grid-cols-4';
}
// 使用完整的类名字符串,让 Tailwind 能够正确识别
const classes: string[] = [];
// 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');
// 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, size?.width]);
// 生成间距类名
const gapClass = useMemo(() => {
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';
}, [gap]);
let finalDom = (
<>
{/* 主要内容 */}
<div className={cn('grid', gridColsClass, gapClass, className)}>
{items.map((item, index) => {
const key = getItemKey(item, index);
const content = renderItem(item, index);
if (enableLazyRender) {
return (
<LazyItem
key={key}
rootMargin={lazyRenderMargin}
placeholder={
<div className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl" />
}
>
{content}
</LazyItem>
);
}
return <React.Fragment key={key}>{content}</React.Fragment>;
})}
</div>
{/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && (
<div ref={loadMoreRef} className="h-8 flex justify-center">
{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>
)}
</>
);
// 错误状态
if (hasError && ErrorComponent && onRetry) {
finalDom = <ErrorComponent onRetry={onRetry} />;
}
// 首次加载状态
if (isLoading && items.length === 0) {
if (LoadingSkeleton) {
finalDom = (
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<LoadingSkeleton key={index} />
))}
</div>
);
}
finalDom = (
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl"
/>
))}
</div>
);
}
// 空状态
if (items.length === 0 && EmptyComponent) {
finalDom = <EmptyComponent />;
}
return (
<div className="w-full" ref={ref}>
{finalDom}
</div>
);
}