详情面板增加演员
This commit is contained in:
69
src/app/api/tmdb/credits/route.ts
Normal file
69
src/app/api/tmdb/credits/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user