diff --git a/src/app/api/tmdb/images/route.ts b/src/app/api/tmdb/images/route.ts new file mode 100644 index 0000000..e83120a --- /dev/null +++ b/src/app/api/tmdb/images/route.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { getTMDBImages } from '@/lib/tmdb.client'; + +export const runtime = 'nodejs'; + +/** + * GET /api/tmdb/images?id=xxx&type=movie|tv&page=1&pageSize=24 + * 获取 TMDB 照片墙数据,并在服务端分页 + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const type = searchParams.get('type') || 'movie'; + const pageParam = searchParams.get('page'); + const pageSizeParam = searchParams.get('pageSize'); + const page = pageParam ? Math.max(parseInt(pageParam, 10), 1) : null; + const pageSize = pageSizeParam ? Math.min(Math.max(parseInt(pageSizeParam, 10), 1), 60) : null; + + if (!id) { + return NextResponse.json({ error: '缺少ID参数' }, { status: 400 }); + } + + if (type !== 'movie' && type !== 'tv') { + return NextResponse.json({ error: '类型参数必须是movie或tv' }, { status: 400 }); + } + + const config = await getConfig(); + const tmdbApiKey = config.SiteConfig.TMDBApiKey; + const tmdbProxy = config.SiteConfig.TMDBProxy; + const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy; + + if (!tmdbApiKey) { + return NextResponse.json({ error: 'TMDB API Key 未配置' }, { status: 400 }); + } + + const response = await getTMDBImages( + tmdbApiKey, + parseInt(id, 10), + type as 'movie' | 'tv', + tmdbProxy, + tmdbReverseProxy + ); + + if (response.code !== 200 || !response.images) { + return NextResponse.json( + { error: 'TMDB 图片信息获取失败', code: response.code }, + { status: response.code } + ); + } + + const backdrops = (response.images.backdrops || []).map((item: any) => ({ + ...item, + imageType: 'backdrop' as const, + })); + const posters = (response.images.posters || []).map((item: any) => ({ + ...item, + imageType: 'poster' as const, + })); + + const allImages = [...backdrops, ...posters].sort((a, b) => { + const voteDiff = (b.vote_average || 0) - (a.vote_average || 0); + if (voteDiff !== 0) return voteDiff; + return (b.vote_count || 0) - (a.vote_count || 0); + }); + + const total = allImages.length; + + if (!page || !pageSize) { + return NextResponse.json({ + total, + list: allImages, + }); + } + + const totalPages = Math.max(Math.ceil(total / pageSize), 1); + const safePage = Math.min(page, totalPages); + const start = (safePage - 1) * pageSize; + const list = allImages.slice(start, start + pageSize); + + return NextResponse.json({ + page: safePage, + pageSize, + total, + totalPages, + list, + }); + } catch (error) { + console.error('TMDB图片信息获取失败:', error); + return NextResponse.json( + { error: '获取图片信息失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index 40cffb4..9c8cbe8 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Calendar, Clock, ExternalLink, Film,Globe, Star, Tag, Users, X } from 'lucide-react'; +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'; @@ -69,6 +69,16 @@ interface Episode { 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, @@ -100,6 +110,15 @@ const DetailPanel: React.FC = ({ 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); // 数据源状态管理 @@ -151,11 +170,82 @@ const DetailPanel: React.FC = ({ 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; @@ -185,6 +275,12 @@ const DetailPanel: React.FC = ({ }; }, [isOpen]); + useEffect(() => { + if (!isOpen) { + setShowGallery(false); + } + }, [isOpen]); + // 阻止背景滚动(仅在非抽屉模式下) useEffect(() => { if (isVisible && !useDrawer) { @@ -887,6 +983,157 @@ const DetailPanel: React.FC = ({ } }; + const galleryEntryButton = canShowGalleryEntry ? ( + + ) : null; + + const virtualGalleryLayout = React.useMemo(() => { + if (galleryImages.length === 0 || galleryViewportWidth <= 0) { + return { + visibleItems: [] as Array, + 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 galleryModal = showGallery ? ( +
+
setShowGallery(false)} + /> +
+
+
+

照片墙

+ {!galleryLoading && ( +

+ 共 {galleryTotal} 张 +

+ )} +
+ +
+ +
+ {galleryLoading && ( +
+
+
+ )} + + {!galleryLoading && galleryError && ( +
{galleryError}
+ )} + + {!galleryLoading && !galleryError && galleryImages.length === 0 && ( +
暂无图片
+ )} + + {!galleryLoading && !galleryError && galleryImages.length > 0 && ( +
+ {virtualGalleryLayout.visibleItems.map((image) => { + const imageUrl = processImageUrl( + getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w500' : 'original') + ); + const thumbUrl = processImageUrl( + getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w342' : 'w780') + ); + + return ( +
+
handleImageClick(imageUrl)} + > + {`${detailData?.title +
+ {image.imageType === 'poster' ? '海报' : '剧照'} +
+
+
+ ); + })} +
+ )} +
+
+
+ ) : null; + if (!isVisible || !mounted) return null; const content = useDrawer ? ( @@ -938,7 +1185,7 @@ const DetailPanel: React.FC = ({ {/* 数据源显示和切换 - 错误时也显示 */}
-
+
数据来源: @@ -948,24 +1195,27 @@ const DetailPanel: React.FC = ({ {currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && ( - - )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} +
+ {galleryEntryButton} + {currentSource !== 'tmdb' && ( + + )} + {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( + + )} +
@@ -976,11 +1226,14 @@ const DetailPanel: React.FC = ({ {/* 海报和基本信息 */}
{detailData.poster && ( -
handleImageClick(detailData.poster!)} - > - {detailData.title} +
+
handleImageClick(detailData.poster!)} + > + {detailData.title} +
+ {galleryEntryButton}
)}
@@ -1332,7 +1585,7 @@ const DetailPanel: React.FC = ({ {/* 数据源显示和切换 */}
-
+
数据来源: @@ -1342,24 +1595,27 @@ const DetailPanel: React.FC = ({ {currentSource === 'tmdb' && 'TMDB'}
- {currentSource !== 'tmdb' && ( - - )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} +
+ {galleryEntryButton} + {currentSource !== 'tmdb' && ( + + )} + {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( + + )} +
@@ -1368,6 +1624,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */} + {galleryModal} {showImageViewer && ( = ({ {/* 海报和基本信息 */}
{detailData.poster && ( -
handleImageClick(detailData.poster!)} - > - {detailData.title} +
+
handleImageClick(detailData.poster!)} + > + {detailData.title} +
+ {galleryEntryButton}
)}
@@ -1872,6 +2132,7 @@ const DetailPanel: React.FC = ({
{/* 图片查看器 */} + {galleryModal} {showImageViewer && ( { + try { + const actualKey = getNextApiKey(apiKey); + if (!actualKey) { + return { code: 400, images: null }; + } + + const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL; + const url = `${baseUrl}/3/${mediaType}/${mediaId}/images?api_key=${actualKey}`; + + const response = await universalFetch(url, proxy); + + if (!response.ok) { + console.error('TMDB Images API 请求失败:', response.status, response.statusText); + return { code: response.status, images: null }; + } + + const data: any = await response.json(); + + return { + code: 200, + images: data, + }; + } catch (error) { + console.error('获取 TMDB 图片信息失败:', error); + return { code: 500, images: null }; + } +}