feat: douban page

This commit is contained in:
shinya
2025-06-18 13:55:27 +08:00
parent a1bf1839ea
commit 3d199261a3
6 changed files with 432 additions and 64 deletions

View File

@@ -88,6 +88,10 @@ export async function GET(request: Request) {
);
}
if (tag === 'top250') {
return handleTop250(pageStart);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
@@ -114,3 +118,71 @@ export async function GET(request: Request) {
);
}
}
function handleTop250(pageStart: number) {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
// 直接使用 fetch 获取 HTML 页面
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
},
};
return fetch(target, fetchOptions)
.then(async (fetchResponse) => {
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
}
// 获取 HTML 内容
const html = await fetchResponse.text();
// 使用正则表达式提取电影信息
const moviePattern =
/<div class="item">[\s\S]*?<img.*?alt="([^"]*)"[\s\S]*?src="([^"]*)"[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const title = match[1];
const cover = match[2];
// 处理图片 URL确保使用 HTTPS
const processedCover = cover.replace(/^http:/, 'https:');
movies.push({
title: title,
poster: processedCover,
});
}
const apiResponse: DoubanResponse = {
code: 200,
message: '获取成功',
list: movies,
};
return NextResponse.json(apiResponse);
})
.catch((error) => {
clearTimeout(timeoutId);
return NextResponse.json(
{
error: '获取豆瓣 Top250 数据失败',
details: (error as Error).message,
},
{ status: 500 }
);
});
}

View File

@@ -4,7 +4,7 @@ import Image from 'next/image';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import Sidebar from '@/components/layout/Sidebar';
import PageLayout from '@/components/layout/PageLayout';
import { VideoDetail } from '../api/detail/route';
@@ -43,23 +43,27 @@ export default function DetailPage() {
}, [searchParams]);
return (
<div className='flex min-h-screen'>
<Sidebar activePath='/detail' />
<main className='flex-1 p-8 flex flex-col items-center'>
<PageLayout activePath='/detail'>
<div className='px-10 py-8 overflow-visible'>
{loading ? (
<div className='flex items-center justify-center min-h-screen'>
<div className='flex items-center justify-center min-h-[60vh]'>
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
</div>
) : error ? (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-red-500'>{error}</div>
<div className='flex items-center justify-center min-h-[60vh]'>
<div className='text-red-500 text-center'>
<div className='text-lg font-semibold mb-2'></div>
<div className='text-sm'>{error}</div>
</div>
</div>
) : !detail ? (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'></div>
<div className='flex items-center justify-center min-h-[60vh]'>
<div className='text-gray-500 text-center'>
<div className='text-lg font-semibold mb-2'></div>
</div>
</div>
) : (
<div className='w-full max-w-[95%]'>
<div className='max-w-[95%] mx-auto'>
{/* 主信息区:左图右文 */}
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
{/* 封面 */}
@@ -152,7 +156,7 @@ export default function DetailPage() {
)}
</div>
)}
</main>
</div>
</div>
</PageLayout>
);
}

250
src/app/douban/page.tsx Normal file
View 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>
);
}