Files
LunaTV/src/app/douban/page.tsx

260 lines
7.6 KiB
TypeScript

'use client';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useEffect, useRef, useState } from 'react';
import DemoCard from '@/components/DemoCard';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import PageLayout from '@/components/PageLayout';
// 定义豆瓣数据项类型
interface DoubanItem {
title: string;
poster: string;
}
// 定义豆瓣响应类型
interface DoubanResponse {
code: number;
message: string;
list: DoubanItem[];
}
function DoubanPageClient() {
const searchParams = useSearchParams();
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
const type = searchParams.get('type');
const tag = searchParams.get('tag');
// 生成骨架屏数据
const skeletonData = Array.from({ length: 20 }, (_, index) => index);
useEffect(() => {
if (!type || !tag) {
setError('缺少必要参数: type 或 tag');
setLoading(false);
return;
}
// 重置页面状态
setDoubanData([]);
setCurrentPage(0);
setHasMore(true);
setError(null);
setIsLoadingMore(false);
// 立即加载第一页数据
const loadInitialData = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=0`
);
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
const data: DoubanResponse = await response.json();
if (data.code === 200) {
setDoubanData(data.list);
setHasMore(data.list.length === 25);
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取豆瓣数据失败');
} finally {
setLoading(false);
}
};
loadInitialData();
}, [type, tag]);
// 单独处理 currentPage 变化(加载更多)
useEffect(() => {
if (currentPage > 0 && type && tag) {
const fetchMoreData = async () => {
try {
setIsLoadingMore(true);
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=${
currentPage * 25
}`
);
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
const data: DoubanResponse = await response.json();
if (data.code === 200) {
setDoubanData((prev) => [...prev, ...data.list]);
setHasMore(data.list.length === 25);
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取豆瓣数据失败');
} finally {
setIsLoadingMore(false);
}
};
fetchMoreData();
}
}, [currentPage, type, tag]);
// 设置滚动监听
useEffect(() => {
// 如果没有更多数据或正在加载,则不设置监听
if (!hasMore || isLoadingMore || loading) {
return;
}
// 确保 loadingRef 存在
if (!loadingRef.current) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
setCurrentPage((prev) => prev + 1);
}
},
{ threshold: 0.1 }
);
observer.observe(loadingRef.current);
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasMore, isLoadingMore, loading]);
const getPageTitle = () => {
// 优先使用 URL 中的 title 参数
const titleParam = searchParams.get('title');
if (titleParam) {
return titleParam;
}
// 如果 title 参数不存在,根据 type 和 tag 拼接
if (!type || !tag) return '豆瓣内容';
const typeText = type === 'movie' ? '电影' : '电视剧';
const tagText = tag === 'top250' ? 'Top250' : tag;
return `${typeText} - ${tagText}`;
};
const getActivePath = () => {
const params = new URLSearchParams();
if (type) params.set('type', type);
if (tag) params.set('tag', tag);
const titleParam = searchParams.get('title');
if (titleParam) params.set('title', titleParam);
const queryString = params.toString();
const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
return activePath;
};
return (
<PageLayout activePath={getActivePath()}>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 页面标题 */}
<div className='mb-8'>
<h1 className='text-3xl font-bold text-gray-800 mb-2'>
{getPageTitle()}
</h1>
<p className='text-gray-600'></p>
</div>
{/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{error ? (
<div className='flex justify-center items-center h-40'>
<div className='text-red-500 text-center'>
<div className='text-lg font-semibold mb-2'></div>
<div className='text-sm'>{error}</div>
</div>
</div>
) : (
<>
{/* 内容网格 */}
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(180px,1fr))] sm:gap-x-8 sm:gap-y-20 sm:px-4'>
{loading
? // 显示骨架屏
skeletonData.map((index) => (
<DoubanCardSkeleton key={index} />
))
: // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<DemoCard title={item.title} poster={item.poster} />
</div>
))}
</div>
{/* 加载更多指示器 */}
{hasMore && !loading && (
<div
ref={loadingRef}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && doubanData.length > 0 && (
<div className='text-center text-gray-500 py-8'>
</div>
)}
{/* 空状态 */}
{!loading && doubanData.length === 0 && !error && (
<div className='text-center text-gray-500 py-8'>
</div>
)}
</>
)}
</div>
</div>
</PageLayout>
);
}
export default function DoubanPage() {
return (
<Suspense>
<DoubanPageClient />
</Suspense>
);
}