246 lines
6.1 KiB
TypeScript
246 lines
6.1 KiB
TypeScript
import React, { ReactNode } from 'react';
|
|
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
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?: {
|
|
default: number;
|
|
sm?: number;
|
|
md?: number;
|
|
lg?: number;
|
|
xl?: 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;
|
|
}
|
|
|
|
/**
|
|
* 通用的无限滚动列表组件
|
|
* 支持网格布局、骨架屏、错误状态等功能
|
|
*/
|
|
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,
|
|
}: InfiniteScrollListProps<T>) {
|
|
const { loadMoreRef, isFetching } = useInfiniteScroll({
|
|
hasNextPage,
|
|
isLoading,
|
|
fetchNextPage,
|
|
threshold,
|
|
enabled,
|
|
isError: hasError,
|
|
});
|
|
|
|
// 生成网格列数的CSS类名映射
|
|
const getGridColsClass = () => {
|
|
if (typeof columns === 'number') {
|
|
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',
|
|
};
|
|
return gridClassMap[columns] || 'grid-cols-4';
|
|
}
|
|
|
|
const classes = [];
|
|
const colsClassMap: 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',
|
|
};
|
|
|
|
classes.push(colsClassMap[columns.default] || 'grid-cols-4');
|
|
if (columns.sm) classes.push(`sm:${colsClassMap[columns.sm]}`);
|
|
if (columns.md) classes.push(`md:${colsClassMap[columns.md]}`);
|
|
if (columns.lg) classes.push(`lg:${colsClassMap[columns.lg]}`);
|
|
if (columns.xl) classes.push(`xl:${colsClassMap[columns.xl]}`);
|
|
|
|
return classes.join(' ');
|
|
};
|
|
|
|
// 生成间距类名
|
|
const getGapClass = () => {
|
|
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';
|
|
};
|
|
|
|
// 错误状态
|
|
if (hasError && ErrorComponent && onRetry) {
|
|
return <ErrorComponent onRetry={onRetry} />;
|
|
}
|
|
|
|
// 首次加载状态
|
|
if (isLoading && items.length === 0) {
|
|
if (LoadingSkeleton) {
|
|
return (
|
|
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
|
|
{Array.from({ length: 8 }).map((_, index) => (
|
|
<LoadingSkeleton key={index} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
|
|
{Array.from({ length: 8 }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="aspect-[3/4] bg-surface-nest-normal rounded-2xl animate-pulse"
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 空状态
|
|
if (items.length === 0 && EmptyComponent) {
|
|
return <EmptyComponent />;
|
|
}
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{/* 主要内容 */}
|
|
<div className={cn('grid', getGridColsClass(), getGapClass(), className)}>
|
|
{items.map((item, index) => (
|
|
<React.Fragment key={getItemKey(item, index)}>
|
|
{renderItem(item, index)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
|
|
{/* 加载更多触发器 - 只在没有错误时显示 */}
|
|
{hasNextPage && !hasError && (
|
|
<div ref={loadMoreRef} className="mt-8 flex justify-center">
|
|
{LoadingMore ? (
|
|
<LoadingMore />
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
{(isFetching || isLoading) && (
|
|
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
|
)}
|
|
<span className="text-txt-tertiary-normal txt-label-s">
|
|
{isFetching || isLoading ? 'Loading...' : ''}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 默认的加载骨架屏组件
|
|
export const DefaultAlbumSkeleton = () => (
|
|
<div className="relative pb-[134%] rounded-2xl overflow-hidden bg-gray-800 animate-pulse">
|
|
<div className="absolute inset-0">
|
|
<div className="w-full h-full bg-gray-700 rounded-2xl" />
|
|
{/* 模拟标签 */}
|
|
<div className="absolute top-2 left-2 w-12 h-6 bg-gray-600 rounded-full" />
|
|
{/* 模拟点赞按钮 */}
|
|
<div className="absolute bottom-2 left-2 w-16 h-6 bg-gray-600 rounded-full" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// 默认的加载更多组件
|
|
export const DefaultLoadingMore = () => (
|
|
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-element-normal">
|
|
<div className="w-4 h-4 border-2 border-primary-normal/20 border-t-primary-normal rounded-full animate-spin" />
|
|
<span className="text-txt-secondary-normal text-sm">Loading...</span>
|
|
</div>
|
|
);
|