newechoes/src/components/MediaGrid.tsx

359 lines
12 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useRef, useState } from 'react';
interface MediaGridProps {
type: 'movie' | 'book';
title: string;
doubanId: string;
}
interface MediaItem {
title: string;
imageUrl: string;
link: string;
}
interface PaginationInfo {
current: number;
hasNext: boolean;
}
const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
const [items, setItems] = useState<MediaItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [error, setError] = useState<string | null>(null);
const itemsPerPage = 15;
const mediaListRef = useRef<HTMLDivElement>(null);
const lastScrollTime = useRef(0);
// 使用ref来跟踪关键状态避免闭包问题
const stateRef = useRef({
isLoading: false,
hasMoreContent: true,
currentPage: 1,
error: null as string | null
});
// 封装fetch函数但不使用useCallback以避免依赖循环
const fetchMedia = async (page = 1, append = false) => {
// 使用ref中的最新状态
if (stateRef.current.isLoading ||
(!append && !stateRef.current.hasMoreContent) ||
(append && !stateRef.current.hasMoreContent)) {
return;
}
// 更新状态和ref
setIsLoading(true);
stateRef.current.isLoading = true;
// 只在首次加载时清除错误
if (!append) {
setError(null);
stateRef.current.error = null;
}
const start = (page - 1) * itemsPerPage;
try {
const response = await fetch(`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`);
if (!response.ok) {
// 解析响应内容,获取详细错误信息
let errorMessage = `获取${type === 'movie' ? '电影' : '图书'}数据失败`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMessage = errorData.error;
if (errorData.message) {
errorMessage += `: ${errorData.message}`;
}
}
} catch (e) {
// 无法解析JSON使用默认错误信息
}
// 针对不同错误提供更友好的提示
if (response.status === 403) {
errorMessage = "豆瓣接口访问受限,可能是请求过于频繁,请稍后再试";
} else if (response.status === 404) {
// 对于404错误如果是追加模式说明已经到了最后一页设置hasMoreContent为false
if (append) {
setHasMoreContent(false);
stateRef.current.hasMoreContent = false;
setIsLoading(false);
stateRef.current.isLoading = false;
return; // 直接返回,不设置错误,不清空已有数据
} else {
errorMessage = "未找到相关内容请检查豆瓣ID是否正确";
}
}
// 设置错误状态和ref
setError(errorMessage);
stateRef.current.error = errorMessage;
// 只有非追加模式才清空数据
if (!append) {
setItems([]);
}
throw new Error(errorMessage);
}
const data = await response.json();
if (data.items.length === 0) {
// 如果返回的项目为空,则认为已经没有更多内容
setHasMoreContent(false);
stateRef.current.hasMoreContent = false;
if (!append) {
setItems([]);
}
} else {
if (append) {
setItems(prev => {
const newItems = [...prev, ...data.items];
return newItems;
});
} else {
setItems(data.items);
}
// 更新页码状态和ref
setCurrentPage(data.pagination.current);
stateRef.current.currentPage = data.pagination.current;
// 更新是否有更多内容的状态和ref
const newHasMoreContent = data.pagination.hasNext;
setHasMoreContent(newHasMoreContent);
stateRef.current.hasMoreContent = newHasMoreContent;
}
} catch (error) {
// 只有在非追加模式下才清空已加载的内容
if (!append) {
setItems([]);
}
} finally {
// 重置加载状态
setIsLoading(false);
stateRef.current.isLoading = false;
}
};
// 处理滚动事件
const handleScroll = () => {
// 获取关键滚动值
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollPosition = scrollY + windowHeight;
const threshold = documentHeight - 300;
// 限制滚动日志频率,每秒最多输出一次
const now = Date.now();
if (now - lastScrollTime.current < 1000) {
return;
}
lastScrollTime.current = now;
// 使用ref中的最新状态来检查
if (stateRef.current.isLoading || !stateRef.current.hasMoreContent || stateRef.current.error) {
return;
}
// 当滚动到距离底部300px时加载更多
if (scrollPosition >= threshold) {
fetchMedia(stateRef.current.currentPage + 1, true);
}
};
// 更新ref值以跟踪状态变化
useEffect(() => {
stateRef.current.isLoading = isLoading;
}, [isLoading]);
useEffect(() => {
stateRef.current.hasMoreContent = hasMoreContent;
}, [hasMoreContent]);
useEffect(() => {
stateRef.current.currentPage = currentPage;
}, [currentPage]);
useEffect(() => {
stateRef.current.error = error;
}, [error]);
// 组件初始化和依赖变化时重置
useEffect(() => {
// 重置状态
setCurrentPage(1);
stateRef.current.currentPage = 1;
setHasMoreContent(true);
stateRef.current.hasMoreContent = true;
setError(null);
stateRef.current.error = null;
setIsLoading(false);
stateRef.current.isLoading = false;
// 清空列表
setItems([]);
// 加载第一页数据
fetchMedia(1, false);
// 管理滚动事件
const scrollListener = handleScroll;
// 移除任何现有监听器
window.removeEventListener('scroll', scrollListener);
// 添加滚动事件监听器 - 使用passive: true可提高滚动性能
window.addEventListener('scroll', scrollListener, { passive: true });
// 创建一个IntersectionObserver作为备选检测方案
const observerOptions = {
root: null,
rootMargin: '300px',
threshold: 0.1
};
const intersectionObserver = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting &&
!stateRef.current.isLoading &&
stateRef.current.hasMoreContent &&
!stateRef.current.error) {
fetchMedia(stateRef.current.currentPage + 1, true);
}
}, observerOptions);
// 添加检测底部的元素 - 放在grid容器的后面而不是内部
const footer = document.createElement('div');
footer.id = 'scroll-detector';
footer.style.width = '100%';
footer.style.height = '10px';
// 确保mediaListRef有父元素
if (mediaListRef.current && mediaListRef.current.parentElement) {
// 插入到grid后面而不是内部
mediaListRef.current.parentElement.insertBefore(footer, mediaListRef.current.nextSibling);
intersectionObserver.observe(footer);
}
// 初始检查一次,以防内容不足一屏
const timeoutId = setTimeout(() => {
if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) {
scrollListener();
}
}, 500);
// 清理函数
return () => {
clearTimeout(timeoutId);
window.removeEventListener('scroll', scrollListener);
intersectionObserver.disconnect();
document.getElementById('scroll-detector')?.remove();
};
}, [type, doubanId]); // 只在关键属性变化时执行
// 错误提示组件
const ErrorMessage = () => {
if (!error) return null;
return (
<div className="col-span-full text-center bg-red-50 p-4 rounded-md">
<div className="flex flex-col items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 text-red-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h3 className="text-lg font-medium text-red-800">访</h3>
<p className="mt-1 text-sm text-red-700">{error}</p>
<button
onClick={() => {
// 重置错误和加载状态
setError(null);
stateRef.current.error = null;
// 允许再次加载
setHasMoreContent(true);
stateRef.current.hasMoreContent = true;
// 重新获取当前页
fetchMedia(currentPage, false);
}}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
</button>
</div>
</div>
);
};
// 没有更多内容提示
const EndMessage = () => {
if (isLoading || !items.length || error) return null;
return (
<div className="text-center py-8">
<p className="text-gray-600"></p>
</div>
);
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-3xl font-bold mb-6">{title}</h1>
<div ref={mediaListRef} className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{error && items.length === 0 ? (
<ErrorMessage />
) : items.length > 0 ? (
items.map((item, index) => (
<div key={`${item.title}-${index}`} className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl">
<div className="relative pb-[150%] overflow-hidden">
<img
src={item.imageUrl}
alt={item.title}
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
/>
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
<h3 className="font-bold text-white text-sm line-clamp-2">
<a href={item.link} target="_blank" rel="noopener noreferrer" className="hover:text-blue-300">
{item.title}
</a>
</h3>
</div>
</div>
</div>
))
) : !isLoading ? (
<div className="col-span-full text-center">{type === 'movie' ? '电影' : '图书'}</div>
) : null}
</div>
{error && items.length > 0 && (
<div className="mt-4">
<ErrorMessage />
</div>
)}
{isLoading && (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
<p className="mt-2 text-gray-600">...{currentPage}</p>
</div>
)}
{!hasMoreContent && items.length > 0 && !isLoading && (
<EndMessage />
)}
</div>
);
};
export default MediaGrid;