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

338 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';
/**
* 虚拟渲染项组件
* 只渲染在视口内或附近的内容,离开视口时卸载
*/
interface VirtualItemProps {
children: ReactNode;
rootMargin?: string;
placeholder?: ReactNode;
}
function VirtualItem({ children, rootMargin = '300px', placeholder }: VirtualItemProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting);
},
{
rootMargin,
threshold: 0,
}
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [rootMargin]);
return <div ref={ref}>{isVisible ? children : placeholder}</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;
/**
* 是否启用虚拟渲染(离开视口时卸载内容,默认开启)
*/
enableVirtualRender?: boolean;
/**
* 虚拟渲染的根边距提前多少px开始渲染默认300px
*/
virtualRenderMargin?: string;
/**
* 虚拟渲染时的占位符组件
*/
VirtualPlaceholder?: React.ComponentType;
}
const defaultVirtualPlaceholder = () => (
<div className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl" />
);
/**
* 通用的无限滚动列表组件
* 支持网格布局、骨架屏、错误状态等功能
*/
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,
enableVirtualRender = true,
virtualRenderMargin = '300px',
VirtualPlaceholder = defaultVirtualPlaceholder,
}: 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 (enableVirtualRender) {
return (
<VirtualItem
key={key}
rootMargin={virtualRenderMargin}
placeholder={<VirtualPlaceholder />}
>
{content}
</VirtualItem>
);
}
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>
);
}