mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-05-13 07:57:29 +08:00
feat: douban page
This commit is contained in:
250
src/app/douban/page.tsx
Normal file
250
src/app/douban/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
|
||||
// 定义豆瓣数据项类型
|
||||
interface DoubanItem {
|
||||
title: string;
|
||||
poster: string;
|
||||
}
|
||||
|
||||
// 定义豆瓣响应类型
|
||||
interface DoubanResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
list: DoubanItem[];
|
||||
}
|
||||
|
||||
export default function DoubanPage() {
|
||||
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-10 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-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
{loading
|
||||
? // 显示骨架屏
|
||||
skeletonData.map((index) => (
|
||||
<DoubanCardSkeleton key={index} />
|
||||
))
|
||||
: // 显示实际数据
|
||||
doubanData.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className='w-44'>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user