详情面板增加演员

This commit is contained in:
mtvpls
2026-02-17 01:22:15 +08:00
parent 29a14bf8fa
commit bdb43450a6
3 changed files with 274 additions and 5 deletions

View File

@@ -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 }
);
}
}

View File

@@ -42,8 +42,8 @@ interface DetailData {
};
intro?: string;
genres?: string[];
directors?: Array<{ name: string }>;
actors?: Array<{ name: string }>;
directors?: Array<{ name: string; profile_path?: string }>;
actors?: Array<{ name: string; character?: string; profile_path?: string }>;
countries?: string[];
languages?: string[];
duration?: string;
@@ -108,6 +108,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
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) => {
@@ -646,6 +651,51 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
fetchSeasonData();
}, [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) => {
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;
const content = (
@@ -929,9 +1027,67 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
<Users size={16} />
</h4>
<p className="text-gray-700 dark:text-gray-300">
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
</p>
{currentSource === 'tmdb' ? (
<div
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>
)}

View File

@@ -659,3 +659,47 @@ export async function getTMDBTVDetails(
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 };
}
}