'use client'; import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X, } from 'lucide-react'; import Image from 'next/image'; import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { getBangumiSubject, getBangumiSubjectUrl } from '@/lib/bangumi.client'; import { appendSpecialSourceParam } from '@/lib/special-source.client'; import { getTMDBImageUrl } from '@/lib/tmdb.client'; import { processImageUrl } from '@/lib/utils'; import ImageViewer from '@/components/ImageViewer'; import ProxyImage from '@/components/ProxyImage'; interface DetailPanelProps { isOpen: boolean; onClose: () => void; title: string; poster?: string; doubanId?: number; bangumiId?: number; isBangumi?: boolean; tmdbId?: number; type?: 'movie' | 'tv'; seasonNumber?: number; currentEpisode?: number; cmsData?: { desc?: string; episodes?: string[]; episodes_titles?: string[]; }; sourceId?: string; source?: string; useDrawer?: boolean; drawerWidth?: string; } interface DetailData { title: string; originalTitle?: string; year?: string; poster?: string; rating?: { value: number; count: number; }; intro?: string; genres?: string[]; directors?: Array<{ name: string; profile_path?: string }>; actors?: Array<{ name: string; character?: string; profile_path?: 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; seriesTitle?: string; } interface Episode { id: number; name: string; episode_number: number; still_path: string | null; overview: string; air_date: string; } interface GalleryImage { file_path: string; width: number; height: number; vote_average?: number; vote_count?: number; iso_639_1?: string | null; imageType: 'backdrop' | 'poster'; } const DetailPanel: React.FC = ({ isOpen, onClose, title, poster, doubanId, bangumiId, isBangumi, tmdbId, type = 'movie', seasonNumber, currentEpisode, cmsData, sourceId, source, useDrawer = false, drawerWidth = 'w-full md:w-[25%]', }) => { 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 [showImageViewer, setShowImageViewer] = useState(false); const [selectedImage, setSelectedImage] = useState(''); const [showGallery, setShowGallery] = useState(false); const [galleryLoading, setGalleryLoading] = useState(false); const [galleryError, setGalleryError] = useState(null); const [galleryImages, setGalleryImages] = useState([]); const [galleryTotal, setGalleryTotal] = useState(0); const [galleryScrollTop, setGalleryScrollTop] = useState(0); const [galleryViewportHeight, setGalleryViewportHeight] = useState(0); const [galleryViewportWidth, setGalleryViewportWidth] = useState(0); const galleryScrollRef = React.useRef(null); // 数据源状态管理 const [currentSource, setCurrentSource] = useState< 'douban' | 'bangumi' | 'cms' | 'tmdb' >('tmdb'); const [originalSource, setOriginalSource] = useState< 'douban' | 'bangumi' | 'cms' | 'tmdb' >('tmdb'); const [isUsingTmdb, setIsUsingTmdb] = useState(false); const [originalDetailData, setOriginalDetailData] = useState(null); const getExternalUrl = () => { if (currentSource === 'douban' && doubanId) { return `https://movie.douban.com/subject/${doubanId}`; } if (currentSource === 'bangumi') { const actualBangumiId = bangumiId || doubanId; if (actualBangumiId) { return getBangumiSubjectUrl(actualBangumiId); } } if (currentSource === 'tmdb') { const actualTmdbId = detailData?.tmdbId || tmdbId; const actualMediaType = detailData?.mediaType || type; if (actualTmdbId) { return `https://www.themoviedb.org/${actualMediaType}/${actualTmdbId}`; } } return null; }; const externalUrl = getExternalUrl(); // 拖动滚动状态 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); const actorsScrollRef = React.useRef(null); const [isActorsDragging, setIsActorsDragging] = useState(false); const [isActorsMouseDown, setIsActorsMouseDown] = useState(false); const [actorsStartX, setActorsStartX] = useState(0); const [actorsScrollLeft, setActorsScrollLeft] = useState(0); // 图片点击处理 const handleImageClick = (imageUrl: string) => { setSelectedImage(imageUrl); setShowImageViewer(true); }; const galleryTmdbId = detailData?.tmdbId || tmdbId; const galleryMediaType = detailData?.mediaType || type; const canShowGalleryEntry = !!galleryTmdbId && !!galleryMediaType; const fetchGalleryImages = async () => { if (!galleryTmdbId || !galleryMediaType) return; setGalleryLoading(true); setGalleryError(null); try { const response = await fetch( `/api/tmdb/images?id=${galleryTmdbId}&type=${galleryMediaType}` ); if (!response.ok) { throw new Error('获取照片墙失败'); } const data = await response.json(); setGalleryImages(data.list || []); setGalleryTotal(data.total || 0); } catch (err) { console.error('获取照片墙失败:', err); setGalleryError(err instanceof Error ? err.message : '获取照片墙失败'); } finally { setGalleryLoading(false); } }; const openGallery = () => { setShowGallery(true); }; // 确保组件在客户端挂载后才渲染 Portal useEffect(() => { setMounted(true); }, []); useEffect(() => { if (!showGallery) { setGalleryImages([]); setGalleryError(null); setGalleryLoading(false); setGalleryTotal(0); setGalleryScrollTop(0); setGalleryViewportHeight(0); setGalleryViewportWidth(0); return; } fetchGalleryImages(); }, [showGallery, galleryTmdbId, galleryMediaType]); useEffect(() => { if (!showGallery || !galleryScrollRef.current) return; const element = galleryScrollRef.current; const updateMetrics = () => { setGalleryViewportHeight(element.clientHeight); setGalleryViewportWidth(element.clientWidth); setGalleryScrollTop(element.scrollTop); }; updateMetrics(); element.addEventListener('scroll', updateMetrics, { passive: true }); const resizeObserver = new ResizeObserver(updateMetrics); resizeObserver.observe(element); return () => { element.removeEventListener('scroll', updateMetrics); resizeObserver.disconnect(); }; }, [showGallery]); // 控制动画状态 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 (!isOpen) { setShowGallery(false); } }, [isOpen]); // 阻止背景滚动(仅在非抽屉模式下) useEffect(() => { if (isVisible && !useDrawer) { // 保存当前滚动位置 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, useDrawer]); // 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 { // 如果正在使用 TMDB 数据,强制使用 TMDB if (isUsingTmdb && title) { await fetchTmdbData(); return; } // 优先使用苹果CMS数据(短剧等) // 如果 cmsData 存在但 desc 为空,尝试通过 source-detail API 获取 if (cmsData) { setCurrentSource('cms'); setOriginalSource('cms'); if (cmsData.desc) { // 有 desc,直接使用 const data = { title: title, intro: cmsData.desc, episodesCount: cmsData.episodes?.length, poster: poster, }; setDetailData(data); setOriginalDetailData(data); setLoading(false); return; } // cmsData 存在但 desc 为空,尝试通过 API 获取详情 if (sourceId && source) { try { const response = await fetch( appendSpecialSourceParam(`/api/source-detail?id=${encodeURIComponent( sourceId )}&source=${encodeURIComponent( source )}&title=${encodeURIComponent(title)}`) ); if (response.ok) { const data = await response.json(); const detailData = { title: data.title || title, intro: data.desc || '', episodesCount: data.episodes?.length || cmsData.episodes?.length, poster: data.poster || poster, year: data.year, }; setDetailData(detailData); setOriginalDetailData(detailData); setLoading(false); return; } } catch (err) { console.error('获取source-detail失败:', err); // 继续执行后续逻辑 } } } // 优先使用 Bangumi ID(因为 isBangumi 为 true 时,doubanId 实际上是 bangumiId) if (bangumiId || (isBangumi && doubanId)) { setCurrentSource('bangumi'); setOriginalSource('bangumi'); const actualBangumiId = bangumiId || doubanId; if (!actualBangumiId) { throw new Error('Bangumi ID 缺失'); } const data = await getBangumiSubject(actualBangumiId); const detailData = { 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, }; setDetailData(detailData); setOriginalDetailData(detailData); return; } // 使用豆瓣ID if (doubanId && !isBangumi) { setCurrentSource('douban'); setOriginalSource('douban'); const response = await fetch(`/api/douban/detail?id=${doubanId}`); if (!response.ok) { throw new Error('获取豆瓣详情失败'); } const data = await response.json(); const detailData = { 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, }; setDetailData(detailData); setOriginalDetailData(detailData); return; } // 使用 TMDB 搜索 if (title) { setCurrentSource('tmdb'); setOriginalSource('tmdb'); await fetchTmdbData(); return; } throw new Error('缺少必要的查询参数'); } catch (err) { console.error('获取详情失败:', err); setError(err instanceof Error ? err.message : '获取详情失败'); } finally { setLoading(false); } }; // 提取 TMDB 数据获取逻辑为独立函数 const fetchTmdbData = async () => { setCurrentSource('tmdb'); // 移除季度信息进行搜索 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/episodes?id=${detailId}&season=${extractedSeasonNumber}` ); if (seasonResponse.ok) { seasonData = await seasonResponse.json(); } } catch (err) { console.error('获取季度信息失败', err); } } setDetailData({ title: mediaType === 'movie' ? detailResult.title : seasonData?.name ? `${detailResult.name} ${seasonData.name}` : detailResult.name, originalTitle: mediaType === 'movie' ? detailResult.original_title : detailResult.original_name, year: mediaType === 'movie' ? detailResult.release_date?.substring(0, 4) : seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4), poster: seasonData?.poster_path || detailResult.poster_path ? processImageUrl( getTMDBImageUrl( seasonData?.poster_path || 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 : seasonData?.air_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, seriesTitle: mediaType === 'tv' ? detailResult.name : undefined, }); return; } throw new Error('未找到相关内容'); }; fetchDetail(); }, [ isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source, isUsingTmdb, ]); // 切换数据源的函数 const handleToggleSource = async () => { if (currentSource === 'tmdb') { // 切换回原始数据源 if (originalDetailData) { setDetailData(originalDetailData); setCurrentSource(originalSource); setError(null); } } else { // 切换到 TMDB // 保存当前数据 if (detailData && !originalDetailData) { setOriginalDetailData(detailData); } setLoading(true); setError(null); try { await fetchTmdbDataForToggle(); } catch (err) { console.error('切换到TMDB失败:', err); setError(err instanceof Error ? err.message : '切换到TMDB失败'); // 切换失败,但保持 currentSource 为 tmdb,这样可以显示切换回按钮 setCurrentSource('tmdb'); } finally { setLoading(false); } } }; // 用于切换时获取 TMDB 数据 const fetchTmdbDataForToggle = async () => { // 移除季度信息进行搜索 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/episodes?id=${detailId}&season=${extractedSeasonNumber}` ); if (seasonResponse.ok) { seasonData = await seasonResponse.json(); } } catch (err) { console.error('获取季度信息失败', err); } } setDetailData({ title: mediaType === 'movie' ? detailResult.title : seasonData?.name ? `${detailResult.name} ${seasonData.name}` : detailResult.name, originalTitle: mediaType === 'movie' ? detailResult.original_title : detailResult.original_name, year: mediaType === 'movie' ? detailResult.release_date?.substring(0, 4) : seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4), poster: seasonData?.poster_path || detailResult.poster_path ? processImageUrl( getTMDBImageUrl( seasonData?.poster_path || 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 : seasonData?.air_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, seriesTitle: mediaType === 'tv' ? detailResult.name : undefined, }); setCurrentSource('tmdb'); return; } throw new Error('未找到相关内容'); }; // 异步获取季度和集数详情(仅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, ]); // 自动滚动到当前集数 useEffect(() => { if ( !currentEpisode || !seasonData?.episodes || !episodesScrollRef.current || currentSource !== 'tmdb' ) { return; } // 等待 DOM 更新后再滚动 const timer = setTimeout(() => { const episodeElement = document.getElementById( `episode-${currentEpisode}` ); if (episodeElement && episodesScrollRef.current) { // 计算滚动位置,使当前集数居中显示 const container = episodesScrollRef.current; const elementLeft = episodeElement.offsetLeft; const elementWidth = episodeElement.offsetWidth; const containerWidth = container.offsetWidth; const scrollLeft = elementLeft - containerWidth / 2 + elementWidth / 2; container.scrollLeft = scrollLeft; } }, 100); return () => clearTimeout(timer); }, [currentEpisode, seasonData?.episodes, currentSource]); // 异步获取演职人员信息(仅TMDB) useEffect(() => { if ( !detailData?.tmdbId || !detailData?.mediaType || currentSource !== 'tmdb' ) { return; } // 如果已经有演员信息,不重复获取 if (detailData.actors && detailData.actors.length > 0) { return; } const fetchCredits = async () => { try { const creditsResponse = await fetch( `/api/tmdb/credits?id=${detailData.tmdbId}&type=${detailData.mediaType}` ); if (!creditsResponse.ok) return; const creditsData = await creditsResponse.json(); // 更新演员和导演信息 setDetailData((prev) => prev ? { ...prev, directors: creditsData.crew ?.filter((person: any) => person.job === 'Director') .slice(0, 5) .map((person: any) => ({ name: person.name, profile_path: person.profile_path, })) || prev.directors, actors: creditsData.cast?.slice(0, 15).map((person: any) => ({ name: person.name, character: person.character, profile_path: person.profile_path, })) || prev.actors, } : null ); } catch (err) { console.error('获取演职人员信息失败:', err); } }; fetchCredits(); }, [ detailData?.tmdbId, detailData?.mediaType, currentSource, detailData?.actors, ]); // 切换季度时获取集数 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.seriesTitle || prev.title} ${ episodesData.name || season?.name }` : prev.title, intro: episodesData.overview || season?.overview || prev.overview, poster: season?.poster_path ? 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'; } } }; // 演员列表拖动滚动处理函数 const handleActorsMouseDown = (e: React.MouseEvent) => { if (!actorsScrollRef.current) return; setIsActorsMouseDown(true); setActorsStartX(e.pageX - actorsScrollRef.current.offsetLeft); setActorsScrollLeft(actorsScrollRef.current.scrollLeft); }; const handleActorsMouseMove = (e: React.MouseEvent) => { if (!isActorsMouseDown || !actorsScrollRef.current) return; const x = e.pageX - actorsScrollRef.current.offsetLeft; const distance = Math.abs(x - actorsStartX); // 只有移动超过5px才进入拖动模式 if (distance > 5 && !isActorsDragging) { setIsActorsDragging(true); actorsScrollRef.current.style.cursor = 'grabbing'; actorsScrollRef.current.style.userSelect = 'none'; } if (isActorsDragging) { e.preventDefault(); const walk = (x - actorsStartX) * 2; // 滚动速度倍数 actorsScrollRef.current.scrollLeft = actorsScrollLeft - walk; } }; const handleActorsMouseUp = () => { setIsActorsMouseDown(false); setIsActorsDragging(false); if (actorsScrollRef.current) { actorsScrollRef.current.style.cursor = 'grab'; actorsScrollRef.current.style.userSelect = 'auto'; } }; const handleActorsMouseLeave = () => { if (isActorsMouseDown || isActorsDragging) { setIsActorsMouseDown(false); setIsActorsDragging(false); if (actorsScrollRef.current) { actorsScrollRef.current.style.cursor = 'grab'; actorsScrollRef.current.style.userSelect = 'auto'; } } }; const galleryEntryButton = canShowGalleryEntry ? ( ) : null; const virtualGalleryLayout = React.useMemo(() => { if (galleryImages.length === 0 || galleryViewportWidth <= 0) { return { visibleItems: [] as Array< GalleryImage & { top: number; left: number; renderWidth: number; renderHeight: number; index: number; } >, totalHeight: 0, usedWidth: 0, }; } const gap = 4; const overscan = 800; const horizontalPadding = 32; const width = Math.max(galleryViewportWidth - horizontalPadding, 0); const columnCount = width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2; const columnWidth = Math.floor( (width - gap * (columnCount - 1)) / columnCount ); const usedWidth = columnWidth * columnCount + gap * (columnCount - 1); const columnHeights = new Array(columnCount).fill(0); const items = galleryImages.map((image, index) => { let targetColumn = 0; for (let i = 1; i < columnCount; i++) { if (columnHeights[i] < columnHeights[targetColumn]) { targetColumn = i; } } const ratio = image.width && image.height ? image.height / image.width : image.imageType === 'poster' ? 1.5 : 0.5625; const renderHeight = Math.max(Math.round(columnWidth * ratio), 80); const top = columnHeights[targetColumn]; const left = targetColumn * (columnWidth + gap); columnHeights[targetColumn] += renderHeight + gap; return { ...image, index, top, left, renderWidth: columnWidth, renderHeight, }; }); const totalHeight = Math.max(...columnHeights, 0); const minVisibleTop = Math.max(galleryScrollTop - overscan, 0); const maxVisibleBottom = galleryScrollTop + galleryViewportHeight + overscan; const visibleItems = items.filter( (item) => item.top + item.renderHeight >= minVisibleTop && item.top <= maxVisibleBottom ); return { visibleItems, totalHeight, usedWidth }; }, [ galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth, ]); const galleryBody = (
{galleryLoading && (
)} {!galleryLoading && galleryError && (
{galleryError}
)} {!galleryLoading && !galleryError && galleryImages.length === 0 && (
暂无图片
)} {!galleryLoading && !galleryError && galleryImages.length > 0 && (
{virtualGalleryLayout.visibleItems.map((image) => { const imageUrl = getTMDBImageUrl( image.file_path, image.imageType === 'poster' ? 'w500' : 'original' ); const thumbUrl = getTMDBImageUrl( image.file_path, image.imageType === 'poster' ? 'w342' : 'w780' ); return (
handleImageClick(imageUrl)} >
{image.imageType === 'poster' ? '海报' : '剧照'}
); })}
)}
); const galleryHeader = (

照片墙

{!galleryLoading && (

共 {galleryTotal} 张

)}
); const galleryModal = showGallery ? ( useDrawer ? (
{galleryHeader} {galleryBody}
) : (
setShowGallery(false)} />
{galleryHeader} {galleryBody}
) ) : null; if (!isVisible || !mounted) return null; const content = useDrawer ? (
{/* 详情面板 - 抽屉模式 */}
{/* 头部 */}

详情

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

{error}

{/* 数据源显示和切换 - 错误时也显示 */}
数据来源: {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} {currentSource === 'tmdb' && 'TMDB'}
{galleryEntryButton} {currentSource !== 'tmdb' && ( )} {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( )}
)} {!loading && !error && detailData && (
{/* 海报和基本信息 */}
{detailData.poster && (
handleImageClick(detailData.poster!)} >
{galleryEntryButton}
)}

{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 && (

演员

{currentSource === 'tmdb' ? (
{detailData.actors.map((actor, index) => (
{actor.profile_path ? (
handleImageClick( getTMDBImageUrl( actor.profile_path || null, 'w185' ) ) } >
) : (
)} e.stopPropagation()} > {actor.name} {actor.character && (

{actor.character}

)}
))}
) : (

{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 && (
{ e.stopPropagation(); handleImageClick( getTMDBImageUrl( season.poster_path, 'w500' ) ); }} >
)}

{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 ); const isCurrentEpisode = currentEpisode === episode.episode_number; return (
{episode.still_path && (
handleImageClick( getTMDBImageUrl( episode.still_path, 'w500' ) ) } >
)}

第{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}

)}
); })}
)} )}
)} {/* 数据源显示和切换 */}
数据来源: {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} {currentSource === 'tmdb' && 'TMDB'}
{galleryEntryButton} {currentSource !== 'tmdb' && ( )} {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( )}
)}
{/* 图片查看器 */} {galleryModal} {showImageViewer && ( setShowImageViewer(false)} imageUrl={selectedImage} alt={detailData?.title || title} /> )}
) : (
{/* 背景遮罩 */}
{/* 详情面板 - 居中模式 */}
{/* 头部 */}

详情

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

{error}

{/* 数据源显示和切换 - 错误时也显示 */}
数据来源: {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} {currentSource === 'tmdb' && 'TMDB'}
{currentSource !== 'tmdb' && ( )} {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( )}
)} {!loading && !error && detailData && (
{/* 海报和基本信息 */}
{detailData.poster && (
handleImageClick(detailData.poster!)} >
{galleryEntryButton}
)}

{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 && (

演员

{currentSource === 'tmdb' ? (
{detailData.actors.map((actor, index) => (
{actor.profile_path ? (
handleImageClick( getTMDBImageUrl( actor.profile_path || null, 'w185' ) ) } >
) : (
)} e.stopPropagation()} > {actor.name} {actor.character && (

{actor.character}

)}
))}
) : (

{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 && (
{ e.stopPropagation(); handleImageClick( getTMDBImageUrl( season.poster_path, 'w500' ) ); }} >
)}

{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 ); const isCurrentEpisode = currentEpisode === episode.episode_number; return (
{episode.still_path && (
handleImageClick( getTMDBImageUrl( episode.still_path, 'w500' ) ) } >
)}

第{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}

)}
); })}
)} )}
)} {/* 数据源显示和切换 */}
数据来源: {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} {currentSource === 'tmdb' && 'TMDB'}
{currentSource !== 'tmdb' && ( )} {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( )}
)}
{/* 图片查看器 */} {galleryModal} {showImageViewer && ( setShowImageViewer(false)} imageUrl={selectedImage} alt={detailData?.title || title} /> )}
); return createPortal(content, document.body); }; export default DetailPanel;