feat: new douban page and selector

This commit is contained in:
shinya
2025-07-16 23:34:14 +08:00
parent 53a1b6603b
commit e1521179d4
8 changed files with 886 additions and 277 deletions

View File

@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { DoubanItem, DoubanResult } from '@/lib/types';
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
async function fetchDoubanData(
url: string
): Promise<DoubanCategoryApiResponse> {
// 添加超时控制
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, */*',
Origin: 'https://movie.douban.com',
},
};
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 const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// 获取参数
const kind = searchParams.get('kind') || 'movie';
const category = searchParams.get('category');
const type = searchParams.get('type');
const pageLimit = parseInt(searchParams.get('limit') || '20');
const pageStart = parseInt(searchParams.get('start') || '0');
// 验证参数
if (!kind || !category || !type) {
return NextResponse.json(
{ error: '缺少必要参数: kind 或 category 或 type' },
{ status: 400 }
);
}
if (!['tv', 'movie'].includes(kind)) {
return NextResponse.json(
{ error: 'kind 参数必须是 tv 或 movie' },
{ status: 400 }
);
}
if (pageLimit < 1 || pageLimit > 100) {
return NextResponse.json(
{ error: 'pageSize 必须在 1-100 之间' },
{ status: 400 }
);
}
if (pageStart < 0) {
return NextResponse.json(
{ error: 'pageStart 不能小于 0' },
{ status: 400 }
);
}
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
try {
// 调用豆瓣 API
const doubanData = await fetchDoubanData(target);
// 转换数据格式
const list: DoubanItem[] = doubanData.items.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}`,
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -1,37 +1,136 @@
/* eslint-disable no-console,react-hooks/exhaustive-deps */
'use client';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getDoubanData } from '@/lib/douban.client';
import { getDoubanCategories } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import DoubanSelector from '@/components/DoubanSelector';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
function DoubanPageClient() {
const searchParams = useSearchParams();
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectorsReady, setSelectorsReady] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const type = searchParams.get('type');
const tag = searchParams.get('tag');
const type = searchParams.get('type') || 'movie';
// 选择器状态 - 完全独立不依赖URL参数
const [primarySelection, setPrimarySelection] = useState<string>(() => {
return type === 'movie' ? '热门' : '';
});
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
if (type === 'movie') return '全部';
if (type === 'tv') return 'tv';
if (type === 'show') return 'show';
return '全部';
});
// 初始化时标记选择器为准备好状态
useEffect(() => {
// 短暂延迟确保初始状态设置完成
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, []); // 只在组件挂载时执行一次
// type变化时立即重置selectorsReady最高优先级
useEffect(() => {
setSelectorsReady(false);
setLoading(true); // 立即显示loading状态
}, [type]);
// 当type变化时重置选择器状态
useEffect(() => {
// 批量更新选择器状态
if (type === 'movie') {
setPrimarySelection('热门');
setSecondarySelection('全部');
} else if (type === 'tv') {
setPrimarySelection('');
setSecondarySelection('tv');
} else if (type === 'show') {
setPrimarySelection('');
setSecondarySelection('show');
} else {
setPrimarySelection('');
setSecondarySelection('全部');
}
// 使用短暂延迟确保状态更新完成后标记选择器准备好
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, [type]);
// 生成骨架屏数据
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
// 生成API请求参数的辅助函数
const getRequestParams = useCallback(
(pageStart: number) => {
// 当type为tv或show时kind统一为'tv'category使用type本身
if (type === 'tv' || type === 'show') {
return {
kind: 'tv' as const,
category: type,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
}
// 电影类型保持原逻辑
return {
kind: type as 'tv' | 'movie',
category: primarySelection,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
},
[type, primarySelection, secondarySelection]
);
// 防抖的数据加载函数
const loadInitialData = useCallback(async () => {
try {
setLoading(true);
const data = await getDoubanCategories(getRequestParams(0));
if (data.code === 200) {
setDoubanData(data.list);
setHasMore(data.list.length === 25);
setLoading(false);
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
console.error(err);
}
}, [type, primarySelection, secondarySelection, getRequestParams]);
// 只在选择器准备好后才加载数据
useEffect(() => {
if (!type || !tag) {
setError('缺少必要参数: type 或 tag');
setLoading(false);
// 只有在选择器准备好时才开始加载
if (!selectorsReady) {
return;
}
@@ -39,49 +138,42 @@ function DoubanPageClient() {
setDoubanData([]);
setCurrentPage(0);
setHasMore(true);
setError(null);
setIsLoadingMore(false);
// 立即加载第一页数据
const loadInitialData = async () => {
try {
setLoading(true);
const data = await getDoubanData({
type: type as 'tv' | 'movie',
tag,
pageSize: 25,
pageStart: 0,
});
// 清除之前的防抖定时器
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
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);
// 使用防抖机制加载数据,避免连续状态更新触发多次请求
debounceTimeoutRef.current = setTimeout(() => {
loadInitialData();
}, 100); // 100ms 防抖延迟
// 清理函数
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
loadInitialData();
}, [type, tag]);
}, [
selectorsReady,
type,
primarySelection,
secondarySelection,
loadInitialData,
]);
// 单独处理 currentPage 变化(加载更多)
useEffect(() => {
if (currentPage > 0 && type && tag) {
if (currentPage > 0) {
const fetchMoreData = async () => {
try {
setIsLoadingMore(true);
const data = await getDoubanData({
type: type as 'tv' | 'movie',
tag,
pageSize: 25,
pageStart: currentPage * 25,
});
const data = await getDoubanCategories(
getRequestParams(currentPage * 25)
);
if (data.code === 200) {
setDoubanData((prev) => [...prev, ...data.list]);
@@ -90,7 +182,7 @@ function DoubanPageClient() {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取豆瓣数据失败');
console.error(err);
} finally {
setIsLoadingMore(false);
}
@@ -98,7 +190,7 @@ function DoubanPageClient() {
fetchMoreData();
}
}, [currentPage, type, tag]);
}, [currentPage, type, primarySelection, secondarySelection]);
// 设置滚动监听
useEffect(() => {
@@ -131,28 +223,35 @@ function DoubanPageClient() {
};
}, [hasMore, isLoadingMore, loading]);
// 处理选择器变化
const handlePrimaryChange = useCallback(
(value: string) => {
// 立即设置loading状态
setLoading(true);
// 批量更新状态 - React 18会自动批处理这些更新
setPrimarySelection(value);
// 电影类型时,重置二级选择器为第一个选项
if (type === 'movie') {
setSecondarySelection('全部');
}
},
[type]
);
const handleSecondaryChange = useCallback((value: string) => {
// 立即设置loading状态
setLoading(true);
setSecondarySelection(value);
}, []);
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}`;
// 根据 type 生成标题
return type === 'movie' ? '电影' : type === 'tv' ? '电视剧' : '综艺';
};
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}` : ''}`;
@@ -162,81 +261,80 @@ function DoubanPageClient() {
return (
<PageLayout activePath={getActivePath()}>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 页面标题 */}
<div className='mb-8'>
<h1 className='text-3xl font-bold text-gray-800 mb-2 dark:text-gray-200'>
{getPageTitle()}
</h1>
<p className='text-gray-600 dark:text-gray-400'></p>
{/* 页面标题和选择器 */}
<div className='mb-8 space-y-6'>
{/* 页面标题 */}
<div>
<h1 className='text-3xl font-bold text-gray-800 mb-2 dark:text-gray-200'>
{getPageTitle()}
</h1>
<p className='text-gray-600 dark:text-gray-400'>
</p>
</div>
{/* 选择器组件 */}
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show'}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
</div>
{/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{error ? (
<div className='flex justify-center items-center h-40'>
<div className='text-red-500 text-center'>
<div className='text-lg font-semibold mb-2'></div>
<div className='text-sm'>{error}</div>
</div>
{/* 内容网格 */}
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || !selectorsReady
? // 显示骨架屏
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
: // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
title={item.title}
poster={item.poster}
douban_id={item.id}
rate={item.rate}
/>
</div>
))}
</div>
{/* 加载更多指示器 */}
{hasMore && !loading && (
<div
ref={(el) => {
if (el && el.offsetParent !== null) {
(
loadingRef as React.MutableRefObject<HTMLDivElement | null>
).current = el;
}
}}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div>
) : (
<>
{/* 内容网格 */}
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading
? // 显示骨架屏
skeletonData.map((index) => (
<DoubanCardSkeleton key={index} />
))
: // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
title={item.title}
poster={item.poster}
douban_id={item.id}
rate={item.rate}
/>
</div>
))}
</div>
)}
{/* 加载更多指示器 */}
{hasMore && !loading && (
<div
ref={(el) => {
if (el && el.offsetParent !== null) {
(
loadingRef as React.MutableRefObject<HTMLDivElement | null>
).current = el;
}
}}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && doubanData.length > 0 && (
<div className='text-center text-gray-500 py-8'></div>
)}
{/* 没有更多数据提示 */}
{!hasMore && doubanData.length > 0 && (
<div className='text-center text-gray-500 py-8'>
</div>
)}
{/* 空状态 */}
{!loading && doubanData.length === 0 && !error && (
<div className='text-center text-gray-500 py-8'>
</div>
)}
</>
{/* 空状态 */}
{!loading && doubanData.length === 0 && (
<div className='text-center text-gray-500 py-8'></div>
)}
</div>
</div>

View File

@@ -13,7 +13,7 @@ import {
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { getDoubanData } from '@/lib/douban.client';
import { getDoubanRecommends } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
@@ -65,8 +65,8 @@ function HomeClient() {
// 并行获取热门电影和热门剧集
const [moviesData, tvShowsData] = await Promise.all([
getDoubanData({ type: 'movie', tag: '热门' }),
getDoubanData({ type: 'tv', tag: '热门' }),
getDoubanRecommends({ type: 'movie', tag: '热门' }),
getDoubanRecommends({ type: 'tv', tag: '热门' }),
]);
if (moviesData.code === 200) {
@@ -209,7 +209,7 @@ function HomeClient() {
</h2>
<Link
href='/douban?type=movie&tag=热门&title=热门电影'
href='/douban?type=movie'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
@@ -255,7 +255,7 @@ function HomeClient() {
</h2>
<Link
href='/douban?type=tv&tag=热门&title=热门剧集'
href='/douban?type=tv'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>

View File

@@ -1,4 +1,6 @@
import React from 'react';
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useRef, useState } from 'react';
interface CapsuleSwitchProps {
options: { label: string; value: string }[];
@@ -13,25 +15,87 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
onChange,
className,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const activeIndex = options.findIndex((opt) => opt.value === active);
// 更新指示器位置
const updateIndicatorPosition = () => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, []);
// 监听选中项变化
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, [activeIndex]);
return (
<div
className={`inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
ref={containerRef}
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
className || ''
}`}
>
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className={`w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 ${
active === opt.value
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-500 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{opt.label}
</button>
))}
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((opt, index) => {
const isActive = active === opt.value;
return (
<button
key={opt.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(opt.value)}
className={`relative z-10 w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{opt.label}
</button>
);
})}
</div>
);
};

View File

@@ -0,0 +1,330 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface SelectorOption {
label: string;
value: string;
}
interface DoubanSelectorProps {
type: 'movie' | 'tv' | 'show';
primarySelection?: string;
secondarySelection?: string;
onPrimaryChange: (value: string) => void;
onSecondaryChange: (value: string) => void;
}
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
type,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
}) => {
// 为不同的选择器创建独立的refs和状态
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(null);
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 电影的一级选择器选项
const moviePrimaryOptions: SelectorOption[] = [
{ label: '热门电影', value: '热门' },
{ label: '最新电影', value: '最新' },
{ label: '豆瓣高分', value: '豆瓣高分' },
{ label: '冷门佳片', value: '冷门佳片' },
];
// 电影的二级选择器选项
const movieSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '华语', value: '华语' },
{ label: '欧美', value: '欧美' },
{ label: '韩国', value: '韩国' },
{ label: '日本', value: '日本' },
];
// 电视剧选择器选项
const tvOptions: SelectorOption[] = [
{ label: '综合', value: 'tv' },
{ label: '国产剧', value: 'tv_domestic' },
{ label: '欧美剧', value: 'tv_american' },
{ label: '日剧', value: 'tv_japanese' },
{ label: '韩剧', value: 'tv_korean' },
{ label: '动漫', value: 'tv_animation' },
{ label: '纪录片', value: 'tv_documentary' },
];
// 综艺选择器选项
const showOptions: SelectorOption[] = [
{ label: '综合', value: 'show' },
{ label: '国内', value: 'show_domestic' },
{ label: '国外', value: 'show_foreign' },
];
// 更新指示器位置的通用函数
const updateIndicatorPosition = (
activeIndex: number,
containerRef: React.RefObject<HTMLDivElement>,
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
setIndicatorStyle: React.Dispatch<
React.SetStateAction<{ left: number; width: number }>
>
) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
// 主选择器初始位置
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || moviePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
let secondaryActiveIndex = -1;
if (type === 'movie') {
secondaryActiveIndex = movieSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
);
} else if (type === 'tv') {
secondaryActiveIndex = tvOptions.findIndex(
(opt) => opt.value === (secondarySelection || tvOptions[0].value)
);
} else if (type === 'show') {
secondaryActiveIndex = showOptions.findIndex(
(opt) => opt.value === (secondarySelection || showOptions[0].value)
);
}
if (secondaryActiveIndex >= 0) {
updateIndicatorPosition(
secondaryActiveIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [type]); // 只在type变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection]);
// 监听副选择器变化
useEffect(() => {
let activeIndex = -1;
let options: SelectorOption[] = [];
if (type === 'movie') {
activeIndex = movieSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = movieSecondaryOptions;
} else if (type === 'tv') {
activeIndex = tvOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = tvOptions;
} else if (type === 'show') {
activeIndex = showOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = showOptions;
}
if (options.length > 0) {
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: SelectorOption[],
activeValue: string | undefined,
onChange: (value: string) => void,
isPrimary = false
) => {
const containerRef = isPrimary
? primaryContainerRef
: secondaryContainerRef;
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
const indicatorStyle = isPrimary
? primaryIndicatorStyle
: secondaryIndicatorStyle;
return (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
return (
<div className='space-y-6'>
{/* 电影类型 - 显示两级选择器 */}
{type === 'movie' && (
<div className='space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
moviePrimaryOptions,
primarySelection || moviePrimaryOptions[0].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
movieSecondaryOptions,
secondarySelection || movieSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
</div>
)}
{/* 电视剧类型 - 只显示一级选择器 */}
{type === 'tv' && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
tvOptions,
secondarySelection || tvOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
)}
{/* 综艺类型 - 只显示一级选择器 */}
{type === 'show' && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
showOptions,
secondarySelection || showOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
)}
</div>
);
};
export default DoubanSelector;

View File

@@ -1,17 +1,6 @@
'use client';
import {
Clover,
Film,
Home,
MessageCircleHeart,
MountainSnow,
Search,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import { Clover, Film, Home, Search, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -34,36 +23,22 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
{
icon: Film,
label: '电影',
href: '/douban?type=movie&tag=热门&title=热门电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '剧集',
href: '/douban?type=tv&tag=热门&title=热门剧集',
},
{
icon: Star,
label: '高分',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
href: '/douban?type=tv',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=tv&tag=综艺&title=综艺',
href: '/douban?type=show',
},
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{
icon: MessageCircleHeart,
label: '韩剧',
href: '/douban?type=tv&tag=韩剧',
},
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
];
const isActive = (href: string) => {
const typeMatch = href.match(/type=([^&]+)/)?.[1];
const tagMatch = href.match(/tag=([^&]+)/)?.[1];
// 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(currentActive);
@@ -72,14 +47,13 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
return (
decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`) &&
decodedActive.includes(`tag=${tagMatch}`))
decodedActive.includes(`type=${typeMatch}`))
);
};
return (
<nav
className='md:hidden fixed left-0 right-0 z-20 bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-x-auto overscroll-x-contain whitespace-nowrap scrollbar-hide dark:bg-gray-900/80 dark:border-gray-700/50'
className='md:hidden fixed left-0 right-0 z-[600] bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-gray-700/50'
style={{
/* 紧贴视口底部,同时在内部留出安全区高度 */
bottom: 0,

View File

@@ -1,18 +1,6 @@
'use client';
import {
Clover,
Film,
Home,
Menu,
MessageCircleHeart,
MountainSnow,
Search,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import { Clover, Film, Home, Menu, Search, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
@@ -137,32 +125,19 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const menuItems = [
{
icon: Film,
label: '热门电影',
href: '/douban?type=movie&tag=热门&title=热门电影',
label: '电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '热门剧集',
href: '/douban?type=tv&tag=热门&title=热门剧集',
},
{
icon: Star,
label: '豆瓣 Top250',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
label: '剧集',
href: '/douban?type=tv',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=tv&tag=综艺&title=综艺',
href: '/douban?type=show',
},
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{
icon: MessageCircleHeart,
label: '韩剧',
href: '/douban?type=tv&tag=韩剧',
},
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
];
return (

View File

@@ -9,18 +9,41 @@ interface DoubanApiResponse {
}>;
}
interface DoubanClientParams {
interface DoubanRecommendsParams {
type: 'tv' | 'movie';
tag: string;
pageSize?: number;
pageStart?: number;
}
interface DoubanCategoriesParams {
kind: 'tv' | 'movie';
category: string;
type: string;
pageLimit?: number;
pageStart?: number;
}
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
/**
* 浏览器端豆瓣数据获取函数
*/
export async function fetchDoubanDataClient(
params: DoubanClientParams
export async function fetchDoubanRecommends(
params: DoubanRecommendsParams
): Promise<DoubanResult> {
const { type, tag, pageSize = 16, pageStart = 0 } = params;
@@ -37,11 +60,6 @@ export async function fetchDoubanDataClient(
throw new Error('pageStart 不能小于 0');
}
// 处理 top250 特殊情况
if (tag === 'top250') {
return handleTop250Client(pageStart);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
@@ -71,63 +89,6 @@ export async function fetchDoubanDataClient(
}
}
/**
* 处理豆瓣 Top250 数据获取
*/
async function handleTop250Client(pageStart: number): Promise<DoubanResult> {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
try {
const response = await fetchWithTimeout(target, {
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',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
// 获取 HTML 内容
const html = await response.text();
// 通过正则同时捕获影片 id、标题、封面以及评分
const moviePattern =
/<div class="item">[\s\S]*?<a[^>]+href="https?:\/\/movie\.douban\.com\/subject\/(\d+)\/"[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]*)<\/span>[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const id = match[1];
const title = match[2];
const cover = match[3];
const rate = match[4] || '';
// 处理图片 URL确保使用 HTTPS
const processedCover = cover.replace(/^http:/, 'https:');
movies.push({
id: id,
title: title,
poster: processedCover,
rate: rate,
});
}
return {
code: 200,
message: '获取成功',
list: movies,
};
} catch (error) {
throw new Error(`获取豆瓣 Top250 数据失败: ${(error as Error).message}`);
}
}
/**
* 带超时的 fetch 请求
*/
@@ -184,12 +145,12 @@ export function shouldUseDoubanClient(): boolean {
/**
* 统一的豆瓣数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanData(
params: DoubanClientParams
export async function getDoubanRecommends(
params: DoubanRecommendsParams
): Promise<DoubanResult> {
if (shouldUseDoubanClient()) {
// 使用客户端代理获取(当设置了代理 URL 时)
return fetchDoubanDataClient(params);
return fetchDoubanRecommends(params);
} else {
// 使用服务端 API当没有设置代理 URL 时)
const { type, tag, pageSize = 16, pageStart = 0 } = params;
@@ -204,3 +165,81 @@ export async function getDoubanData(
return response.json();
}
}
/**
* 浏览器端豆瓣分类数据获取函数
*/
export async function fetchDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
// 验证参数
if (!['tv', 'movie'].includes(kind)) {
throw new Error('kind 参数必须是 tv 或 movie');
}
if (!category || !type) {
throw new Error('category 和 type 参数不能为空');
}
if (pageLimit < 1 || pageLimit > 100) {
throw new Error('pageLimit 必须在 1-100 之间');
}
if (pageStart < 0) {
throw new Error('pageStart 不能小于 0');
}
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
try {
const response = await fetchWithTimeout(target);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanCategoryApiResponse = await response.json();
// 转换数据格式
const list: DoubanItem[] = doubanData.items.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
/**
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
if (shouldUseDoubanClient()) {
// 使用客户端代理获取(当设置了代理 URL 时)
return fetchDoubanCategories(params);
} else {
// 使用服务端 API当没有设置代理 URL 时)
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
const response = await fetch(
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
);
if (!response.ok) {
throw new Error('获取豆瓣分类数据失败');
}
return response.json();
}
}