2025-12-22 11:31:36 +00:00
|
|
|
|
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-22 11:31:36 +00:00
|
|
|
|
/**
|
2025-12-29 09:18:11 +00:00
|
|
|
|
* 虚拟渲染项组件
|
|
|
|
|
|
* 只渲染在视口内或附近的内容,离开视口时卸载
|
2025-12-22 11:31:36 +00:00
|
|
|
|
*/
|
2025-12-29 09:18:11 +00:00
|
|
|
|
interface VirtualItemProps {
|
2025-12-22 11:31:36 +00:00
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
rootMargin?: string;
|
|
|
|
|
|
placeholder?: ReactNode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 09:18:11 +00:00
|
|
|
|
function VirtualItem({ children, rootMargin = '300px', placeholder }: VirtualItemProps) {
|
2025-12-22 11:31:36 +00:00
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
2025-12-29 09:18:11 +00:00
|
|
|
|
const [isVisible, setIsVisible] = useState(false);
|
2025-12-22 11:31:36 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const element = ref.current;
|
|
|
|
|
|
if (!element) return;
|
|
|
|
|
|
|
|
|
|
|
|
const observer = new IntersectionObserver(
|
|
|
|
|
|
([entry]) => {
|
2025-12-29 09:18:11 +00:00
|
|
|
|
setIsVisible(entry.isIntersecting);
|
2025-12-22 11:31:36 +00:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
rootMargin,
|
|
|
|
|
|
threshold: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
observer.observe(element);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
|
};
|
2025-12-29 09:18:11 +00:00
|
|
|
|
}, [rootMargin]);
|
2025-12-22 11:31:36 +00:00
|
|
|
|
|
2025-12-29 09:18:11 +00:00
|
|
|
|
return <div ref={ref}>{isVisible ? children : placeholder}</div>;
|
2025-12-22 11:31:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
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-22 11:31:36 +00:00
|
|
|
|
/**
|
2025-12-29 09:18:11 +00:00
|
|
|
|
* 是否启用虚拟渲染(离开视口时卸载内容,默认开启)
|
|
|
|
|
|
*/
|
|
|
|
|
|
enableVirtualRender?: boolean;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 虚拟渲染的根边距(提前多少px开始渲染,默认300px)
|
2025-12-22 11:31:36 +00:00
|
|
|
|
*/
|
2025-12-29 09:18:11 +00:00
|
|
|
|
virtualRenderMargin?: string;
|
2025-12-22 11:31:36 +00:00
|
|
|
|
/**
|
2025-12-29 09:18:11 +00:00
|
|
|
|
* 虚拟渲染时的占位符组件
|
2025-12-22 11:31:36 +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)}>
|
2025-12-22 11:31:36 +00:00
|
|
|
|
{items.map((item, index) => {
|
|
|
|
|
|
const key = getItemKey(item, index);
|
|
|
|
|
|
const content = renderItem(item, index);
|
|
|
|
|
|
|
2025-12-29 09:18:11 +00:00
|
|
|
|
if (enableVirtualRender) {
|
2025-12-22 11:31:36 +00:00
|
|
|
|
return (
|
2025-12-29 09:18:11 +00:00
|
|
|
|
<VirtualItem
|
2025-12-22 11:31:36 +00:00
|
|
|
|
key={key}
|
2025-12-29 09:18:11 +00:00
|
|
|
|
rootMargin={virtualRenderMargin}
|
|
|
|
|
|
placeholder={<VirtualPlaceholder />}
|
2025-12-22 11:31:36 +00:00
|
|
|
|
>
|
|
|
|
|
|
{content}
|
2025-12-29 09:18:11 +00:00
|
|
|
|
</VirtualItem>
|
2025-12-22 11:31:36 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|