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

View File

@@ -1,5 +1,5 @@
{ {
"name": "ts-nextjs-tailwind-starter", "name": "moon-tv",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -38,6 +38,7 @@
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7", "@testing-library/react": "^15.0.7",
"@types/node": "24.0.3",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/testing-library__jest-dom": "^5.14.9", "@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",

3
pnpm-lock.yaml generated
View File

@@ -63,6 +63,9 @@ importers:
'@testing-library/react': '@testing-library/react':
specifier: ^15.0.7 specifier: ^15.0.7
version: 15.0.7(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 15.0.7(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/node':
specifier: 24.0.3
version: 24.0.3
'@types/react': '@types/react':
specifier: ^18.3.18 specifier: ^18.3.18
version: 18.3.23 version: 18.3.23

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'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import CapsuleSwitch from '@/components/CapsuleSwitch'; import CapsuleSwitch from '@/components/CapsuleSwitch';
import DemoCard from '@/components/DemoCard'; import DemoCard from '@/components/DemoCard';
@@ -11,6 +11,17 @@ import VideoCard from '@/components/VideoCard';
const defaultPoster = const defaultPoster =
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg'; '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 = { const mockData = {
recentMovies: [ recentMovies: [
@@ -83,6 +94,37 @@ const mockData = {
export default function Home() { export default function Home() {
const [activeTab, setActiveTab] = useState('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 ( return (
<PageLayout> <PageLayout>
@@ -116,31 +158,53 @@ export default function Home() {
</ScrollableRow> </ScrollableRow>
</section> </section>
{/* 最新电影 */} {/* 热门电影 */}
<section className='mb-8'> <section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'> <h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2> </h2>
<ScrollableRow> <ScrollableRow>
{mockData.recentMovies.map((movie) => ( {loading
<div key={movie.id} className='min-w-[180px] w-44'> ? // 加载状态显示灰色占位数据
<DemoCard {...movie} /> Array.from({ length: 8 }).map((_, index) => (
</div> <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> </ScrollableRow>
</section> </section>
{/* 最新电视剧 */} {/* 热门剧集 */}
<section className='mb-8'> <section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'> <h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2> </h2>
<ScrollableRow> <ScrollableRow>
{mockData.recentTvShows.map((show) => ( {loading
<div key={show.id} className='min-w-[180px] w-44'> ? // 加载状态显示灰色占位数据
<DemoCard {...show} /> Array.from({ length: 8 }).map((_, index) => (
</div> <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> </ScrollableRow>
</section> </section>
</div> </div>

View File

@@ -67,7 +67,13 @@ const DemoCard = ({ title, poster }: DemoCardProps) => {
> >
{/* 海报图片 - 2:3 比例 */} {/* 海报图片 - 2:3 比例 */}
<div className='relative aspect-[2/3] w-full overflow-hidden'> <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 效果 */} {/* 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 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'> <div className='absolute inset-0 flex items-center justify-center'>

View File

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