mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-14 01:57:29 +08:00
260 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|