详情面板增加演员
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
/* 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 { getTMDBCredits } from '@/lib/tmdb.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tmdb/credits?id=xxx&type=movie|tv
|
||||||
|
* 获取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';
|
||||||
|
|
||||||
|
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 getTMDBCredits(
|
||||||
|
tmdbApiKey,
|
||||||
|
parseInt(id),
|
||||||
|
type as 'movie' | 'tv',
|
||||||
|
tmdbProxy,
|
||||||
|
tmdbReverseProxy
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.code !== 200 || !response.credits) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'TMDB 演职人员信息获取失败', code: response.code },
|
||||||
|
{ status: response.code }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response.credits);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TMDB演职人员信息获取失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取演职人员信息失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,8 +42,8 @@ interface DetailData {
|
|||||||
};
|
};
|
||||||
intro?: string;
|
intro?: string;
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
directors?: Array<{ name: string }>;
|
directors?: Array<{ name: string; profile_path?: string }>;
|
||||||
actors?: Array<{ name: string }>;
|
actors?: Array<{ name: string; character?: string; profile_path?: string }>;
|
||||||
countries?: string[];
|
countries?: string[];
|
||||||
languages?: string[];
|
languages?: string[];
|
||||||
duration?: string;
|
duration?: string;
|
||||||
@@ -108,6 +108,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const [startX, setStartX] = useState(0);
|
const [startX, setStartX] = useState(0);
|
||||||
const [scrollLeft, setScrollLeft] = useState(0);
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
const episodesScrollRef = React.useRef<HTMLDivElement>(null);
|
const episodesScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const actorsScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [isActorsDragging, setIsActorsDragging] = useState(false);
|
||||||
|
const [isActorsMouseDown, setIsActorsMouseDown] = useState(false);
|
||||||
|
const [actorsStartX, setActorsStartX] = useState(0);
|
||||||
|
const [actorsScrollLeft, setActorsScrollLeft] = useState(0);
|
||||||
|
|
||||||
// 图片点击处理
|
// 图片点击处理
|
||||||
const handleImageClick = (imageUrl: string) => {
|
const handleImageClick = (imageUrl: string) => {
|
||||||
@@ -646,6 +651,51 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
fetchSeasonData();
|
fetchSeasonData();
|
||||||
}, [detailData?.tmdbId, detailData?.mediaType, detailData?.seasonNumber, seasonsLoaded]);
|
}, [detailData?.tmdbId, detailData?.mediaType, detailData?.seasonNumber, seasonsLoaded]);
|
||||||
|
|
||||||
|
// 异步获取演职人员信息(仅TMDB)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailData?.tmdbId || !detailData?.mediaType || currentSource !== 'tmdb') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经有演员信息,不重复获取
|
||||||
|
if (detailData.actors && detailData.actors.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCredits = async () => {
|
||||||
|
try {
|
||||||
|
const creditsResponse = await fetch(
|
||||||
|
`/api/tmdb/credits?id=${detailData.tmdbId}&type=${detailData.mediaType}`
|
||||||
|
);
|
||||||
|
if (!creditsResponse.ok) return;
|
||||||
|
const creditsData = await creditsResponse.json();
|
||||||
|
|
||||||
|
// 更新演员和导演信息
|
||||||
|
setDetailData(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
directors: creditsData.crew
|
||||||
|
?.filter((person: any) => person.job === 'Director')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((person: any) => ({
|
||||||
|
name: person.name,
|
||||||
|
profile_path: person.profile_path,
|
||||||
|
})) || prev.directors,
|
||||||
|
actors: creditsData.cast
|
||||||
|
?.slice(0, 15)
|
||||||
|
.map((person: any) => ({
|
||||||
|
name: person.name,
|
||||||
|
character: person.character,
|
||||||
|
profile_path: person.profile_path,
|
||||||
|
})) || prev.actors,
|
||||||
|
} : null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取演职人员信息失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCredits();
|
||||||
|
}, [detailData?.tmdbId, detailData?.mediaType, currentSource, detailData?.actors]);
|
||||||
|
|
||||||
// 切换季度时获取集数
|
// 切换季度时获取集数
|
||||||
const handleSeasonChange = async (seasonNumber: number) => {
|
const handleSeasonChange = async (seasonNumber: number) => {
|
||||||
if (!detailData?.tmdbId || selectedSeason === seasonNumber) return;
|
if (!detailData?.tmdbId || selectedSeason === seasonNumber) return;
|
||||||
@@ -734,6 +784,54 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 演员列表拖动滚动处理函数
|
||||||
|
const handleActorsMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!actorsScrollRef.current) return;
|
||||||
|
setIsActorsMouseDown(true);
|
||||||
|
setActorsStartX(e.pageX - actorsScrollRef.current.offsetLeft);
|
||||||
|
setActorsScrollLeft(actorsScrollRef.current.scrollLeft);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActorsMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isActorsMouseDown || !actorsScrollRef.current) return;
|
||||||
|
|
||||||
|
const x = e.pageX - actorsScrollRef.current.offsetLeft;
|
||||||
|
const distance = Math.abs(x - actorsStartX);
|
||||||
|
|
||||||
|
// 只有移动超过5px才进入拖动模式
|
||||||
|
if (distance > 5 && !isActorsDragging) {
|
||||||
|
setIsActorsDragging(true);
|
||||||
|
actorsScrollRef.current.style.cursor = 'grabbing';
|
||||||
|
actorsScrollRef.current.style.userSelect = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActorsDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
const walk = (x - actorsStartX) * 2; // 滚动速度倍数
|
||||||
|
actorsScrollRef.current.scrollLeft = actorsScrollLeft - walk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActorsMouseUp = () => {
|
||||||
|
setIsActorsMouseDown(false);
|
||||||
|
setIsActorsDragging(false);
|
||||||
|
if (actorsScrollRef.current) {
|
||||||
|
actorsScrollRef.current.style.cursor = 'grab';
|
||||||
|
actorsScrollRef.current.style.userSelect = 'auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActorsMouseLeave = () => {
|
||||||
|
if (isActorsMouseDown || isActorsDragging) {
|
||||||
|
setIsActorsMouseDown(false);
|
||||||
|
setIsActorsDragging(false);
|
||||||
|
if (actorsScrollRef.current) {
|
||||||
|
actorsScrollRef.current.style.cursor = 'grab';
|
||||||
|
actorsScrollRef.current.style.userSelect = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isVisible || !mounted) return null;
|
if (!isVisible || !mounted) return null;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
@@ -929,9 +1027,67 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
演员
|
演员
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
{currentSource === 'tmdb' ? (
|
||||||
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
|
<div
|
||||||
</p>
|
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, 'w185')))}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={processImageUrl(getTMDBImageUrl(actor.profile_path, '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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -659,3 +659,47 @@ export async function getTMDBTVDetails(
|
|||||||
return { code: 500, details: null };
|
return { code: 500, details: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 TMDB 演职人员信息
|
||||||
|
* @param apiKey - TMDB API Key
|
||||||
|
* @param mediaId - 媒体ID
|
||||||
|
* @param mediaType - 媒体类型 (movie 或 tv)
|
||||||
|
* @param proxy - 代理服务器地址
|
||||||
|
* @param reverseProxyBaseUrl - 反代 Base URL
|
||||||
|
* @returns 演职人员信息
|
||||||
|
*/
|
||||||
|
export async function getTMDBCredits(
|
||||||
|
apiKey: string,
|
||||||
|
mediaId: number,
|
||||||
|
mediaType: 'movie' | 'tv',
|
||||||
|
proxy?: string,
|
||||||
|
reverseProxyBaseUrl?: string
|
||||||
|
): Promise<{ code: number; credits: any }> {
|
||||||
|
try {
|
||||||
|
const actualKey = getNextApiKey(apiKey);
|
||||||
|
if (!actualKey) {
|
||||||
|
return { code: 400, credits: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL;
|
||||||
|
const url = `${baseUrl}/3/${mediaType}/${mediaId}/credits?api_key=${actualKey}&language=zh-CN`;
|
||||||
|
|
||||||
|
const response = await universalFetch(url, proxy);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('TMDB Credits API 请求失败:', response.status, response.statusText);
|
||||||
|
return { code: response.status, credits: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
credits: data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 TMDB 演职人员信息失败:', error);
|
||||||
|
return { code: 500, credits: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user