mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-23 19:52:09 +08:00
feat: douban api
This commit is contained in:
@@ -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",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
116
src/app/api/douban/route.ts
Normal file
116
src/app/api/douban/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user