forked from 77media/video-flow
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import React, { useState, useCallback, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { X, Check, ChevronDown } from 'lucide-react';
|
||
import * as Popover from '@radix-ui/react-popover';
|
||
import { mockSceneOptions, mockCharacterOptions } from '@/app/model/enums';
|
||
import { Button } from './button';
|
||
import { Input } from './input';
|
||
import { useScriptService } from '@/app/service/Interaction/ScriptService';
|
||
|
||
const ScriptTabContent: React.FC = () => {
|
||
// 获取当前项目ID(这里需要根据实际项目路由或上下文获取)
|
||
const projectId = 'current-project-id'; // TODO: 从路由或上下文获取实际项目ID
|
||
|
||
const {
|
||
scriptSlices,
|
||
userPrompt,
|
||
loading,
|
||
error,
|
||
updateUserPrompt,
|
||
fetchScriptData,
|
||
setFocusedSlice,
|
||
updateScriptSliceText,
|
||
resetScript,
|
||
applyScript,
|
||
fetchProjectScript
|
||
} = useScriptService();
|
||
|
||
// 组件挂载时获取项目剧本数据
|
||
useEffect(() => {
|
||
const initializeScript = async () => {
|
||
try {
|
||
await fetchProjectScript(projectId);
|
||
} catch (error) {
|
||
console.error('初始化剧本数据失败:', error);
|
||
}
|
||
};
|
||
|
||
initializeScript();
|
||
}, [projectId, fetchProjectScript]);
|
||
|
||
// 处理AI生成按钮点击
|
||
const handleAiGenerate = useCallback(async () => {
|
||
if (!userPrompt.trim()) return;
|
||
|
||
try {
|
||
await fetchScriptData(userPrompt);
|
||
} catch (error) {
|
||
console.error('生成剧本失败:', error);
|
||
}
|
||
}, [userPrompt, fetchScriptData]);
|
||
|
||
// 处理重置按钮点击
|
||
const handleReset = useCallback(() => {
|
||
resetScript();
|
||
}, [resetScript]);
|
||
|
||
// 处理确认按钮点击
|
||
const handleConfirm = useCallback(async () => {
|
||
try {
|
||
await applyScript();
|
||
} catch (error) {
|
||
console.error('应用剧本失败:', error);
|
||
}
|
||
}, [applyScript]);
|
||
|
||
// 处理提示词输入变化
|
||
const handlePromptChange = useCallback((value: string) => {
|
||
updateUserPrompt(value);
|
||
}, [updateUserPrompt]);
|
||
|
||
// 处理剧本片段文本变化
|
||
const handleScriptSliceChange = useCallback((sliceId: string, text: string) => {
|
||
setFocusedSlice(sliceId);
|
||
updateScriptSliceText(text);
|
||
}, [setFocusedSlice, updateScriptSliceText]);
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<motion.div
|
||
className="relative w-full h-[90vh] backdrop-blur-xl rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||
initial={{ scale: 0.95, y: 10, opacity: 0 }}
|
||
animate={{
|
||
scale: 1,
|
||
y: 0,
|
||
opacity: 1,
|
||
transition: {
|
||
type: "spring",
|
||
duration: 0.3,
|
||
bounce: 0.15,
|
||
stiffness: 300,
|
||
damping: 25
|
||
}
|
||
}}
|
||
exit={{
|
||
scale: 0.95,
|
||
y: 10,
|
||
opacity: 0,
|
||
transition: {
|
||
type: "tween",
|
||
duration: 0.1,
|
||
ease: "easeOut"
|
||
}
|
||
}}
|
||
>
|
||
{/* 标题 */}
|
||
<motion.div
|
||
className="flex-none px-6 py-4"
|
||
initial={{ opacity: 0, y: -20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.1 }}
|
||
>
|
||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||
Edit Script
|
||
</h2>
|
||
</motion.div>
|
||
|
||
{/* 内容区域 */}
|
||
<motion.div
|
||
className="flex-1 overflow-auto p-6 pt-0 pb-0"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.1, duration: 0.2 }}
|
||
>
|
||
{/* 剧本片段渲染区域 */}
|
||
<motion.div
|
||
style={{
|
||
height: 'calc(100% - 88px)'
|
||
}}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.2 }}
|
||
className="space-y-4"
|
||
>
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||
<p className="text-red-600 text-sm">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 剧本片段列表 */}
|
||
<div className="space-y-3">
|
||
{scriptSlices.map((slice, index) => (
|
||
<motion.div
|
||
key={slice.id}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: index * 0.1 }}
|
||
className="relative"
|
||
>
|
||
<Input
|
||
value={slice.text}
|
||
onChange={(e) => handleScriptSliceChange(slice.id, e.target.value)}
|
||
placeholder={`输入${slice.type}内容...`}
|
||
className="w-full bg-white/50 dark:bg-[#5b75ac20] border border-gray-200 dark:border-gray-600 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
|
||
/>
|
||
<div className="absolute top-2 right-2">
|
||
<span className="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 rounded">
|
||
{slice.type}
|
||
</span>
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 加载状态 */}
|
||
{loading && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="flex items-center justify-center py-8"
|
||
>
|
||
<div className="flex items-center space-x-2">
|
||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||
正在生成剧本...
|
||
</span>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</motion.div>
|
||
|
||
{/* 修改建议输入区域 */}
|
||
<motion.div
|
||
className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent dark:from-[#5b75ac4d] dark:via-[#5b75ac4d] dark:to-transparent pt-8 pb-4 rounded-sm"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.3 }}
|
||
>
|
||
<div className="flex items-center space-x-2 px-4">
|
||
<div className="flex-1">
|
||
<Input
|
||
value={userPrompt}
|
||
onChange={(e) => handlePromptChange(e.target.value)}
|
||
placeholder="输入提示词,然后点击AI生成按钮..."
|
||
className="outline-none box-shadow-none bg-white/50 dark:bg-[#5b75ac20] border-0 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
|
||
disabled={loading}
|
||
/>
|
||
</div>
|
||
<motion.div
|
||
initial={false}
|
||
animate={{
|
||
scale: userPrompt.trim() && !loading ? 1 : 0.8,
|
||
opacity: userPrompt.trim() && !loading ? 1 : 0.5,
|
||
}}
|
||
transition={{
|
||
type: "spring",
|
||
stiffness: 500,
|
||
damping: 30
|
||
}}
|
||
>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
disabled={!userPrompt.trim() || loading}
|
||
onClick={handleAiGenerate}
|
||
className="aiGenerate relative w-9 h-9 rounded-full bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400"
|
||
>
|
||
<motion.span
|
||
initial={false}
|
||
animate={{
|
||
opacity: loading ? 0 : 1,
|
||
scale: loading ? 0.5 : 1,
|
||
}}
|
||
>
|
||
<svg
|
||
className="w-5 h-5 transform rotate-90"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||
/>
|
||
</svg>
|
||
</motion.span>
|
||
{loading && (
|
||
<motion.div
|
||
className="absolute inset-0 flex items-center justify-center"
|
||
initial={{ opacity: 0, scale: 0.5 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.5 }}
|
||
>
|
||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||
</motion.div>
|
||
)}
|
||
</Button>
|
||
</motion.div>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
|
||
{/* 底部按钮 */}
|
||
<motion.div
|
||
className="flex-none px-6 py-4 flex justify-end space-x-4"
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: 0.4 }}
|
||
>
|
||
<Button
|
||
variant="ghost"
|
||
className="min-w-[80px] bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||
onClick={handleReset}
|
||
disabled={loading}
|
||
>
|
||
Reset
|
||
</Button>
|
||
<Button
|
||
className="min-w-[80px] bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||
onClick={handleConfirm}
|
||
disabled={loading || scriptSlices.length === 0}
|
||
>
|
||
Confirm
|
||
</Button>
|
||
</motion.div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ScriptTabContent;
|