Files
ViGent2/frontend/src/features/home/ui/ScriptExtractionModal.tsx
Kevin Wong 1717635bfd 更新
2026-02-25 17:51:58 +08:00

363 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useCallback } from "react";
import { Loader2 } from "lucide-react";
import { useScriptExtraction } from "./script-extraction/useScriptExtraction";
interface ScriptExtractionModalProps {
isOpen: boolean;
onClose: () => void;
onApply?: (text: string) => void;
}
export default function ScriptExtractionModal({
isOpen,
onClose,
onApply,
}: ScriptExtractionModalProps) {
const {
isLoading,
script,
rewrittenScript,
error,
doRewrite,
step,
dragActive,
selectedFile,
activeTab,
inputUrl,
customPrompt,
showCustomPrompt,
setDoRewrite,
setActiveTab,
setInputUrl,
setCustomPrompt,
setShowCustomPrompt,
handleDrag,
handleDrop,
handleFileChange,
handleExtract,
copyToClipboard,
resetToConfig,
clearSelectedFile,
clearInputUrl,
} = useScriptExtraction({ isOpen });
// 快捷键ESC 关闭Enter 提交(仅在 config 步骤)
const canExtract = (activeTab === "file" && selectedFile) || (activeTab === "url" && inputUrl.trim());
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "Enter" && !e.shiftKey && step === "config" && canExtract && !isLoading) {
e.preventDefault();
handleExtract();
}
}, [onClose, step, canExtract, isLoading, handleExtract]);
useEffect(() => {
if (!isOpen) return;
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
const handleApplyAndClose = (text: string) => {
onApply?.(text);
onClose();
};
const handleExtractNext = () => {
resetToConfig();
clearSelectedFile();
clearInputUrl();
};
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 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"
>
&times;
</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={clearInputUrl}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
</button>
)}
</div>
<p className="text-xs text-gray-500 pl-1">
B站
</p>
</div>
)}
{/* File Upload Area */}
{activeTab === "file" && (
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${dragActive
? "border-purple-500 bg-purple-500/10"
: "border-white/10 hover:border-white/20"
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="space-y-2">
<p className="text-white">{selectedFile.name}</p>
<p className="text-sm text-gray-400">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
<button
onClick={clearSelectedFile}
className="text-xs text-purple-400 hover:text-purple-300"
>
</button>
</div>
) : (
<div className="space-y-4">
<div className="text-4xl">📁</div>
<p className="text-gray-400">
/
<label className="text-purple-400 hover:text-purple-300 cursor-pointer">
<input
type="file"
accept=".mp4,.mov,.avi,.mp3,.wav,.m4a"
onChange={handleFileChange}
className="hidden"
/>
</label>
</p>
<p className="text-xs text-gray-500">
MP4, MOV, AVI, MP3, WAV, M4A
</p>
</div>
)}
</div>
)}
{/* Options */}
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<div className="flex items-center justify-between p-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={doRewrite}
onChange={(e) => setDoRewrite(e.target.checked)}
className="w-4 h-4 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
/>
<span className="text-sm text-gray-300">
AI
</span>
</label>
{doRewrite && (
<button
type="button"
onClick={() => setShowCustomPrompt(!showCustomPrompt)}
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
>
{showCustomPrompt ? "▲" : "▼"}
</button>
)}
</div>
{doRewrite && showCustomPrompt && (
<div className="px-4 pb-4 space-y-2">
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="输入自定义改写提示词..."
rows={3}
className="w-full bg-black/20 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors resize-none"
/>
<p className="text-xs text-gray-500">
使
</p>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Action Button */}
<div className="flex gap-3 pt-2">
<button
onClick={onClose}
className="flex-1 py-3 px-4 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-colors"
>
</button>
<button
onClick={handleExtract}
disabled={
(activeTab === "file" && !selectedFile) ||
(activeTab === "url" && !inputUrl.trim()) ||
isLoading
}
className="flex-1 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition-all font-medium shadow-lg flex items-center justify-center gap-2"
>
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : null}
</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={() => handleApplyAndClose(rewrittenScript)}
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 hide-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={() => handleApplyAndClose(script)}
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 hide-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={handleExtractNext}
className="px-6 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}