新增照片墙

This commit is contained in:
mtvpls
2026-04-06 11:02:57 +08:00
parent 23b8115f36
commit 4bed01e7a3
3 changed files with 459 additions and 49 deletions
+105
View File
@@ -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 }
);
}
}
+310 -49
View File
@@ -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<DetailPanelProps> = ({
isOpen,
onClose,
@@ -100,6 +110,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState<string>('');
const [showGallery, setShowGallery] = useState(false);
const [galleryLoading, setGalleryLoading] = useState(false);
const [galleryError, setGalleryError] = useState<string | null>(null);
const [galleryImages, setGalleryImages] = useState<GalleryImage[]>([]);
const [galleryTotal, setGalleryTotal] = useState(0);
const [galleryScrollTop, setGalleryScrollTop] = useState(0);
const [galleryViewportHeight, setGalleryViewportHeight] = useState(0);
const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
const galleryScrollRef = React.useRef<HTMLDivElement>(null);
// 数据源状态管理
@@ -151,11 +170,82 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
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<DetailPanelProps> = ({
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setShowGallery(false);
}
}, [isOpen]);
// 阻止背景滚动(仅在非抽屉模式下)
useEffect(() => {
if (isVisible && !useDrawer) {
@@ -887,6 +983,157 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}
};
const galleryEntryButton = canShowGalleryEntry ? (
<button
onClick={openGallery}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors"
>
<Images size={16} />
</button>
) : 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 galleryModal = showGallery ? (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setShowGallery(false)}
/>
<div className="relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h3>
{!galleryLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{galleryTotal}
</p>
)}
</div>
<button
onClick={() => setShowGallery(false)}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="关闭照片墙"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
<div ref={galleryScrollRef} className="flex-1 overflow-y-auto overflow-x-hidden p-4">
{galleryLoading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
</div>
)}
{!galleryLoading && galleryError && (
<div className="text-center py-12 text-red-500 dark:text-red-400">{galleryError}</div>
)}
{!galleryLoading && !galleryError && galleryImages.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400"></div>
)}
{!galleryLoading && !galleryError && galleryImages.length > 0 && (
<div
className="relative mx-auto"
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
>
{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 (
<div
key={`${image.imageType}-${image.file_path}-${image.index}`}
className="group absolute"
style={{
top: image.top,
left: image.left,
width: image.renderWidth,
height: image.renderHeight,
}}
>
<div
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(imageUrl)}
>
<Image
src={thumbUrl}
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
className="object-cover"
draggable={false}
/>
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
{image.imageType === 'poster' ? '海报' : '剧照'}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
) : null;
if (!isVisible || !mounted) return null;
const content = useDrawer ? (
@@ -938,7 +1185,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 数据源显示和切换 - 错误时也显示 */}
<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 justify-between gap-3 flex-wrap">
<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">
@@ -948,24 +1195,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{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 className="flex items-center gap-2 flex-wrap">
{galleryEntryButton}
{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>
@@ -976,11 +1226,14 @@ 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 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 className="flex flex-col items-start gap-3 flex-shrink-0">
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 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>
{galleryEntryButton}
</div>
)}
<div className="flex-1 min-w-0">
@@ -1332,7 +1585,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 数据源显示和切换 */}
<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 justify-between gap-3 flex-wrap">
<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">
@@ -1342,24 +1595,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{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 className="flex items-center gap-2 flex-wrap">
{galleryEntryButton}
{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>
@@ -1368,6 +1624,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
</div>
{/* 图片查看器 */}
{galleryModal}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
@@ -1480,11 +1737,14 @@ 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 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 className="flex flex-col items-start gap-3 flex-shrink-0">
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 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>
{galleryEntryButton}
</div>
)}
<div className="flex-1 min-w-0">
@@ -1872,6 +2132,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
</div>
{/* 图片查看器 */}
{galleryModal}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
+44
View File
@@ -703,3 +703,47 @@ export async function getTMDBCredits(
return { code: 500, credits: null };
}
}
/**
* 获取 TMDB 图片信息
* @param apiKey - TMDB API Key
* @param mediaId - 媒体ID
* @param mediaType - 媒体类型 (movie 或 tv)
* @param proxy - 代理服务器地址
* @param reverseProxyBaseUrl - 反代 Base URL
* @returns 图片信息
*/
export async function getTMDBImages(
apiKey: string,
mediaId: number,
mediaType: 'movie' | 'tv',
proxy?: string,
reverseProxyBaseUrl?: string
): Promise<{ code: number; images: any }> {
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 };
}
}