'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'; import { getTMDBImageUrl } from '@/lib/tmdb.client'; import { processImageUrl } from '@/lib/utils'; 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; tmdbId?: number; mediaType?: 'movie' | 'tv'; seasonNumber?: number; } interface Episode { id: number; name: string; episode_number: number; still_path: string | null; overview: string; air_date: 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); const [seasonData, setSeasonData] = useState<{ seasons: any[]; episodes: Episode[] } | null>(null); const [loadingSeasons, setLoadingSeasons] = useState(false); const [expandedEpisodes, setExpandedEpisodes] = useState>(new Set()); const [selectedSeason, setSelectedSeason] = useState(1); const [seasonsLoaded, setSeasonsLoaded] = useState(false); // 拖动滚动状态 const [isDragging, setIsDragging] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false); const [startX, setStartX] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); const episodesScrollRef = React.useRef(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 ? processImageUrl(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 ? processImageUrl(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 ? processImageUrl(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) ? processImageUrl(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 ? processImageUrl(getTMDBImageUrl(detailResult.poster_path, 'w500')) : 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, tmdbId: detailId, mediaType: mediaType, seasonNumber: extractedSeasonNumber, }); 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]); // 异步获取季度和集数详情(仅TMDB) useEffect(() => { if (!detailData?.tmdbId || !detailData?.mediaType || detailData.mediaType !== 'tv' || seasonsLoaded) { return; } const fetchSeasonData = async () => { setLoadingSeasons(true); try { // 获取所有季度 const seasonsResponse = await fetch(`/api/tmdb/seasons?tvId=${detailData.tmdbId}`); if (!seasonsResponse.ok) return; const seasonsData = await seasonsResponse.json(); // 设置默认选中季度 const defaultSeason = detailData.seasonNumber || 1; setSelectedSeason(defaultSeason); // 获取默认季度的集数详情 const episodesResponse = await fetch( `/api/tmdb/episodes?id=${detailData.tmdbId}&season=${defaultSeason}` ); if (!episodesResponse.ok) return; const episodesData = await episodesResponse.json(); setSeasonData({ seasons: seasonsData.seasons || [], episodes: episodesData.episodes || [], }); setSeasonsLoaded(true); } catch (err) { console.error('获取季度和集数详情失败:', err); } finally { setLoadingSeasons(false); } }; fetchSeasonData(); }, [detailData?.tmdbId, detailData?.mediaType, detailData?.seasonNumber, seasonsLoaded]); // 切换季度时获取集数 const handleSeasonChange = async (seasonNumber: number) => { if (!detailData?.tmdbId || selectedSeason === seasonNumber) return; setSelectedSeason(seasonNumber); setLoadingSeasons(true); try { const episodesResponse = await fetch( `/api/tmdb/episodes?id=${detailData.tmdbId}&season=${seasonNumber}` ); if (!episodesResponse.ok) return; const episodesData = await episodesResponse.json(); // 从当前 seasonData 中查找季度信息 const season = seasonData?.seasons.find((s: any) => s.season_number === seasonNumber); setSeasonData(prev => ({ seasons: prev?.seasons || [], episodes: episodesData.episodes || [], })); // 更新季度元信息 setDetailData(prev => prev ? { ...prev, title: episodesData.name || season?.name || prev.title, intro: episodesData.overview || season?.overview || prev.overview, poster: season?.poster_path ? processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')) : prev.poster, releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate, year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year, episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount, } : null); setExpandedEpisodes(new Set()); } catch (err) { console.error('获取集数详情失败:', err); } finally { setLoadingSeasons(false); } }; // 拖动滚动处理函数 const handleMouseDown = (e: React.MouseEvent) => { if (!episodesScrollRef.current) return; setIsMouseDown(true); setStartX(e.pageX - episodesScrollRef.current.offsetLeft); setScrollLeft(episodesScrollRef.current.scrollLeft); }; const handleMouseMove = (e: React.MouseEvent) => { if (!isMouseDown || !episodesScrollRef.current) return; const x = e.pageX - episodesScrollRef.current.offsetLeft; const distance = Math.abs(x - startX); // 只有移动超过5px才进入拖动模式 if (distance > 5 && !isDragging) { setIsDragging(true); episodesScrollRef.current.style.cursor = 'grabbing'; episodesScrollRef.current.style.userSelect = 'none'; } if (isDragging) { e.preventDefault(); const walk = (x - startX) * 2; // 滚动速度倍数 episodesScrollRef.current.scrollLeft = scrollLeft - walk; } }; const handleMouseUp = () => { setIsMouseDown(false); setIsDragging(false); if (episodesScrollRef.current) { episodesScrollRef.current.style.cursor = 'grab'; episodesScrollRef.current.style.userSelect = 'auto'; } }; const handleMouseLeave = () => { if (isMouseDown || isDragging) { setIsMouseDown(false); setIsDragging(false); if (episodesScrollRef.current) { episodesScrollRef.current.style.cursor = 'grab'; episodesScrollRef.current.style.userSelect = 'auto'; } } }; 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}

)}
{/* 季度和集数信息(仅TMDB电视剧) */} {detailData.mediaType === 'tv' && (
{loadingSeasons && (
)} {!loadingSeasons && seasonData && ( <> {/* 季度列表 */} {seasonData.seasons.length > 0 && (

季度

{seasonData.seasons.map((season: any) => (
handleSeasonChange(season.season_number)} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${ selectedSeason === season.season_number ? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500' : 'bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700' }`} > {season.poster_path && (
{season.name}
)}

{season.name}

{season.episode_count} 集

))}
)} {/* 集数列表 */} {seasonData.episodes.length > 0 && (

{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`}

{seasonData.episodes.map((episode: Episode) => { const isExpanded = expandedEpisodes.has(episode.id); return (
{episode.still_path && (
{episode.name}
)}

第{episode.episode_number}集: {episode.name}

{episode.overview && (

{ const newExpanded = new Set(expandedEpisodes); if (isExpanded) { newExpanded.delete(episode.id); } else { newExpanded.add(episode.id); } setExpandedEpisodes(newExpanded); }} className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`} > {episode.overview}

)} {episode.air_date && (

{episode.air_date}

)}
); })}
)} )}
)}
)}
); return createPortal(content, document.body); }; export default DetailPanel;