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}
+
) : !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 ? (
+
+ ) : (
+ <>
+ {/* 内容网格 */}
+
+ {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}
+
+ )}
+
+ );
+ })}