详情点击可大图

This commit is contained in:
mtvpls
2026-02-06 10:34:14 +08:00
parent 161243b0a7
commit deb887c6e1
4 changed files with 240 additions and 5 deletions

View File

@@ -8,6 +8,8 @@ import { createPortal } from 'react-dom';
import { getTMDBImageUrl } from '@/lib/tmdb.client';
import { processImageUrl } from '@/lib/utils';
import ImageViewer from '@/components/ImageViewer';
interface DetailPanelProps {
isOpen: boolean;
onClose: () => void;
@@ -91,6 +93,8 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(new Set());
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState<string>('');
// 拖动滚动状态
const [isDragging, setIsDragging] = useState(false);
@@ -99,6 +103,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [scrollLeft, setScrollLeft] = useState(0);
const episodesScrollRef = React.useRef<HTMLDivElement>(null);
// 图片点击处理
const handleImageClick = (imageUrl: string) => {
setSelectedImage(imageUrl);
setShowImageViewer(true);
};
// 确保组件在客户端挂载后才渲染 Portal
useEffect(() => {
setMounted(true);
@@ -608,7 +618,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 海报和基本信息 */}
<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">
<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>
)}
@@ -788,7 +801,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}`}
>
{season.poster_path && (
<div className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
<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}
@@ -840,7 +859,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
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">
<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}
@@ -889,6 +911,16 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
</div>
</div>
{/* 图片查看器 */}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
onClose={() => setShowImageViewer(false)}
imageUrl={selectedImage}
alt={detailData?.title || title}
/>
)}
</div>
);

View File

@@ -0,0 +1,175 @@
'use client';
import { X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface ImageViewerProps {
isOpen: boolean;
onClose: () => void;
imageUrl: string;
alt?: string;
}
const ImageViewer: React.FC<ImageViewerProps> = ({
isOpen,
onClose,
imageUrl,
alt = '图片',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [mounted, setMounted] = useState(false);
// 确保组件在客户端挂载后才渲染 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.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(() => {
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]);
if (!isVisible || !mounted) return null;
const content = (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-0 sm:p-4">
{/* 背景遮罩 */}
<div
className={`absolute inset-0 bg-black/80 transition-opacity duration-200 ease-out ${
isAnimating ? 'opacity-100' : 'opacity-0'
}`}
onClick={onClose}
style={{
backdropFilter: 'blur(4px)',
willChange: 'opacity',
}}
/>
{/* 关闭按钮 */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors duration-150"
aria-label="关闭"
>
<X size={24} className="text-white" />
</button>
{/* 图片容器 */}
<div
className="relative max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] transition-all duration-200 ease-out"
style={{
willChange: 'transform, opacity',
backfaceVisibility: 'hidden',
transform: isAnimating ? 'scale(1) translateZ(0)' : 'scale(0.95) translateZ(0)',
opacity: isAnimating ? 1 : 0,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full h-full">
<Image
src={imageUrl}
alt={alt}
width={1200}
height={1800}
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
style={{
maxWidth: '100vw',
maxHeight: '100vh',
}}
priority
quality={100}
/>
</div>
</div>
</div>
);
return createPortal(content, document.body);
};
export default ImageViewer;

View File

@@ -24,6 +24,7 @@ interface MobileActionSheetProps {
currentEpisode?: number; // 当前集数
totalEpisodes?: number; // 总集数
origin?: 'vod' | 'live';
onPosterClick?: () => void; // 海报点击回调
}
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
@@ -38,6 +39,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
currentEpisode,
totalEpisodes,
origin = 'vod',
onPosterClick,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
@@ -221,7 +223,13 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3 flex-1 min-w-0">
{poster && (
<div className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0">
<div
className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onPosterClick?.();
}}
>
<Image
src={poster}
alt={title}

View File

@@ -27,6 +27,7 @@ import { useLongPress } from '@/hooks/useLongPress';
import AIChatPanel from '@/components/AIChatPanel';
import DetailPanel from '@/components/DetailPanel';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
import ImageViewer from '@/components/ImageViewer';
import MobileActionSheet from '@/components/MobileActionSheet';
export interface VideoCardProps {
@@ -112,6 +113,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
const [aiEnabled, setAiEnabled] = useState(false);
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
const [showDetailPanel, setShowDetailPanel] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
// 检查AI功能是否启用
useEffect(() => {
@@ -734,6 +736,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
referrerPolicy='no-referrer'
loading='lazy'
onLoadingComplete={() => setIsLoading(true)}
onClick={(e) => {
e.stopPropagation();
setShowImageViewer(true);
}}
onError={(e) => {
// 图片加载失败时的重试机制
const img = e.target as HTMLImageElement;
@@ -749,7 +755,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
pointerEvents: 'none', // 图片不响应任何指针事件
pointerEvents: 'auto', // 改为auto以响应点击事件
cursor: 'pointer', // 添加指针样式
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
@@ -1478,6 +1485,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
currentEpisode={currentEpisode}
totalEpisodes={actualEpisodes}
origin={origin}
onPosterClick={() => {
setShowImageViewer(true);
}}
/>
{/* AI问片面板 */}
@@ -1515,6 +1525,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
source={source}
/>
)}
{/* 图片查看器 */}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
onClose={() => setShowImageViewer(false)}
imageUrl={processImageUrl(actualPoster)}
alt={actualTitle}
/>
)}
</>
);
}