feat: douban api

This commit is contained in:
shinya
2025-06-18 12:54:16 +08:00
parent 9c16d5636c
commit a1bf1839ea
6 changed files with 211 additions and 21 deletions

116
src/app/api/douban/route.ts Normal file
View File

@@ -0,0 +1,116 @@
import { NextResponse } from 'next/server';
interface DoubanItem {
title: string;
poster: string;
}
interface DoubanResponse {
code: number;
message: string;
list: DoubanItem[];
}
interface DoubanApiResponse {
subjects: Array<{
title: string;
cover: string;
}>;
}
async function fetchDoubanData(url: string): Promise<DoubanApiResponse> {
// 添加超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 设置请求选项,包括信号和头部
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: 'application/json, text/plain, */*',
},
};
try {
// 尝试直接访问豆瓣API
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// 获取参数
const type = searchParams.get('type');
const tag = searchParams.get('tag');
const pageSize = parseInt(searchParams.get('pageSize') || '16');
const pageStart = parseInt(searchParams.get('pageStart') || '0');
// 验证参数
if (!type || !tag) {
return NextResponse.json(
{ error: '缺少必要参数: type 或 tag' },
{ status: 400 }
);
}
if (!['tv', 'movie'].includes(type)) {
return NextResponse.json(
{ error: 'type 参数必须是 tv 或 movie' },
{ status: 400 }
);
}
if (pageSize < 1 || pageSize > 100) {
return NextResponse.json(
{ error: 'pageSize 必须在 1-100 之间' },
{ status: 400 }
);
}
if (pageStart < 0) {
return NextResponse.json(
{ error: 'pageStart 不能小于 0' },
{ status: 400 }
);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
// 调用豆瓣 API
const doubanData = await fetchDoubanData(target);
// 转换数据格式
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
title: item.title,
poster: item.cover,
}));
const response: DoubanResponse = {
code: 200,
message: '获取成功',
list: list,
};
return NextResponse.json(response);
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import DemoCard from '@/components/DemoCard';
@@ -11,6 +11,17 @@ import VideoCard from '@/components/VideoCard';
const defaultPoster =
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg';
interface DoubanItem {
title: string;
poster: string;
}
interface DoubanResponse {
code: number;
message: string;
list: DoubanItem[];
}
// 模拟数据
const mockData = {
recentMovies: [
@@ -83,6 +94,37 @@ const mockData = {
export default function Home() {
const [activeTab, setActiveTab] = useState('home');
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDoubanData = async () => {
try {
setLoading(true);
// 并行获取热门电影和热门剧集
const [moviesResponse, tvShowsResponse] = await Promise.all([
fetch('/api/douban?type=movie&tag=热门'),
fetch('/api/douban?type=tv&tag=热门'),
]);
if (moviesResponse.ok) {
const moviesData: DoubanResponse = await moviesResponse.json();
setHotMovies(moviesData.list);
}
if (tvShowsResponse.ok) {
const tvShowsData: DoubanResponse = await tvShowsResponse.json();
setHotTvShows(tvShowsData.list);
}
} finally {
setLoading(false);
}
};
fetchDoubanData();
}, []);
return (
<PageLayout>
@@ -116,31 +158,53 @@ export default function Home() {
</ScrollableRow>
</section>
{/* 最新电影 */}
{/* 热门电影 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow>
{mockData.recentMovies.map((movie) => (
<div key={movie.id} className='min-w-[180px] w-44'>
<DemoCard {...movie} />
</div>
))}
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div key={index} className='min-w-[180px] w-44'>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
<div className='absolute inset-0 bg-gray-300'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
</div>
))
: // 显示真实数据
hotMovies.map((movie, index) => (
<div key={index} className='min-w-[180px] w-44'>
<DemoCard title={movie.title} poster={movie.poster} />
</div>
))}
</ScrollableRow>
</section>
{/* 最新电视剧 */}
{/* 热门剧集 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow>
{mockData.recentTvShows.map((show) => (
<div key={show.id} className='min-w-[180px] w-44'>
<DemoCard {...show} />
</div>
))}
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div key={index} className='min-w-[180px] w-44'>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
<div className='absolute inset-0 bg-gray-300'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
</div>
))
: // 显示真实数据
hotTvShows.map((show, index) => (
<div key={index} className='min-w-[180px] w-44'>
<DemoCard title={show.title} poster={show.poster} />
</div>
))}
</ScrollableRow>
</section>
</div>

View File

@@ -67,7 +67,13 @@ const DemoCard = ({ title, poster }: DemoCardProps) => {
>
{/* 海报图片 - 2:3 比例 */}
<div className='relative aspect-[2/3] w-full overflow-hidden'>
<Image src={poster} alt={title} fill className='object-cover' />
<Image
src={poster}
alt={title}
fill
className='object-cover'
referrerPolicy='no-referrer'
/>
{/* Hover 效果 */}
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center group'>
<div className='absolute inset-0 flex items-center justify-center'>

View File

@@ -70,13 +70,13 @@ export default function ScrollableRow({ children }: ScrollableRowProps) {
const handleScrollRightClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({ left: 800, behavior: 'smooth' });
containerRef.current.scrollBy({ left: 1000, behavior: 'smooth' });
}
};
const handleScrollLeftClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({ left: -800, behavior: 'smooth' });
containerRef.current.scrollBy({ left: -1000, behavior: 'smooth' });
}
};
@@ -112,7 +112,7 @@ export default function ScrollableRow({ children }: ScrollableRowProps) {
/>
<div
className='absolute inset-0 flex items-center justify-center'
style={{ top: '40%', bottom: '60%' }}
style={{ top: '40%', bottom: '60%', left: '-4.5rem' }}
>
<button
onClick={handleScrollLeftClick}
@@ -139,7 +139,7 @@ export default function ScrollableRow({ children }: ScrollableRowProps) {
/>
<div
className='absolute inset-0 flex items-center justify-center'
style={{ top: '40%', bottom: '60%' }}
style={{ top: '40%', bottom: '60%', right: '-4.5rem' }}
>
<button
onClick={handleScrollRightClick}