diff --git a/src/app/api/tmdb/detail/route.ts b/src/app/api/tmdb/detail/route.ts new file mode 100644 index 0000000..d1cfb0c --- /dev/null +++ b/src/app/api/tmdb/detail/route.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { getNextApiKey } from '@/lib/tmdb.client'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import nodeFetch from 'node-fetch'; + +export const runtime = 'nodejs'; + +/** + * GET /api/tmdb/detail?id=xxx&type=movie|tv + * 获取TMDB详情 + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const type = searchParams.get('type') || 'movie'; + + if (!id) { + return NextResponse.json({ error: '缺少ID参数' }, { status: 400 }); + } + + const config = await getConfig(); + const tmdbApiKey = config.SiteConfig.TMDBApiKey; + const tmdbProxy = config.SiteConfig.TMDBProxy; + + const actualKey = getNextApiKey(tmdbApiKey || ''); + if (!actualKey) { + return NextResponse.json( + { error: 'TMDB API Key 未配置' }, + { status: 400 } + ); + } + + // 根据类型选择API端点 + const endpoint = type === 'movie' ? 'movie' : 'tv'; + const url = `https://api.themoviedb.org/3/${endpoint}/${id}?api_key=${actualKey}&language=zh-CN&append_to_response=credits`; + + const fetchOptions: any = tmdbProxy + ? { + agent: new HttpsProxyAgent(tmdbProxy, { + timeout: 30000, + keepAlive: false, + }), + signal: AbortSignal.timeout(30000), + } + : { + signal: AbortSignal.timeout(15000), + }; + + const response = await nodeFetch(url, fetchOptions); + + if (!response.ok) { + console.error('TMDB 详情获取失败:', response.status, response.statusText); + return NextResponse.json( + { error: 'TMDB 详情获取失败', code: response.status }, + { status: response.status } + ); + } + + const data: any = await response.json(); + + return NextResponse.json(data); + } catch (error) { + console.error('TMDB详情获取失败:', 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 9560c14..297e477 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -240,11 +240,21 @@ function HomeClient() { className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' > ))} diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx new file mode 100644 index 0000000..097a1a3 --- /dev/null +++ b/src/components/DetailPanel.tsx @@ -0,0 +1,613 @@ +'use client'; + +import { X, Calendar, Star, Clock, Tag, Users, Globe, Film } from 'lucide-react'; +import Image from 'next/image'; +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +interface DetailPanelProps { + isOpen: boolean; + onClose: () => void; + title: string; + poster?: string; + doubanId?: number; + bangumiId?: number; + isBangumi?: boolean; + tmdbId?: number; + type?: 'movie' | 'tv'; + seasonNumber?: number; + cmsData?: { + desc?: string; + episodes?: string[]; + episodes_titles?: string[]; + }; + // 用于调用 source-detail API + sourceId?: string; + source?: string; +} + +interface DetailData { + title: string; + originalTitle?: string; + year?: string; + poster?: string; + rating?: { + value: number; + count: number; + }; + intro?: string; + genres?: string[]; + directors?: Array<{ name: string }>; + actors?: Array<{ name: string }>; + countries?: string[]; + languages?: string[]; + duration?: string; + episodesCount?: number; + releaseDate?: string; + status?: string; + tagline?: string; + seasons?: number; + overview?: string; +} + +const DetailPanel: React.FC = ({ + isOpen, + onClose, + title, + poster, + doubanId, + bangumiId, + isBangumi, + tmdbId, + type = 'movie', + seasonNumber, + cmsData, + sourceId, + source, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [mounted, setMounted] = useState(false); + const [detailData, setDetailData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 确保组件在客户端挂载后才渲染 Portal + useEffect(() => { + setMounted(true); + }, []); + + // 控制动画状态 + useEffect(() => { + let animationId: number; + let timer: NodeJS.Timeout; + + if (isOpen) { + setIsVisible(true); + animationId = requestAnimationFrame(() => { + animationId = requestAnimationFrame(() => { + setIsAnimating(true); + }); + }); + } else { + setIsAnimating(false); + timer = setTimeout(() => { + setIsVisible(false); + }, 200); + } + + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + if (timer) { + clearTimeout(timer); + } + }; + }, [isOpen]); + + // 阻止背景滚动 + useEffect(() => { + if (isVisible) { + // 保存当前滚动位置 + const scrollY = window.scrollY; + const scrollX = window.scrollX; + const body = document.body; + const html = document.documentElement; + + // 获取滚动条宽度 + const scrollBarWidth = window.innerWidth - html.clientWidth; + + // 保存原始样式 + const originalBodyStyle = { + position: body.style.position, + top: body.style.top, + left: body.style.left, + right: body.style.right, + width: body.style.width, + paddingRight: body.style.paddingRight, + overflow: body.style.overflow, + }; + + // 设置body样式来阻止滚动,但保持原位置 + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = `-${scrollX}px`; + body.style.right = '0'; + body.style.width = '100%'; + body.style.overflow = 'hidden'; + body.style.paddingRight = `${scrollBarWidth}px`; + + return () => { + // 恢复所有原始样式 + body.style.position = originalBodyStyle.position; + body.style.top = originalBodyStyle.top; + body.style.left = originalBodyStyle.left; + body.style.right = originalBodyStyle.right; + body.style.width = originalBodyStyle.width; + body.style.paddingRight = originalBodyStyle.paddingRight; + body.style.overflow = originalBodyStyle.overflow; + + // 使用 requestAnimationFrame 确保样式恢复后再滚动 + requestAnimationFrame(() => { + window.scrollTo(scrollX, scrollY); + }); + }; + } + }, [isVisible]); + + // ESC键关闭 + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + if (isVisible) { + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + } + }, [isVisible, onClose]); + + // 获取详情数据 + useEffect(() => { + if (!isOpen) { + return; + } + + const fetchDetail = async () => { + setLoading(true); + setError(null); + + try { + // 优先使用苹果CMS数据(短剧等) + // 如果 cmsData 存在但 desc 为空,尝试通过 source-detail API 获取 + if (cmsData) { + if (cmsData.desc) { + // 有 desc,直接使用 + setDetailData({ + title: title, + intro: cmsData.desc, + episodesCount: cmsData.episodes?.length, + poster: poster, + }); + setLoading(false); + return; + } + + // cmsData 存在但 desc 为空,尝试通过 API 获取详情 + if (sourceId && source) { + try { + const response = await fetch( + `/api/source-detail?id=${encodeURIComponent(sourceId)}&source=${encodeURIComponent(source)}&title=${encodeURIComponent(title)}` + ); + if (response.ok) { + const data = await response.json(); + setDetailData({ + title: data.title || title, + intro: data.desc || '', + episodesCount: data.episodes?.length || cmsData.episodes?.length, + poster: data.poster || poster, + year: data.year, + }); + setLoading(false); + return; + } + } catch (err) { + console.error('获取source-detail失败:', err); + // 继续执行后续逻辑 + } + } + } + + // 优先使用 Bangumi ID(因为 isBangumi 为 true 时,doubanId 实际上是 bangumiId) + if (bangumiId || (isBangumi && doubanId)) { + const actualBangumiId = bangumiId || doubanId; + const response = await fetch(`https://api.bgm.tv/v0/subjects/${actualBangumiId}`); + if (!response.ok) { + throw new Error('获取Bangumi详情失败'); + } + const data = await response.json(); + + setDetailData({ + title: data.name_cn || data.name, + originalTitle: data.name, + year: data.date ? data.date.substring(0, 4) : undefined, + poster: data.images?.large || poster, + rating: data.rating + ? { + value: data.rating.score, + count: data.rating.total, + } + : undefined, + intro: data.summary, + genres: data.tags?.map((tag: any) => tag.name).slice(0, 5), + episodesCount: data.eps, + releaseDate: data.date, + }); + return; + } + + // 使用豆瓣ID + if (doubanId && !isBangumi) { + const response = await fetch(`/api/douban/detail?id=${doubanId}`); + if (!response.ok) { + throw new Error('获取豆瓣详情失败'); + } + const data = await response.json(); + + setDetailData({ + title: data.title, + originalTitle: data.original_title, + year: data.year, + poster: data.pic?.large || data.pic?.normal || poster, + rating: data.rating + ? { + value: data.rating.value, + count: data.rating.count, + } + : undefined, + intro: data.intro, + genres: data.genres, + directors: data.directors, + actors: data.actors, + countries: data.countries, + languages: data.languages, + duration: data.durations?.[0], + episodesCount: data.episodes_count, + }); + return; + } + + // 使用 TMDB 搜索 + if (title) { + // 移除季度信息进行搜索 + let searchTitle = title; + let extractedSeasonNumber = seasonNumber; + + // 匹配各种季度格式: 第一季、第1季、第一部、Season 1、S1等 + const seasonPatterns = [ + /第([一二三四五六七八九十\d]+)[季部]/, + /Season\s*(\d+)/i, + /S(\d+)/i, + ]; + + for (const pattern of seasonPatterns) { + const match = title.match(pattern); + if (match) { + searchTitle = title.replace(pattern, '').trim(); + // 如果没有传入seasonNumber,尝试从标题中提取 + if (!extractedSeasonNumber) { + const seasonStr = match[1]; + // 中文数字转数字 + const chineseNumbers: Record = { + '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, + '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, + }; + extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined; + } + break; + } + } + + const searchResponse = await fetch( + `/api/tmdb/search?query=${encodeURIComponent(searchTitle)}` + ); + if (!searchResponse.ok) { + throw new Error('搜索失败'); + } + const searchData = await searchResponse.json(); + + if (searchData.results && searchData.results.length > 0) { + const result = searchData.results[0]; + const detailId = result.id; + const mediaType = result.media_type || type; + + // 获取详情 + const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`); + if (!detailResponse.ok) { + throw new Error('获取TMDB详情失败'); + } + const detailResult = await detailResponse.json(); + + // 如果有季度信息,尝试获取季度详情 + let seasonData = null; + if (extractedSeasonNumber && mediaType === 'tv') { + try { + const seasonResponse = await fetch( + `/api/tmdb/seasons?id=${detailId}&season=${extractedSeasonNumber}` + ); + if (seasonResponse.ok) { + seasonData = await seasonResponse.json(); + } + } catch (err) { + console.error('获取季度信息失败', err); + } + } + + setDetailData({ + title: mediaType === 'movie' ? detailResult.title : detailResult.name, + originalTitle: + mediaType === 'movie' ? detailResult.original_title : detailResult.original_name, + year: + mediaType === 'movie' + ? detailResult.release_date?.substring(0, 4) + : detailResult.first_air_date?.substring(0, 4), + poster: detailResult.poster_path + ? `https://image.tmdb.org/t/p/w500${detailResult.poster_path}` + : poster, + rating: detailResult.vote_average + ? { + value: detailResult.vote_average, + count: detailResult.vote_count, + } + : undefined, + intro: seasonData?.overview || detailResult.overview, + genres: detailResult.genres?.map((g: any) => g.name), + countries: detailResult.production_countries?.map((c: any) => c.name), + languages: detailResult.spoken_languages?.map((l: any) => l.name), + duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined, + episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes, + releaseDate: + mediaType === 'movie' ? detailResult.release_date : detailResult.first_air_date, + status: detailResult.status, + tagline: detailResult.tagline, + seasons: detailResult.number_of_seasons, + overview: detailResult.overview, + }); + return; + } + + throw new Error('未找到相关内容'); + } + + throw new Error('缺少必要的查询参数'); + } catch (err) { + console.error('获取详情失败:', err); + setError(err instanceof Error ? err.message : '获取详情失败'); + } finally { + setLoading(false); + } + }; + + fetchDetail(); + }, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source]); + + if (!isVisible || !mounted) return null; + + const content = ( +
+ {/* 背景遮罩 */} +
+ + {/* 详情面板 */} +
+ {/* 头部 */} +
+

