newechoes/src/components/MediaGrid.tsx

359 lines
12 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, { 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;