From a1bf1839ea8d79d0e353d49482df68f675d1cdd4 Mon Sep 17 00:00:00 2001 From: shinya Date: Wed, 18 Jun 2025 12:54:16 +0800 Subject: [PATCH] feat: douban api --- package.json | 3 +- pnpm-lock.yaml | 3 + src/app/api/douban/route.ts | 116 +++++++++++++++++++++++++++++++ src/app/page.tsx | 94 +++++++++++++++++++++---- src/components/DemoCard.tsx | 8 ++- src/components/ScrollableRow.tsx | 8 +-- 6 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 src/app/api/douban/route.ts diff --git a/package.json b/package.json index ad5c56e..a3a7a71 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "ts-nextjs-tailwind-starter", + "name": "moon-tv", "version": "0.1.0", "private": true, "scripts": { @@ -38,6 +38,7 @@ "@tailwindcss/forms": "^0.5.10", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^15.0.7", + "@types/node": "24.0.3", "@types/react": "^18.3.18", "@types/testing-library__jest-dom": "^5.14.9", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a5341e..1f3dee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@testing-library/react': 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) + '@types/node': + specifier: 24.0.3 + version: 24.0.3 '@types/react': specifier: ^18.3.18 version: 18.3.23 diff --git a/src/app/api/douban/route.ts b/src/app/api/douban/route.ts new file mode 100644 index 0000000..fc20329 --- /dev/null +++ b/src/app/api/douban/route.ts @@ -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 { + // 添加超时控制 + 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 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 669b52c..173d54f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); + const [hotTvShows, setHotTvShows] = useState([]); + 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 ( @@ -116,31 +158,53 @@ export default function Home() { - {/* 最新电影 */} + {/* 热门电影 */}

- 最新电影 + 热门电影

- {mockData.recentMovies.map((movie) => ( -
- -
- ))} + {loading + ? // 加载状态显示灰色占位数据 + Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+ )) + : // 显示真实数据 + hotMovies.map((movie, index) => ( +
+ +
+ ))}
- {/* 最新电视剧 */} + {/* 热门剧集 */}

- 最新电视剧 + 热门剧集

- {mockData.recentTvShows.map((show) => ( -
- -
- ))} + {loading + ? // 加载状态显示灰色占位数据 + Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+ )) + : // 显示真实数据 + hotTvShows.map((show, index) => ( +
+ +
+ ))}
diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index ef7426d..5ac8e5e 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -67,7 +67,13 @@ const DemoCard = ({ title, poster }: DemoCardProps) => { > {/* 海报图片 - 2:3 比例 */}
- {title} + {title} {/* Hover 效果 */}
diff --git a/src/components/ScrollableRow.tsx b/src/components/ScrollableRow.tsx index f8859c2..900c0b6 100644 --- a/src/components/ScrollableRow.tsx +++ b/src/components/ScrollableRow.tsx @@ -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) { />