播放页面的弹窗在大屏下使用抽屉式
This commit is contained in:
@@ -21,15 +21,16 @@ interface DetailPanelProps {
|
||||
tmdbId?: number;
|
||||
type?: 'movie' | 'tv';
|
||||
seasonNumber?: number;
|
||||
currentEpisode?: number; // 当前集数(从1开始)
|
||||
currentEpisode?: number;
|
||||
cmsData?: {
|
||||
desc?: string;
|
||||
episodes?: string[];
|
||||
episodes_titles?: string[];
|
||||
};
|
||||
// 用于调用 source-detail API
|
||||
sourceId?: string;
|
||||
source?: string;
|
||||
useDrawer?: boolean;
|
||||
drawerWidth?: string;
|
||||
}
|
||||
|
||||
interface DetailData {
|
||||
@@ -83,6 +84,8 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
cmsData,
|
||||
sourceId,
|
||||
source,
|
||||
useDrawer = false,
|
||||
drawerWidth = 'w-full md:w-[25%]',
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
@@ -156,9 +159,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 阻止背景滚动
|
||||
// 阻止背景滚动(仅在非抽屉模式下)
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
if (isVisible && !useDrawer) {
|
||||
// 保存当前滚动位置
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
@@ -204,7 +207,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [isVisible]);
|
||||
}, [isVisible, useDrawer]);
|
||||
|
||||
// ESC键关闭
|
||||
useEffect(() => {
|
||||
@@ -860,7 +863,481 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
|
||||
if (!isVisible || !mounted) return null;
|
||||
|
||||
const content = (
|
||||
const content = useDrawer ? (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none">
|
||||
{/* 详情面板 - 抽屉模式 */}
|
||||
<div
|
||||
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
|
||||
isAnimating ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">详情</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
|
||||
{/* 数据源显示和切换 - 错误时也显示 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
{currentSource === 'douban' && 'Douban'}
|
||||
{currentSource === 'bangumi' && 'Bangumi'}
|
||||
{currentSource === 'cms' && 'CMS'}
|
||||
{currentSource === 'tmdb' && 'TMDB'}
|
||||
</span>
|
||||
</div>
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && detailData && (
|
||||
<div className="p-6">
|
||||
{/* 海报和基本信息 */}
|
||||
<div className="flex gap-6 mb-6">
|
||||
{detailData.poster && (
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{detailData.title}
|
||||
</h3>
|
||||
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{detailData.originalTitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 评分 */}
|
||||
{detailData.rating && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Star
|
||||
size={20}
|
||||
className="text-yellow-500 fill-yellow-500"
|
||||
/>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{detailData.rating.value.toFixed(1)}
|
||||
</span>
|
||||
{detailData.rating.count > 0 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({detailData.rating.count} 评价)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型标签 */}
|
||||
{detailData.genres && detailData.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{detailData.genres.map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 年份和时长 */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{detailData.year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={16} />
|
||||
<span>{detailData.year}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.duration && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={16} />
|
||||
<span>{detailData.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.episodesCount && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Film size={16} />
|
||||
<span>{detailData.episodesCount} 集</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 简介 */}
|
||||
{(detailData.intro || detailData.overview) && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
简介
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
{detailData.intro || detailData.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导演和演员 */}
|
||||
{detailData.directors && detailData.directors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
导演
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.directors.map((d) => d.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.actors && detailData.actors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
演员
|
||||
</h4>
|
||||
{currentSource === 'tmdb' ? (
|
||||
<div
|
||||
ref={actorsScrollRef}
|
||||
onMouseDown={handleActorsMouseDown}
|
||||
onMouseMove={handleActorsMouseMove}
|
||||
onMouseUp={handleActorsMouseUp}
|
||||
onMouseLeave={handleActorsMouseLeave}
|
||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollBehavior: isActorsDragging ? 'auto' : 'smooth'
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 pb-2">
|
||||
{detailData.actors.map((actor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center flex-shrink-0"
|
||||
style={{ pointerEvents: isActorsDragging ? 'none' : 'auto' }}
|
||||
>
|
||||
{actor.profile_path ? (
|
||||
<div
|
||||
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
|
||||
alt={actor.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center">
|
||||
<Users size={28} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={`https://baike.baidu.com/item/${encodeURIComponent(actor.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actor.name}
|
||||
</a>
|
||||
{actor.character && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2">
|
||||
{actor.character}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 制作信息 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{detailData.countries && detailData.countries.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<Globe size={14} />
|
||||
国家/地区
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.countries.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.languages && detailData.languages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<Tag size={14} />
|
||||
语言
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.languages.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.releaseDate && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
上映日期
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.status && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">状态</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 季度和集数信息(仅TMDB电视剧) */}
|
||||
{detailData.mediaType === 'tv' && (
|
||||
<div className="mt-6">
|
||||
{loadingSeasons && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingSeasons && seasonData && (
|
||||
<>
|
||||
{/* 季度列表 */}
|
||||
{seasonData.seasons.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
季度
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{seasonData.seasons.map((season: any) => (
|
||||
<div
|
||||
key={season.id}
|
||||
onClick={() => 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 && (
|
||||
<div
|
||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
||||
alt={season.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{season.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{season.episode_count} 集
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数列表 */}
|
||||
{seasonData.episodes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`}
|
||||
</h4>
|
||||
<div
|
||||
ref={episodesScrollRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollBehavior: isDragging ? 'auto' : 'smooth'
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-3 py-2">
|
||||
{seasonData.episodes.map((episode: Episode) => {
|
||||
const isExpanded = expandedEpisodes.has(episode.id);
|
||||
const isCurrentEpisode = currentEpisode === episode.episode_number;
|
||||
return (
|
||||
<div
|
||||
key={episode.id}
|
||||
id={`episode-${episode.episode_number}`}
|
||||
className={`flex-shrink-0 w-64 p-3 rounded ${
|
||||
isCurrentEpisode
|
||||
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
|
||||
: 'bg-gray-50 dark:bg-gray-800'
|
||||
}`}
|
||||
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
||||
>
|
||||
{episode.still_path && (
|
||||
<div
|
||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
||||
alt={episode.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
第{episode.episode_number}集: {episode.name}
|
||||
</p>
|
||||
{episode.overview && (
|
||||
<p
|
||||
onClick={() => {
|
||||
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}
|
||||
</p>
|
||||
)}
|
||||
{episode.air_date && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{episode.air_date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据源显示和切换 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">数据来源:</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
|
||||
{currentSource === 'douban' && 'Douban'}
|
||||
{currentSource === 'bangumi' && 'Bangumi'}
|
||||
{currentSource === 'cms' && 'CMS'}
|
||||
{currentSource === 'tmdb' && 'TMDB'}
|
||||
</span>
|
||||
</div>
|
||||
{currentSource !== 'tmdb' && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换到 TMDB
|
||||
</button>
|
||||
)}
|
||||
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
|
||||
<button
|
||||
onClick={handleToggleSource}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
切换回 {originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
imageUrl={selectedImage}
|
||||
alt={detailData?.title || title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
@@ -874,7 +1351,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 详情面板 */}
|
||||
{/* 详情面板 - 居中模式 */}
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out"
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user