338 lines
8.7 KiB
TypeScript
338 lines
8.7 KiB
TypeScript
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>
|
||
);
|
||
}
|