From deb887c6e1147af2d93adf62e2bc152c67da2230 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 6 Feb 2026 10:34:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=A6=E6=83=85=E7=82=B9=E5=87=BB=E5=8F=AF?= =?UTF-8?q?=E5=A4=A7=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DetailPanel.tsx | 38 +++++- src/components/ImageViewer.tsx | 175 +++++++++++++++++++++++++++ src/components/MobileActionSheet.tsx | 10 +- src/components/VideoCard.tsx | 22 +++- 4 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 src/components/ImageViewer.tsx diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index f752413..1270885 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -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 = ({ 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 [isDragging, setIsDragging] = useState(false); @@ -99,6 +103,12 @@ const DetailPanel: React.FC = ({ const [scrollLeft, setScrollLeft] = useState(0); const episodesScrollRef = React.useRef(null); + // 图片点击处理 + const handleImageClick = (imageUrl: string) => { + setSelectedImage(imageUrl); + setShowImageViewer(true); + }; + // 确保组件在客户端挂载后才渲染 Portal useEffect(() => { setMounted(true); @@ -608,7 +618,10 @@ const DetailPanel: React.FC = ({ {/* 海报和基本信息 */}
{detailData.poster && ( -
+
handleImageClick(detailData.poster!)} + > {detailData.title}
)} @@ -788,7 +801,13 @@ const DetailPanel: React.FC = ({ }`} > {season.poster_path && ( -
+
{ + e.stopPropagation(); + handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500'))); + }} + > {season.name} = ({ style={{ pointerEvents: isDragging ? 'none' : 'auto' }} > {episode.still_path && ( -
+
handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))} + > {episode.name} = ({ )}
+ + {/* 图片查看器 */} + {showImageViewer && ( + setShowImageViewer(false)} + imageUrl={selectedImage} + alt={detailData?.title || title} + /> + )}
); diff --git a/src/components/ImageViewer.tsx b/src/components/ImageViewer.tsx new file mode 100644 index 0000000..4eda739 --- /dev/null +++ b/src/components/ImageViewer.tsx @@ -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 = ({ + 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 = ( +
+ {/* 背景遮罩 */} +
+ + {/* 关闭按钮 */} + + + {/* 图片容器 */} +
e.stopPropagation()} + > +
+ {alt} +
+
+
+ ); + + return createPortal(content, document.body); +}; + +export default ImageViewer; diff --git a/src/components/MobileActionSheet.tsx b/src/components/MobileActionSheet.tsx index 3d3791d..6370dc2 100644 --- a/src/components/MobileActionSheet.tsx +++ b/src/components/MobileActionSheet.tsx @@ -24,6 +24,7 @@ interface MobileActionSheetProps { currentEpisode?: number; // 当前集数 totalEpisodes?: number; // 总集数 origin?: 'vod' | 'live'; + onPosterClick?: () => void; // 海报点击回调 } const MobileActionSheet: React.FC = ({ @@ -38,6 +39,7 @@ const MobileActionSheet: React.FC = ({ currentEpisode, totalEpisodes, origin = 'vod', + onPosterClick, }) => { const [isVisible, setIsVisible] = useState(false); const [isAnimating, setIsAnimating] = useState(false); @@ -221,7 +223,13 @@ const MobileActionSheet: React.FC = ({
{poster && ( -
+
{ + e.stopPropagation(); + onPosterClick?.(); + }} + > {title}(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(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(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(function VideoCard currentEpisode={currentEpisode} totalEpisodes={actualEpisodes} origin={origin} + onPosterClick={() => { + setShowImageViewer(true); + }} /> {/* AI问片面板 */} @@ -1515,6 +1525,16 @@ const VideoCard = forwardRef(function VideoCard source={source} /> )} + + {/* 图片查看器 */} + {showImageViewer && ( + setShowImageViewer(false)} + imageUrl={processImageUrl(actualPoster)} + alt={actualTitle} + /> + )} ); }