详情

+ +
+ + {/* 内容区域 */} +
+ {loading && ( +
+
+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!loading && !error && detailData && ( +
+ {/* 海报和基本信息 */} +
+ {detailData.poster && ( +
+ {detailData.title} +
+ )} +
+

+ {detailData.title} +

+ {detailData.originalTitle && detailData.originalTitle !== detailData.title && ( +

+ {detailData.originalTitle} +

+ )} + + {/* 评分 */} + {detailData.rating && ( +
+ + + {detailData.rating.value.toFixed(1)} + + {detailData.rating.count > 0 && ( + + ({detailData.rating.count} 评价) + + )} +
+ )} + + {/* 类型标签 */} + {detailData.genres && detailData.genres.length > 0 && ( +
+ {detailData.genres.map((genre, index) => ( + + {genre} + + ))} +
+ )} + + {/* 年份和时长 */} +
+ {detailData.year && ( +
+ + {detailData.year} +
+ )} + {detailData.duration && ( +
+ + {detailData.duration} +
+ )} + {detailData.episodesCount && ( +
+ + {detailData.episodesCount} 集 +
+ )} +
+
+
+ + {/* 简介 */} + {(detailData.intro || detailData.overview) && ( +
+

+ 简介 +

+

+ {detailData.intro || detailData.overview} +

+
+ )} + + {/* 导演和演员 */} + {detailData.directors && detailData.directors.length > 0 && ( +
+

+ + 导演 +

+

+ {detailData.directors.map((d) => d.name).join(', ')} +

+
+ )} + + {detailData.actors && detailData.actors.length > 0 && ( +
+

+ + 演员 +

+

+ {detailData.actors.slice(0, 10).map((a) => a.name).join(', ')} +

+
+ )} + + {/* 制作信息 */} +
+ {detailData.countries && detailData.countries.length > 0 && ( +
+

+ + 国家/地区 +

+

+ {detailData.countries.join(', ')} +

+
+ )} + + {detailData.languages && detailData.languages.length > 0 && ( +
+

+ + 语言 +

+

+ {detailData.languages.join(', ')} +

+
+ )} + + {detailData.releaseDate && ( +
+

+ + 上映日期 +

+

{detailData.releaseDate}

+
+ )} + + {detailData.status && ( +
+

状态

+

{detailData.status}

+
+ )} +
+
+ )} +
+
+
+ ); + + return createPortal(content, document.body); +}; + +export default DetailPanel; diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 1dccfb6..630e3fd 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */ -import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react'; +import { ExternalLink, Heart, Info, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { @@ -27,6 +27,7 @@ import { useLongPress } from '@/hooks/useLongPress'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; import MobileActionSheet from '@/components/MobileActionSheet'; import AIChatPanel from '@/components/AIChatPanel'; +import DetailPanel from '@/components/DetailPanel'; export interface VideoCardProps { id?: string; @@ -56,6 +57,11 @@ export interface VideoCardProps { orientation?: 'vertical' | 'horizontal'; // 卡片方向 playTime?: number; // 当前播放时间(秒) totalTime?: number; // 总时长(秒) + cmsData?: { + desc?: string; + episodes?: string[]; + episodes_titles?: string[]; + }; } export type VideoCardHandle = { @@ -93,6 +99,7 @@ const VideoCard = forwardRef(function VideoCard orientation = 'vertical', playTime, totalTime, + cmsData, }: VideoCardProps, ref ) { @@ -103,6 +110,7 @@ const VideoCard = forwardRef(function VideoCard const [searchFavorited, setSearchFavorited] = useState(null); // 搜索结果的收藏状态 const [showAIChat, setShowAIChat] = useState(false); const [aiEnabled, setAiEnabled] = useState(false); + const [showDetailPanel, setShowDetailPanel] = useState(false); // 检查AI功能是否启用 useEffect(() => { @@ -553,6 +561,21 @@ const VideoCard = forwardRef(function VideoCard }); } + // 详情页面按钮 + actions.push({ + id: 'detail', + label: '详情', + icon: , + onClick: () => { + setShowMobileActions(false); + // 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成 + setTimeout(() => { + setShowDetailPanel(true); + }, 250); + }, + color: 'default' as const, + }); + // AI问片功能 if (aiEnabled && actualTitle) { actions.push({ @@ -1167,7 +1190,7 @@ const VideoCard = forwardRef(function VideoCard )} {/* 来源 - 右侧 */} - {config.showSourceName && source_name && ( + {config.showSourceName && source_name && !cmsData && ( (function VideoCard )} {/* 直播时只显示来源 */} - {origin === 'live' && config.showSourceName && source_name && ( + {origin === 'live' && config.showSourceName && source_name && !cmsData && (
(function VideoCard >
- {config.showSourceName && source_name && ( + {config.showSourceName && source_name && !cmsData && ( (function VideoCard actions={mobileActions} sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined} isAggregate={isAggregate} - sourceName={source_name} + sourceName={cmsData ? undefined : source_name} currentEpisode={currentEpisode} totalEpisodes={actualEpisodes} origin={origin} @@ -1402,6 +1425,25 @@ const VideoCard = forwardRef(function VideoCard welcomeMessage={`想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`} /> )} + + {/* 详情面板 */} + {showDetailPanel && ( + setShowDetailPanel(false)} + title={actualTitle} + poster={processImageUrl(actualPoster)} + doubanId={actualDoubanId} + bangumiId={isBangumi ? actualDoubanId : undefined} + isBangumi={isBangumi} + tmdbId={tmdb_id} + type={actualSearchType as 'movie' | 'tv'} + seasonNumber={seasonNumber} + cmsData={cmsData} + sourceId={id} + source={source} + /> + )} ); }