视频右键增加详情
This commit is contained in:
80
src/app/api/tmdb/detail/route.ts
Normal file
80
src/app/api/tmdb/detail/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/* 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 { getNextApiKey } from '@/lib/tmdb.client';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import nodeFetch from 'node-fetch';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/tmdb/detail?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 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||
|
||||
const actualKey = getNextApiKey(tmdbApiKey || '');
|
||||
if (!actualKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TMDB API Key 未配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 根据类型选择API端点
|
||||
const endpoint = type === 'movie' ? 'movie' : 'tv';
|
||||
const url = `https://api.themoviedb.org/3/${endpoint}/${id}?api_key=${actualKey}&language=zh-CN&append_to_response=credits`;
|
||||
|
||||
const fetchOptions: any = tmdbProxy
|
||||
? {
|
||||
agent: new HttpsProxyAgent(tmdbProxy, {
|
||||
timeout: 30000,
|
||||
keepAlive: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
const response = await nodeFetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB 详情获取失败:', response.status, response.statusText);
|
||||
return NextResponse.json(
|
||||
{ error: 'TMDB 详情获取失败', code: response.status },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('TMDB详情获取失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取详情失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -240,11 +240,21 @@ function HomeClient() {
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
id={duanju.id}
|
||||
source={duanju.source}
|
||||
poster={duanju.poster}
|
||||
title={duanju.title}
|
||||
year={duanju.year}
|
||||
type='tv'
|
||||
from='douban'
|
||||
from='search'
|
||||
source_name={duanju.source_name}
|
||||
episodes={duanju.episodes?.length}
|
||||
douban_id={duanju.douban_id}
|
||||
cmsData={{
|
||||
desc: duanju.desc,
|
||||
episodes: duanju.episodes,
|
||||
episodes_titles: duanju.episodes_titles,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
613
src/components/DetailPanel.tsx
Normal file
613
src/components/DetailPanel.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
'use client';
|
||||
|
||||
import { X, Calendar, Star, Clock, Tag, Users, Globe, Film } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DetailPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
poster?: string;
|
||||
doubanId?: number;
|
||||
bangumiId?: number;
|
||||
isBangumi?: boolean;
|
||||
tmdbId?: number;
|
||||
type?: 'movie' | 'tv';
|
||||
seasonNumber?: number;
|
||||
cmsData?: {
|
||||
desc?: string;
|
||||
episodes?: string[];
|
||||
episodes_titles?: string[];
|
||||
};
|
||||
// 用于调用 source-detail API
|
||||
sourceId?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface DetailData {
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
year?: string;
|
||||
poster?: string;
|
||||
rating?: {
|
||||
value: number;
|
||||
count: number;
|
||||
};
|
||||
intro?: string;
|
||||
genres?: string[];
|
||||
directors?: Array<{ name: string }>;
|
||||
actors?: Array<{ name: string }>;
|
||||
countries?: string[];
|
||||
languages?: string[];
|
||||
duration?: string;
|
||||
episodesCount?: number;
|
||||
releaseDate?: string;
|
||||
status?: string;
|
||||
tagline?: string;
|
||||
seasons?: number;
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
poster,
|
||||
doubanId,
|
||||
bangumiId,
|
||||
isBangumi,
|
||||
tmdbId,
|
||||
type = 'movie',
|
||||
seasonNumber,
|
||||
cmsData,
|
||||
sourceId,
|
||||
source,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [detailData, setDetailData] = useState<DetailData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 确保组件在客户端挂载后才渲染 Portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 控制动画状态
|
||||
useEffect(() => {
|
||||
let animationId: number;
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
animationId = requestAnimationFrame(() => {
|
||||
animationId = requestAnimationFrame(() => {
|
||||
setIsAnimating(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 阻止背景滚动
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
// 保存当前滚动位置
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
// 获取滚动条宽度
|
||||
const scrollBarWidth = window.innerWidth - html.clientWidth;
|
||||
|
||||
// 保存原始样式
|
||||
const originalBodyStyle = {
|
||||
position: body.style.position,
|
||||
top: body.style.top,
|
||||
left: body.style.left,
|
||||
right: body.style.right,
|
||||
width: body.style.width,
|
||||
paddingRight: body.style.paddingRight,
|
||||
overflow: body.style.overflow,
|
||||
};
|
||||
|
||||
// 设置body样式来阻止滚动,但保持原位置
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}px`;
|
||||
body.style.left = `-${scrollX}px`;
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
body.style.overflow = 'hidden';
|
||||
body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
|
||||
return () => {
|
||||
// 恢复所有原始样式
|
||||
body.style.position = originalBodyStyle.position;
|
||||
body.style.top = originalBodyStyle.top;
|
||||
body.style.left = originalBodyStyle.left;
|
||||
body.style.right = originalBodyStyle.right;
|
||||
body.style.width = originalBodyStyle.width;
|
||||
body.style.paddingRight = originalBodyStyle.paddingRight;
|
||||
body.style.overflow = originalBodyStyle.overflow;
|
||||
|
||||
// 使用 requestAnimationFrame 确保样式恢复后再滚动
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// ESC键关闭
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
// 获取详情数据
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 优先使用苹果CMS数据(短剧等)
|
||||
// 如果 cmsData 存在但 desc 为空,尝试通过 source-detail API 获取
|
||||
if (cmsData) {
|
||||
if (cmsData.desc) {
|
||||
// 有 desc,直接使用
|
||||
setDetailData({
|
||||
title: title,
|
||||
intro: cmsData.desc,
|
||||
episodesCount: cmsData.episodes?.length,
|
||||
poster: poster,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// cmsData 存在但 desc 为空,尝试通过 API 获取详情
|
||||
if (sourceId && source) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/source-detail?id=${encodeURIComponent(sourceId)}&source=${encodeURIComponent(source)}&title=${encodeURIComponent(title)}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDetailData({
|
||||
title: data.title || title,
|
||||
intro: data.desc || '',
|
||||
episodesCount: data.episodes?.length || cmsData.episodes?.length,
|
||||
poster: data.poster || poster,
|
||||
year: data.year,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取source-detail失败:', err);
|
||||
// 继续执行后续逻辑
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先使用 Bangumi ID(因为 isBangumi 为 true 时,doubanId 实际上是 bangumiId)
|
||||
if (bangumiId || (isBangumi && doubanId)) {
|
||||
const actualBangumiId = bangumiId || doubanId;
|
||||
const response = await fetch(`https://api.bgm.tv/v0/subjects/${actualBangumiId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取Bangumi详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
setDetailData({
|
||||
title: data.name_cn || data.name,
|
||||
originalTitle: data.name,
|
||||
year: data.date ? data.date.substring(0, 4) : undefined,
|
||||
poster: data.images?.large || poster,
|
||||
rating: data.rating
|
||||
? {
|
||||
value: data.rating.score,
|
||||
count: data.rating.total,
|
||||
}
|
||||
: undefined,
|
||||
intro: data.summary,
|
||||
genres: data.tags?.map((tag: any) => tag.name).slice(0, 5),
|
||||
episodesCount: data.eps,
|
||||
releaseDate: data.date,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用豆瓣ID
|
||||
if (doubanId && !isBangumi) {
|
||||
const response = await fetch(`/api/douban/detail?id=${doubanId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取豆瓣详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
setDetailData({
|
||||
title: data.title,
|
||||
originalTitle: data.original_title,
|
||||
year: data.year,
|
||||
poster: data.pic?.large || data.pic?.normal || poster,
|
||||
rating: data.rating
|
||||
? {
|
||||
value: data.rating.value,
|
||||
count: data.rating.count,
|
||||
}
|
||||
: undefined,
|
||||
intro: data.intro,
|
||||
genres: data.genres,
|
||||
directors: data.directors,
|
||||
actors: data.actors,
|
||||
countries: data.countries,
|
||||
languages: data.languages,
|
||||
duration: data.durations?.[0],
|
||||
episodesCount: data.episodes_count,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 TMDB 搜索
|
||||
if (title) {
|
||||
// 移除季度信息进行搜索
|
||||
let searchTitle = title;
|
||||
let extractedSeasonNumber = seasonNumber;
|
||||
|
||||
// 匹配各种季度格式: 第一季、第1季、第一部、Season 1、S1等
|
||||
const seasonPatterns = [
|
||||
/第([一二三四五六七八九十\d]+)[季部]/,
|
||||
/Season\s*(\d+)/i,
|
||||
/S(\d+)/i,
|
||||
];
|
||||
|
||||
for (const pattern of seasonPatterns) {
|
||||
const match = title.match(pattern);
|
||||
if (match) {
|
||||
searchTitle = title.replace(pattern, '').trim();
|
||||
// 如果没有传入seasonNumber,尝试从标题中提取
|
||||
if (!extractedSeasonNumber) {
|
||||
const seasonStr = match[1];
|
||||
// 中文数字转数字
|
||||
const chineseNumbers: Record<string, number> = {
|
||||
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
|
||||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
||||
};
|
||||
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const searchResponse = await fetch(
|
||||
`/api/tmdb/search?query=${encodeURIComponent(searchTitle)}`
|
||||
);
|
||||
if (!searchResponse.ok) {
|
||||
throw new Error('搜索失败');
|
||||
}
|
||||
const searchData = await searchResponse.json();
|
||||
|
||||
if (searchData.results && searchData.results.length > 0) {
|
||||
const result = searchData.results[0];
|
||||
const detailId = result.id;
|
||||
const mediaType = result.media_type || type;
|
||||
|
||||
// 获取详情
|
||||
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error('获取TMDB详情失败');
|
||||
}
|
||||
const detailResult = await detailResponse.json();
|
||||
|
||||
// 如果有季度信息,尝试获取季度详情
|
||||
let seasonData = null;
|
||||
if (extractedSeasonNumber && mediaType === 'tv') {
|
||||
try {
|
||||
const seasonResponse = await fetch(
|
||||
`/api/tmdb/seasons?id=${detailId}&season=${extractedSeasonNumber}`
|
||||
);
|
||||
if (seasonResponse.ok) {
|
||||
seasonData = await seasonResponse.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取季度信息失败', err);
|
||||
}
|
||||
}
|
||||
|
||||
setDetailData({
|
||||
title: mediaType === 'movie' ? detailResult.title : detailResult.name,
|
||||
originalTitle:
|
||||
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
|
||||
year:
|
||||
mediaType === 'movie'
|
||||
? detailResult.release_date?.substring(0, 4)
|
||||
: detailResult.first_air_date?.substring(0, 4),
|
||||
poster: detailResult.poster_path
|
||||
? `https://image.tmdb.org/t/p/w500${detailResult.poster_path}`
|
||||
: poster,
|
||||
rating: detailResult.vote_average
|
||||
? {
|
||||
value: detailResult.vote_average,
|
||||
count: detailResult.vote_count,
|
||||
}
|
||||
: undefined,
|
||||
intro: seasonData?.overview || detailResult.overview,
|
||||
genres: detailResult.genres?.map((g: any) => g.name),
|
||||
countries: detailResult.production_countries?.map((c: any) => c.name),
|
||||
languages: detailResult.spoken_languages?.map((l: any) => l.name),
|
||||
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
|
||||
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
|
||||
releaseDate:
|
||||
mediaType === 'movie' ? detailResult.release_date : detailResult.first_air_date,
|
||||
status: detailResult.status,
|
||||
tagline: detailResult.tagline,
|
||||
seasons: detailResult.number_of_seasons,
|
||||
overview: detailResult.overview,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('未找到相关内容');
|
||||
}
|
||||
|
||||
throw new Error('缺少必要的查询参数');
|
||||
} catch (err) {
|
||||
console.error('获取详情失败:', err);
|
||||
setError(err instanceof Error ? err.message : '获取详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source]);
|
||||
|
||||
if (!isVisible || !mounted) return null;
|
||||
|
||||
const content = (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${
|
||||
isAnimating ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
style={{
|
||||
backdropFilter: 'blur(4px)',
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 详情面板 */}
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out"
|
||||
style={{
|
||||
willChange: 'transform, opacity',
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: isAnimating ? 'scale(1) translateZ(0)' : 'scale(0.95) translateZ(0)',
|
||||
opacity: isAnimating ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">详情</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && detailData && (
|
||||
<div className="p-6">
|
||||
{/* 海报和基本信息 */}
|
||||
<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">
|
||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{detailData.title}
|
||||
</h3>
|
||||
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{detailData.originalTitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 评分 */}
|
||||
{detailData.rating && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Star
|
||||
size={20}
|
||||
className="text-yellow-500 fill-yellow-500"
|
||||
/>
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{detailData.rating.value.toFixed(1)}
|
||||
</span>
|
||||
{detailData.rating.count > 0 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({detailData.rating.count} 评价)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型标签 */}
|
||||
{detailData.genres && detailData.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{detailData.genres.map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 年份和时长 */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{detailData.year && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={16} />
|
||||
<span>{detailData.year}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.duration && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={16} />
|
||||
<span>{detailData.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailData.episodesCount && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Film size={16} />
|
||||
<span>{detailData.episodesCount} 集</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 简介 */}
|
||||
{(detailData.intro || detailData.overview) && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
简介
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
{detailData.intro || detailData.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导演和演员 */}
|
||||
{detailData.directors && detailData.directors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
导演
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.directors.map((d) => d.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.actors && detailData.actors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
演员
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 制作信息 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{detailData.countries && detailData.countries.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<Globe size={14} />
|
||||
国家/地区
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.countries.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.languages && detailData.languages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<Tag size={14} />
|
||||
语言
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{detailData.languages.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.releaseDate && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
上映日期
|
||||
</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailData.status && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">状态</h4>
|
||||
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default DetailPanel;
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
|
||||
|
||||
import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { ExternalLink, Heart, Info, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
@@ -27,6 +27,7 @@ import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
import MobileActionSheet from '@/components/MobileActionSheet';
|
||||
import AIChatPanel from '@/components/AIChatPanel';
|
||||
import DetailPanel from '@/components/DetailPanel';
|
||||
|
||||
export interface VideoCardProps {
|
||||
id?: string;
|
||||
@@ -56,6 +57,11 @@ export interface VideoCardProps {
|
||||
orientation?: 'vertical' | 'horizontal'; // 卡片方向
|
||||
playTime?: number; // 当前播放时间(秒)
|
||||
totalTime?: number; // 总时长(秒)
|
||||
cmsData?: {
|
||||
desc?: string;
|
||||
episodes?: string[];
|
||||
episodes_titles?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type VideoCardHandle = {
|
||||
@@ -93,6 +99,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
orientation = 'vertical',
|
||||
playTime,
|
||||
totalTime,
|
||||
cmsData,
|
||||
}: VideoCardProps,
|
||||
ref
|
||||
) {
|
||||
@@ -103,6 +110,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(null); // 搜索结果的收藏状态
|
||||
const [showAIChat, setShowAIChat] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||
|
||||
// 检查AI功能是否启用
|
||||
useEffect(() => {
|
||||
@@ -553,6 +561,21 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
});
|
||||
}
|
||||
|
||||
// 详情页面按钮
|
||||
actions.push({
|
||||
id: 'detail',
|
||||
label: '详情',
|
||||
icon: <Info size={20} />,
|
||||
onClick: () => {
|
||||
setShowMobileActions(false);
|
||||
// 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成
|
||||
setTimeout(() => {
|
||||
setShowDetailPanel(true);
|
||||
}, 250);
|
||||
},
|
||||
color: 'default' as const,
|
||||
});
|
||||
|
||||
// AI问片功能
|
||||
if (aiEnabled && actualTitle) {
|
||||
actions.push({
|
||||
@@ -1167,7 +1190,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
)}
|
||||
|
||||
{/* 来源 - 右侧 */}
|
||||
{config.showSourceName && source_name && (
|
||||
{config.showSourceName && source_name && !cmsData && (
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
actualSource === 'openlist' ? 'border-yellow-500' : 'border-white/60'
|
||||
@@ -1216,7 +1239,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
)}
|
||||
|
||||
{/* 直播时只显示来源 */}
|
||||
{origin === 'live' && config.showSourceName && source_name && (
|
||||
{origin === 'live' && config.showSourceName && source_name && !cmsData && (
|
||||
<div className='flex items-center justify-end'>
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
@@ -1334,7 +1357,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{config.showSourceName && source_name && (
|
||||
{config.showSourceName && source_name && !cmsData && (
|
||||
<span
|
||||
className='block text-xs text-gray-500 dark:text-gray-400 mt-1'
|
||||
style={{
|
||||
@@ -1380,7 +1403,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
actions={mobileActions}
|
||||
sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined}
|
||||
isAggregate={isAggregate}
|
||||
sourceName={source_name}
|
||||
sourceName={cmsData ? undefined : source_name}
|
||||
currentEpisode={currentEpisode}
|
||||
totalEpisodes={actualEpisodes}
|
||||
origin={origin}
|
||||
@@ -1402,6 +1425,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
welcomeMessage={`想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情面板 */}
|
||||
{showDetailPanel && (
|
||||
<DetailPanel
|
||||
isOpen={showDetailPanel}
|
||||
onClose={() => setShowDetailPanel(false)}
|
||||
title={actualTitle}
|
||||
poster={processImageUrl(actualPoster)}
|
||||
doubanId={actualDoubanId}
|
||||
bangumiId={isBangumi ? actualDoubanId : undefined}
|
||||
isBangumi={isBangumi}
|
||||
tmdbId={tmdb_id}
|
||||
type={actualSearchType as 'movie' | 'tv'}
|
||||
seasonNumber={seasonNumber}
|
||||
cmsData={cmsData}
|
||||
sourceId={id}
|
||||
source={source}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user