播放页面的弹窗在大屏下使用抽屉式
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
106
src/components/Drawer.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user