播放页面的弹窗在大屏下使用抽屉式

This commit is contained in:
mtvpls
2026-02-28 02:15:36 +08:00
parent 19251c8f17
commit fffced713c
5 changed files with 921 additions and 64 deletions

View File

@@ -58,6 +58,7 @@ import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
import DetailPanel from '@/components/DetailPanel';
import DoubanComments from '@/components/DoubanComments';
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
import Drawer from '@/components/Drawer';
import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch';
@@ -132,6 +133,48 @@ function PlayPageClient() {
// 详情面板状态
const [showDetailPanel, setShowDetailPanel] = useState(false);
// 大屏设备检测(判断选集面板是否在右侧)
const [isLargeScreen, setIsLargeScreen] = useState(false);
// 检测是否为大屏设备
useEffect(() => {
const checkScreenSize = () => {
setIsLargeScreen(window.innerWidth >= 768); // md断点
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
// 抽屉管理:打开指定抽屉时关闭其他抽屉
const openDrawer = (drawerName: 'pansou' | 'aiChat' | 'correct' | 'detail') => {
if (!isLargeScreen) {
// 小屏设备不需要互斥
switch (drawerName) {
case 'pansou':
setShowPansouDialog(true);
break;
case 'aiChat':
setShowAIChat(true);
break;
case 'correct':
setShowCorrectDialog(true);
break;
case 'detail':
setShowDetailPanel(true);
break;
}
return;
}
// 大屏设备:关闭其他抽屉
setShowPansouDialog(drawerName === 'pansou');
setShowAIChat(drawerName === 'aiChat');
setShowCorrectDialog(drawerName === 'correct');
setShowDetailPanel(drawerName === 'detail');
};
// 检查AI功能是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
@@ -8103,7 +8146,7 @@ function PlayPageClient() {
<button
onClick={(e) => {
e.stopPropagation();
setShowPansouDialog(true);
openDrawer('pansou');
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity'
title='搜索网盘资源'
@@ -8115,7 +8158,7 @@ function PlayPageClient() {
<button
onClick={(e) => {
e.stopPropagation();
setShowAIChat(true);
openDrawer('aiChat');
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity'
title='AI问片'
@@ -8128,7 +8171,7 @@ function PlayPageClient() {
<button
onClick={(e) => {
e.stopPropagation();
setShowDetailPanel(true);
openDrawer('detail');
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity px-2 py-1 text-base font-medium text-gray-700 dark:text-gray-300'
title='详情'
@@ -8141,7 +8184,7 @@ function PlayPageClient() {
<button
onClick={(e) => {
e.stopPropagation();
setShowCorrectDialog(true);
openDrawer('correct');
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity'
title='纠错'
@@ -8392,36 +8435,52 @@ function PlayPageClient() {
{/* 网盘搜索弹窗 */}
{showPansouDialog && (
<div
className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/50'
onClick={() => setShowPansouDialog(false)}
>
<div
className='relative w-full max-w-4xl max-h-[80vh] overflow-y-auto bg-white dark:bg-gray-900 rounded-lg shadow-xl m-4'
onClick={(e) => e.stopPropagation()}
isLargeScreen ? (
<Drawer
isOpen={showPansouDialog}
onClose={() => setShowPansouDialog(false)}
title={`搜索网盘资源: ${detail?.title || ''}`}
width='w-[400px]'
>
{/* 弹窗头部 */}
<div className='sticky top-0 z-10 flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900'>
<h2 className='text-xl font-bold text-gray-900 dark:text-gray-100'>
: {detail?.title || ''}
</h2>
<button
onClick={() => setShowPansouDialog(false)}
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'
>
<X className='h-5 w-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
{/* 弹窗内容 */}
<div className='p-4'>
<PansouSearch
keyword={detail?.title || ''}
triggerSearch={showPansouDialog}
/>
</div>
</Drawer>
) : (
<div
className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/50'
onClick={() => setShowPansouDialog(false)}
>
<div
className='relative w-full max-w-4xl max-h-[80vh] overflow-y-auto bg-white dark:bg-gray-900 rounded-lg shadow-xl m-4'
onClick={(e) => e.stopPropagation()}
>
{/* 弹窗头部 */}
<div className='sticky top-0 z-10 flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900'>
<h2 className='text-xl font-bold text-gray-900 dark:text-gray-100'>
: {detail?.title || ''}
</h2>
<button
onClick={() => setShowPansouDialog(false)}
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'
>
<X className='h-5 w-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
{/* 弹窗内容 */}
<div className='p-4'>
<PansouSearch
keyword={detail?.title || ''}
triggerSearch={showPansouDialog}
/>
</div>
</div>
</div>
</div>
)
)}
{/* AI问片面板 */}
@@ -8436,6 +8495,8 @@ function PlayPageClient() {
currentEpisode: currentEpisodeIndex + 1,
}}
welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', detail.title || '') : `想了解《${detail.title}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
useDrawer={isLargeScreen}
drawerWidth='w-[400px]'
/>
)}
@@ -8460,6 +8521,8 @@ function PlayPageClient() {
// 纠错成功后的回调
handleCorrectSuccess();
}}
useDrawer={isLargeScreen}
drawerWidth='w-[400px]'
/>
)}
@@ -8507,6 +8570,8 @@ function PlayPageClient() {
}
sourceId={detail.id}
source={detail.source}
useDrawer={isLargeScreen}
drawerWidth='w-[400px]'
/>
)}
</PageLayout>

View File

@@ -20,6 +20,8 @@ interface AIChatPanelProps {
context?: VideoContext;
welcomeMessage?: string;
onStreamingChange?: (isStreaming: boolean) => void;
useDrawer?: boolean;
drawerWidth?: string;
}
export default function AIChatPanel({
@@ -28,6 +30,8 @@ export default function AIChatPanel({
context,
welcomeMessage = '你好我是MoonTVPlus的AI影视助手有什么可以帮你的吗',
onStreamingChange,
useDrawer = false,
drawerWidth = 'w-full md:w-[25%]',
}: AIChatPanelProps) {
// 使用 useMemo 稳定 storage key只在实际内容变化时才改变
const storageKey = useMemo(() => {
@@ -136,22 +140,24 @@ export default function AIChatPanel({
inputRef.current.focus();
}
// 防止背景滚动
const originalOverflow = document.body.style.overflow;
const originalPaddingRight = document.body.style.paddingRight;
// 只在非抽屉模式下防止背景滚动
if (!useDrawer) {
const originalOverflow = document.body.style.overflow;
const originalPaddingRight = document.body.style.paddingRight;
// 获取滚动条宽度
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
// 获取滚动条宽度
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = originalPaddingRight;
};
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = originalPaddingRight;
};
}
}
}, [isOpen]);
}, [isOpen, useDrawer]);
const handleSendMessage = async () => {
if (!input.trim() || isStreaming) return;
@@ -303,7 +309,192 @@ export default function AIChatPanel({
console.log('已清空聊天上下文');
};
const modalContent = (
const modalContent = useDrawer ? (
// 抽屉模式
<div
className={`fixed inset-0 z-[1002] flex items-center justify-end transition-opacity duration-200 pointer-events-none ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<div
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* 头部 */}
<div className='flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700'>
<div className='flex items-center gap-3 min-w-0 flex-1'>
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-purple-500 flex-shrink-0'>
<Sparkles size={20} className='text-white' />
</div>
<div className='min-w-0 flex-1'>
<h2 className='text-lg font-semibold text-gray-900 dark:text-white'>
AI影视助手
</h2>
{context?.title && (
<p className='text-xs text-gray-500 dark:text-gray-400 truncate'>
: {context.title}
{context.year && ` (${context.year})`}
</p>
)}
</div>
</div>
<button
onClick={onClose}
className='rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 flex-shrink-0'
>
<X size={20} />
</button>
</div>
{/* 消息列表 */}
<div className='flex-1 overflow-y-auto p-4'>
<div className='space-y-4'>
{messages.map((message, index) => (
<div
key={index}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`flex max-w-[80%] gap-3 ${message.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
>
{/* 头像 */}
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
message.role === 'user'
? 'bg-blue-500'
: 'bg-purple-500'
}`}
>
{message.role === 'user' ? (
<span className='text-xs font-semibold text-white'>
U
</span>
) : (
<Bot size={16} className='text-white' />
)}
</div>
{/* 消息内容 */}
<div
className={`rounded-2xl px-4 py-2 ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
}`}
>
{message.role === 'user' ? (
<p className='whitespace-pre-wrap break-words text-sm leading-relaxed'>
{message.content}
</p>
) : (
<div className='prose prose-sm max-w-none dark:prose-invert prose-p:my-2 prose-p:leading-relaxed prose-pre:bg-gray-800 prose-pre:text-gray-100 dark:prose-pre:bg-gray-900 prose-code:text-purple-600 dark:prose-code:text-purple-400 prose-code:bg-purple-50 dark:prose-code:bg-purple-900/20 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-strong:text-gray-900 dark:prose-strong:text-white prose-ul:my-2 prose-ol:my-2 prose-li:my-1'>
<ReactMarkdown remarkPlugins={[remarkGfm as any]}>
{message.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
</div>
))}
{/* 加载指示器 */}
{isStreaming && (
<div className='flex justify-start'>
<div className='flex max-w-[80%] gap-3'>
<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-500'>
<Bot size={16} className='text-white' />
</div>
<div className='flex items-center gap-2 rounded-2xl bg-gray-100 px-4 py-2 dark:bg-gray-800'>
<Loader2 size={16} className='animate-spin text-gray-500' />
<span className='text-sm text-gray-500 dark:text-gray-400'>
AI正在思考...
</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* 输入区域 */}
<div className='border-t border-gray-200 p-4 dark:border-gray-700'>
<div className='flex gap-2'>
<button
onClick={handleClearContext}
className='flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-gray-300 text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800'
title='清空聊天记录'
disabled={isStreaming}
>
<Trash2 size={20} />
</button>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isMobile ? '输入你的问题...' : '输入你的问题... (Shift+Enter换行)'}
disabled={isStreaming}
rows={1}
className='flex-1 resize-none rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 placeholder-gray-400 transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-purple-400'
style={{
minHeight: '48px',
maxHeight: '120px',
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
}}
/>
<button
onClick={handleSendMessage}
disabled={!input.trim() || isStreaming}
className='flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-purple-500 text-white transition-colors hover:bg-purple-600 disabled:cursor-not-allowed disabled:opacity-50'
>
{isStreaming ? (
<Loader2 size={20} className='animate-spin' />
) : (
<Send size={20} />
)}
</button>
</div>
{/* 快捷提示 */}
{messages.length === 1 && !isStreaming && (
<div className='mt-3 flex flex-wrap gap-2'>
<button
onClick={() => setInput('推荐一些高分电影')}
className='rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
>
</button>
<button
onClick={() => setInput('最近有什么新电影上映?')}
className='rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
>
</button>
{context?.title && (
<button
onClick={() =>
setInput(`${context.title}讲的是什么故事?`)
}
className='rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
>
</button>
)}
</div>
)}
</div>
</div>
</div>
) : (
// 原有的居中弹窗模式
<div
className={`fixed inset-0 z-[1002] flex items-center justify-center bg-black/50 backdrop-blur-sm overflow-hidden transition-opacity duration-200 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'

View File

@@ -49,7 +49,9 @@ interface CorrectDialogProps {
seasonName?: string;
};
onCorrect: () => void;
source?: string; // 新增视频源类型xiaoya, openlist等
source?: string;
useDrawer?: boolean;
drawerWidth?: string;
}
export default function CorrectDialog({
@@ -59,7 +61,9 @@ export default function CorrectDialog({
currentTitle,
currentVideo,
onCorrect,
source = 'openlist', // 默认为 openlist
source = 'openlist',
useDrawer = false,
drawerWidth = 'w-full md:w-[25%]',
}: CorrectDialogProps) {
const [searchQuery, setSearchQuery] = useState(currentTitle);
const [searching, setSearching] = useState(false);
@@ -397,21 +401,20 @@ export default function CorrectDialog({
if (!isOpen) return null;
return createPortal(
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm'>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col m-4'>
{/* 头部 */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{currentTitle}
</h2>
<button
onClick={onClose}
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<X size={24} />
</button>
</div>
const dialogContent = (
<>
{/* 头部 */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{currentTitle}
</h2>
<button
onClick={onClose}
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<X size={24} />
</button>
</div>
{/* 搜索框 */}
{!showManualInput && (
@@ -810,8 +813,23 @@ export default function CorrectDialog({
</>
)}
</div>
</>
);
return createPortal(
useDrawer ? (
<div className='fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none'>
<div className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-800 shadow-2xl flex flex-col pointer-events-auto`}>
{dialogContent}
</div>
</div>
</div>,
) : (
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm'>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col m-4'>
{dialogContent}
</div>
</div>
),
document.body
);
}

View File

@@ -21,15 +21,16 @@ interface DetailPanelProps {
tmdbId?: number;
type?: 'movie' | 'tv';
seasonNumber?: number;
currentEpisode?: number; // 当前集数从1开始
currentEpisode?: number;
cmsData?: {
desc?: string;
episodes?: string[];
episodes_titles?: string[];
};
// 用于调用 source-detail API
sourceId?: string;
source?: string;
useDrawer?: boolean;
drawerWidth?: string;
}
interface DetailData {
@@ -83,6 +84,8 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
cmsData,
sourceId,
source,
useDrawer = false,
drawerWidth = 'w-full md:w-[25%]',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
@@ -156,9 +159,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
};
}, [isOpen]);
// 阻止背景滚动
// 阻止背景滚动(仅在非抽屉模式下)
useEffect(() => {
if (isVisible) {
if (isVisible && !useDrawer) {
// 保存当前滚动位置
const scrollY = window.scrollY;
const scrollX = window.scrollX;
@@ -204,7 +207,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
});
};
}
}, [isVisible]);
}, [isVisible, useDrawer]);
// ESC键关闭
useEffect(() => {
@@ -860,7 +863,481 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
if (!isVisible || !mounted) return null;
const content = (
const content = useDrawer ? (
<div className="fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none">
{/* 详情面板 - 抽屉模式 */}
<div
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
isAnimating ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* 头部 */}
<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">
<div className="text-center mb-6">
<p className="text-red-500 dark:text-red-400">{error}</p>
</div>
{/* 数据源显示和切换 - 错误时也显示 */}
<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 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">
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
{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>
</div>
</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 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>
)}
<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>
{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 || null, 'w185')))}
>
<Image
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, '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 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>
{/* 季度和集数信息仅TMDB电视剧 */}
{detailData.mediaType === 'tv' && (
<div className="mt-6">
{loadingSeasons && (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
</div>
)}
{!loadingSeasons && seasonData && (
<>
{/* 季度列表 */}
{seasonData.seasons.length > 0 && (
<div className="mb-6">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{seasonData.seasons.map((season: any) => (
<div
key={season.id}
onClick={() => handleSeasonChange(season.season_number)}
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
selectedSeason === season.season_number
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
: 'bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{season.poster_path && (
<div
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
}}
>
<Image
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
alt={season.name}
fill
className="object-cover"
draggable={false}
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{season.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{season.episode_count}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* 集数列表 */}
{seasonData.episodes.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `${selectedSeason}`}
</h4>
<div
ref={episodesScrollRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
style={{
scrollbarWidth: 'thin',
scrollBehavior: isDragging ? 'auto' : 'smooth'
}}
>
<div className="flex gap-3 py-2">
{seasonData.episodes.map((episode: Episode) => {
const isExpanded = expandedEpisodes.has(episode.id);
const isCurrentEpisode = currentEpisode === episode.episode_number;
return (
<div
key={episode.id}
id={`episode-${episode.episode_number}`}
className={`flex-shrink-0 w-64 p-3 rounded ${
isCurrentEpisode
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
: 'bg-gray-50 dark:bg-gray-800'
}`}
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
>
{episode.still_path && (
<div
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
>
<Image
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
alt={episode.name}
fill
className="object-cover"
draggable={false}
/>
</div>
)}
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{episode.episode_number}: {episode.name}
</p>
{episode.overview && (
<p
onClick={() => {
const newExpanded = new Set(expandedEpisodes);
if (isExpanded) {
newExpanded.delete(episode.id);
} else {
newExpanded.add(episode.id);
}
setExpandedEpisodes(newExpanded);
}}
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`}
>
{episode.overview}
</p>
)}
{episode.air_date && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{episode.air_date}
</p>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</>
)}
</div>
)}
{/* 数据源显示和切换 */}
<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 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">
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
{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>
</div>
</div>
)}
</div>
</div>
{/* 图片查看器 */}
{showImageViewer && (
<ImageViewer
isOpen={showImageViewer}
onClose={() => setShowImageViewer(false)}
imageUrl={selectedImage}
alt={detailData?.title || title}
/>
)}
</div>
) : (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
{/* 背景遮罩 */}
<div
@@ -874,7 +1351,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}}
/>
{/* 详情面板 */}
{/* 详情面板 - 居中模式 */}
<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={{

106
src/components/Drawer.tsx Normal file
View File

@@ -0,0 +1,106 @@
'use client';
import { X } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
position?: 'left' | 'right';
width?: string;
}
export default function Drawer({
isOpen,
onClose,
title,
children,
position = 'right',
width = 'w-full md:w-96',
}: DrawerProps) {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [mounted, setMounted] = useState(false);
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);
}, 300);
}
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (timer) {
clearTimeout(timer);
}
};
}, [isOpen]);
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isVisible) {
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}
}, [isVisible, onClose]);
if (!isVisible || !mounted) return null;
const content = (
<div className='fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none'>
<div
className={`relative ${width} h-full bg-white dark:bg-gray-900 shadow-2xl flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
position === 'right'
? isAnimating
? 'translate-x-0'
: 'translate-x-full'
: isAnimating
? 'translate-x-0'
: '-translate-x-full'
}`}
>
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
{title}
</h2>
<button
onClick={onClose}
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'
>
<X size={20} className='text-gray-500 dark:text-gray-400' />
</button>
</div>
<div className='flex-1 overflow-y-auto'>{children}</div>
</div>
</div>
);
return createPortal(content, document.body);
}