425 lines
21 KiB
TypeScript
425 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import api from "@/lib/axios";
|
||
|
||
interface ScriptExtractionModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onApply?: (text: string) => void;
|
||
}
|
||
|
||
export default function ScriptExtractionModal({
|
||
isOpen,
|
||
onClose,
|
||
onApply
|
||
}: ScriptExtractionModalProps) {
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [script, setScript] = useState("");
|
||
const [rewrittenScript, setRewrittenScript] = useState("");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [doRewrite, setDoRewrite] = useState(true);
|
||
const [step, setStep] = useState<'config' | 'processing' | 'result'>('config');
|
||
const [dragActive, setDragActive] = useState(false);
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
|
||
// New state for URL mode
|
||
const [activeTab, setActiveTab] = useState<'file' | 'url'>('url');
|
||
const [inputUrl, setInputUrl] = useState("");
|
||
|
||
// Reset state when modal opens
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
setStep('config');
|
||
setScript("");
|
||
setRewrittenScript("");
|
||
setError(null);
|
||
setIsLoading(false);
|
||
setSelectedFile(null);
|
||
setInputUrl("");
|
||
setActiveTab('url');
|
||
}
|
||
}, [isOpen]);
|
||
|
||
const handleDrag = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.type === "dragenter" || e.type === "dragover") {
|
||
setDragActive(true);
|
||
} else if (e.type === "dragleave") {
|
||
setDragActive(false);
|
||
}
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setDragActive(false);
|
||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||
handleFile(e.dataTransfer.files[0]);
|
||
}
|
||
};
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
handleFile(e.target.files[0]);
|
||
}
|
||
};
|
||
|
||
const handleFile = (file: File) => {
|
||
const validTypes = ['.mp4', '.mov', '.avi', '.mp3', '.wav', '.m4a'];
|
||
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
|
||
if (!validTypes.includes(ext)) {
|
||
setError(`不支持的文件格式 ${ext},请上传视频或音频文件`);
|
||
return;
|
||
}
|
||
setSelectedFile(file);
|
||
setError(null);
|
||
};
|
||
|
||
const handleExtract = async () => {
|
||
if (activeTab === 'file' && !selectedFile) {
|
||
setError("请先上传文件");
|
||
return;
|
||
}
|
||
if (activeTab === 'url' && !inputUrl.trim()) {
|
||
setError("请先输入视频链接");
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setStep('processing');
|
||
setError(null);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
if (activeTab === 'file' && selectedFile) {
|
||
formData.append('file', selectedFile);
|
||
} else if (activeTab === 'url') {
|
||
formData.append('url', inputUrl.trim());
|
||
}
|
||
formData.append('rewrite', doRewrite ? 'true' : 'false');
|
||
|
||
const { data } = await api.post('/api/tools/extract-script', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
timeout: 180000 // 3 minutes timeout
|
||
});
|
||
|
||
if (data.success) {
|
||
setScript(data.original_script);
|
||
setRewrittenScript(data.rewritten_script || "");
|
||
setStep('result');
|
||
} else {
|
||
setError("提取失败:未知错误");
|
||
setStep('config');
|
||
}
|
||
} catch (err: any) {
|
||
console.error(err);
|
||
const msg = err.response?.data?.detail || err.message || "请求失败";
|
||
setError(msg);
|
||
setStep('config');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const copyToClipboard = (text: string) => {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
alert("已复制到剪贴板");
|
||
}).catch(err => {
|
||
console.error('Async: Could not copy text: ', err);
|
||
fallbackCopyTextToClipboard(text);
|
||
});
|
||
} else {
|
||
fallbackCopyTextToClipboard(text);
|
||
}
|
||
};
|
||
|
||
const fallbackCopyTextToClipboard = (text: string) => {
|
||
var textArea = document.createElement("textarea");
|
||
textArea.value = text;
|
||
|
||
// Avoid scrolling to bottom
|
||
textArea.style.top = "0";
|
||
textArea.style.left = "0";
|
||
textArea.style.position = "fixed";
|
||
textArea.style.opacity = "0";
|
||
|
||
document.body.appendChild(textArea);
|
||
textArea.focus();
|
||
textArea.select();
|
||
|
||
try {
|
||
var successful = document.execCommand('copy');
|
||
var msg = successful ? 'successful' : 'unsuccessful';
|
||
if (successful) {
|
||
alert("已复制到剪贴板");
|
||
} else {
|
||
alert("复制失败,请手动复制");
|
||
}
|
||
} catch (err) {
|
||
console.error('Fallback: Oops, unable to copy', err);
|
||
alert("复制失败,请手动复制");
|
||
}
|
||
|
||
document.body.removeChild(textArea);
|
||
};
|
||
|
||
// Close when clicking outside - DISABLED as per user request
|
||
// const modalRef = useRef<HTMLDivElement>(null);
|
||
// const handleBackdropClick = (e: React.MouseEvent) => {
|
||
// if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||
// onClose();
|
||
// }
|
||
// };
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||
>
|
||
<div
|
||
// ref={modalRef}
|
||
className="bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl"
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5">
|
||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||
📜 文案提取助手
|
||
</h3>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
{step === 'config' && (
|
||
<div className="space-y-6">
|
||
|
||
{/* Tabs */}
|
||
<div className="flex p-1 bg-white/5 rounded-xl border border-white/10">
|
||
<button
|
||
onClick={() => setActiveTab('url')}
|
||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'url'
|
||
? 'bg-purple-600 text-white shadow-lg'
|
||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||
}`}
|
||
>
|
||
🔗 粘贴链接
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('file')}
|
||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'file'
|
||
? 'bg-purple-600 text-white shadow-lg'
|
||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||
}`}
|
||
>
|
||
📂 上传文件
|
||
</button>
|
||
</div>
|
||
|
||
{/* URL Input Area */}
|
||
{activeTab === 'url' && (
|
||
<div className="space-y-2 py-4">
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
value={inputUrl}
|
||
onChange={(e) => setInputUrl(e.target.value)}
|
||
placeholder="请粘贴抖音、B站等主流平台视频链接..."
|
||
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-4 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||
/>
|
||
{inputUrl && (
|
||
<button
|
||
onClick={() => setInputUrl("")}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white p-1"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-gray-400 px-1">
|
||
支持抖音、B站等主流平台分享链接,自动解析下载并提取文案。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* File Upload Area */}
|
||
{activeTab === 'file' && (
|
||
<div
|
||
className={`
|
||
relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer
|
||
${dragActive ? 'border-purple-500 bg-purple-500/10' : 'border-white/20 hover:border-white/40 hover:bg-white/5'}
|
||
${selectedFile ? 'bg-purple-900/10 border-purple-500/50' : ''}
|
||
`}
|
||
onDragEnter={handleDrag}
|
||
onDragLeave={handleDrag}
|
||
onDragOver={handleDrag}
|
||
onDrop={handleDrop}
|
||
>
|
||
<input
|
||
type="file"
|
||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||
onChange={handleFileChange}
|
||
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
|
||
/>
|
||
|
||
{selectedFile ? (
|
||
<div className="flex flex-col items-center">
|
||
<div className="text-4xl mb-2">📄</div>
|
||
<div className="font-medium text-white break-all max-w-xs">{selectedFile.name}</div>
|
||
<div className="text-sm text-gray-400 mt-1">{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</div>
|
||
<div className="mt-4 text-xs text-purple-400">点击更换文件</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center">
|
||
<div className="text-4xl mb-2">📤</div>
|
||
<div className="font-medium text-white">点击上传或拖拽文件到此处</div>
|
||
<div className="text-sm text-gray-400 mt-2">支持 MP4, MOV, MP3, WAV 等音视频格式</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Options */}
|
||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||
<label className="flex items-center gap-3 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={doRewrite}
|
||
onChange={e => setDoRewrite(e.target.checked)}
|
||
className="w-5 h-5 accent-purple-600 rounded"
|
||
/>
|
||
<div>
|
||
<div className="text-white font-medium">启用 AI 洗稿</div>
|
||
<div className="text-xs text-gray-400">自动将提取的文案重写为更自然流畅的口播稿</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="p-3 bg-red-500/20 text-red-200 rounded-lg text-sm text-center">
|
||
❌ {error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-center pt-2">
|
||
<button
|
||
onClick={handleExtract}
|
||
className="w-full sm:w-auto px-10 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-bold hover:shadow-lg hover:from-purple-500 hover:to-pink-500 transition-all transform hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
disabled={activeTab === 'file' ? !selectedFile : !inputUrl.trim()}
|
||
>
|
||
{activeTab === 'url' ? '🔗 解析并提取' : '🚀 开始提取'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'processing' && (
|
||
<div className="flex flex-col items-center justify-center py-20">
|
||
<div className="relative w-20 h-20 mb-6">
|
||
<div className="absolute inset-0 border-4 border-purple-500/30 rounded-full"></div>
|
||
<div className="absolute inset-0 border-4 border-t-purple-500 rounded-full animate-spin"></div>
|
||
</div>
|
||
<h4 className="text-xl font-medium text-white mb-2">正在处理中...</h4>
|
||
<p className="text-sm text-gray-400 text-center max-w-sm px-4">
|
||
{activeTab === 'url' && "正在下载视频..."}<br />
|
||
{doRewrite ? "正在进行语音识别和 AI 智能改写..." : "正在进行语音识别..."}<br />
|
||
<span className="opacity-75">大文件可能需要几分钟,请不要关闭窗口</span>
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'result' && (
|
||
<div className="space-y-6">
|
||
{rewrittenScript && (
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between items-center">
|
||
<h4 className="font-semibold text-purple-300 flex items-center gap-2">
|
||
✨ AI 洗稿结果 <span className="text-xs font-normal text-purple-400/70">(推荐)</span>
|
||
</h4>
|
||
{onApply && (
|
||
<button
|
||
onClick={() => {
|
||
onApply(rewrittenScript);
|
||
onClose();
|
||
}}
|
||
className="text-xs bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
|
||
>
|
||
📥 填入
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => copyToClipboard(rewrittenScript)}
|
||
className="text-xs bg-purple-600 hover:bg-purple-500 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
|
||
>
|
||
📋 复制内容
|
||
</button>
|
||
</div>
|
||
<div className="bg-purple-900/10 border border-purple-500/20 rounded-xl p-4 max-h-60 overflow-y-auto custom-scrollbar">
|
||
<p className="text-gray-200 text-sm leading-relaxed whitespace-pre-wrap">
|
||
{rewrittenScript}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between items-center">
|
||
<h4 className="font-semibold text-gray-400 flex items-center gap-2">
|
||
🎙️ 原始识别结果
|
||
</h4>
|
||
{onApply && (
|
||
<button
|
||
onClick={() => {
|
||
onApply(script);
|
||
onClose();
|
||
}}
|
||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
|
||
>
|
||
📥 填入
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => copyToClipboard(script)}
|
||
className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-40 overflow-y-auto custom-scrollbar">
|
||
<p className="text-gray-400 text-sm leading-relaxed whitespace-pre-wrap">
|
||
{script}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-center pt-4">
|
||
<button
|
||
onClick={() => {
|
||
setStep('config');
|
||
setScript("");
|
||
setRewrittenScript("");
|
||
setSelectedFile(null);
|
||
setInputUrl("");
|
||
// Keep current tab active
|
||
}}
|
||
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
|
||
>
|
||
提取下一个
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|