diff --git a/src/app/api/douban/route.ts b/src/app/api/douban/route.ts index fc20329..39b40b1 100644 --- a/src/app/api/douban/route.ts +++ b/src/app/api/douban/route.ts @@ -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 = + /
[\s\S]*?/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 } + ); + }); +} diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 0a3c7d7..3e3da08 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -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 ( -
- -
+ +
{loading ? ( -
+
) : error ? ( -
-
{error}
+
+
+
加载失败
+
{error}
+
) : !detail ? ( -
-
未找到视频详情
+
+
+
未找到视频详情
+
) : ( -
+
{/* 主信息区:左图右文 */}
{/* 封面 */} @@ -152,7 +156,7 @@ export default function DetailPage() { )}
)} -
-
+
+ ); } diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx new file mode 100644 index 0000000..1f13bd7 --- /dev/null +++ b/src/app/douban/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const observerRef = useRef(null); + const loadingRef = useRef(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 ( + +
+ {/* 页面标题 */} +
+

+ {getPageTitle()} +

+

来自豆瓣的精选内容

+
+ + {/* 内容展示区域 */} +
+ {error ? ( +
+
+
加载失败
+
{error}
+
+
+ ) : ( + <> + {/* 内容网格 */} +
+ {loading + ? // 显示骨架屏 + skeletonData.map((index) => ( + + )) + : // 显示实际数据 + doubanData.map((item, index) => ( +
+ +
+ ))} +
+ + {/* 加载更多指示器 */} + {hasMore && !loading && ( +
+ {isLoadingMore && ( +
+
+ 加载中... +
+ )} +
+ )} + + {/* 没有更多数据提示 */} + {!hasMore && doubanData.length > 0 && ( +
+ 已加载全部内容 +
+ )} + + {/* 空状态 */} + {!loading && doubanData.length === 0 && !error && ( +
+ 暂无相关内容 +
+ )} + + )} +
+
+
+ ); +} diff --git a/src/components/DoubanCardSkeleton.tsx b/src/components/DoubanCardSkeleton.tsx new file mode 100644 index 0000000..ab15ad0 --- /dev/null +++ b/src/components/DoubanCardSkeleton.tsx @@ -0,0 +1,21 @@ +const DoubanCardSkeleton = () => { + return ( +
+
+ {/* 海报骨架 - 2:3 比例 */} +
+
+
+ + {/* 信息层骨架 */} +
+
+
+
+
+
+
+ ); +}; + +export default DoubanCardSkeleton; diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx deleted file mode 100644 index 6b0f94e..0000000 --- a/src/components/Skeleton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -type SkeletonProps = React.ComponentPropsWithoutRef<'div'>; - -export default function Skeleton({ className, ...rest }: SkeletonProps) { - return ( -
- ); -} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 835c88e..c04eb75 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { Film, Folder, Home, Menu, Search, Star, Tv } from 'lucide-react'; import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { createContext, useCallback, @@ -26,7 +26,7 @@ const Logo = () => ( className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200' > - LibreTV + MoonTV ); @@ -39,6 +39,7 @@ interface SidebarProps { const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { const router = useRouter(); const pathname = usePathname(); + const searchParams = useSearchParams(); const [isCollapsed, setIsCollapsed] = useState(() => { if (typeof window === 'undefined') return false; const saved = localStorage.getItem('sidebarCollapsed'); @@ -47,8 +48,19 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { const [active, setActive] = useState(activePath); useEffect(() => { - setActive(pathname); - }, [pathname]); + // 优先使用传入的 activePath + if (activePath) { + setActive(activePath); + } else { + // 否则使用当前路径 + const getCurrentFullPath = () => { + const queryString = searchParams.toString(); + return queryString ? `${pathname}?${queryString}` : pathname; + }; + const fullPath = getCurrentFullPath(); + setActive(fullPath); + } + }, [activePath, pathname, searchParams]); const handleToggle = useCallback(() => { const newState = !isCollapsed; @@ -66,12 +78,25 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { }; const menuItems = [ - { icon: Tv, label: '电视剧', href: '/tv-shows' }, - { icon: Film, label: '电影', href: '/movies' }, - { icon: Star, label: '豆瓣 Top250', href: '/top250' }, - { icon: Folder, label: '合集', href: '/collections' }, - { icon: Star, label: '热门电影', href: '/douban/hot-movies' }, - { icon: Star, label: '热门电视剧', href: '/douban/hot-tv' }, + { + icon: Film, + label: '热门电影', + href: '/douban?type=movie&tag=热门&title=热门电影', + }, + { + icon: Tv, + label: '热门剧集', + href: '/douban?type=tv&tag=热门&title=热门剧集', + }, + { + icon: Star, + label: '豆瓣 Top250', + href: '/douban?type=movie&tag=top250&title=豆瓣 Top250', + }, + { icon: Folder, label: '美剧', href: '/douban?type=tv&tag=美剧' }, + { icon: Folder, label: '韩剧', href: '/douban?type=tv&tag=韩剧' }, + { icon: Folder, label: '日剧', href: '/douban?type=tv&tag=日剧' }, + { icon: Folder, label: '日漫', href: '/douban?type=tv&tag=日本动画' }, ]; return ( @@ -158,28 +183,44 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { {/* 菜单项 */}
- {menuItems.map((item) => ( - setActive(item.href)} - data-active={active === item.href} - className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${ - isCollapsed - ? 'w-full max-w-none mx-0' - : 'max-w-[220px] mx-auto' - } gap-3 justify-start`} - > -
- -
- {!isCollapsed && ( - - {item.label} - - )} - - ))} + {menuItems.map((item) => { + // 检查当前路径是否匹配这个菜单项 + const typeMatch = item.href.match(/type=([^&]+)/)?.[1]; + const tagMatch = item.href.match(/tag=([^&]+)/)?.[1]; + + // 解码URL以进行正确的比较 + const decodedActive = decodeURIComponent(active); + const decodedItemHref = decodeURIComponent(item.href); + + const isActive = + decodedActive === decodedItemHref || + (decodedActive.startsWith('/douban') && + decodedActive.includes(`type=${typeMatch}`) && + decodedActive.includes(`tag=${tagMatch}`)); + + return ( + setActive(item.href)} + data-active={isActive} + className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] ${ + isCollapsed + ? 'w-full max-w-none mx-0' + : 'max-w-[220px] mx-auto' + } gap-3 justify-start`} + > +
+ +
+ {!isCollapsed && ( + + {item.label} + + )} + + ); + })}