Files
ViGent2/frontend/src/features/home/ui/ScriptEditor.tsx
Kevin Wong 091f78174e 更新
2026-03-03 15:16:38 +08:00

269 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { FileText, History, Languages, Loader2, Maximize2, RotateCcw, Save, Sparkles, Trash2 } from "lucide-react";
import type { SavedScript } from "@/features/home/model/useSavedScripts";
import { AppModal, AppModalHeader } from "@/shared/ui/AppModal";
const LANGUAGES = [
{ code: "English", label: "英语 English" },
{ code: "日本語", label: "日语 日本語" },
{ code: "한국어", label: "韩语 한국어" },
{ code: "Français", label: "法语 Français" },
{ code: "Deutsch", label: "德语 Deutsch" },
{ code: "Español", label: "西班牙语 Español" },
{ code: "Русский", label: "俄语 Русский" },
{ code: "Italiano", label: "意大利语 Italiano" },
{ code: "Português", label: "葡萄牙语 Português" },
];
interface ScriptEditorProps {
text: string;
onChangeText: (value: string) => void;
onOpenExtractModal: () => void;
onOpenRewriteModal: () => void;
onTranslate: (targetLang: string) => void;
isTranslating: boolean;
hasOriginalText: boolean;
onRestoreOriginal: () => void;
savedScripts: SavedScript[];
onSaveScript: () => void;
onLoadScript: (content: string) => void;
onDeleteScript: (id: string) => void;
}
export function ScriptEditor({
text,
onChangeText,
onOpenExtractModal,
onOpenRewriteModal,
onTranslate,
isTranslating,
hasOriginalText,
onRestoreOriginal,
savedScripts,
onSaveScript,
onLoadScript,
onDeleteScript,
}: ScriptEditorProps) {
const actionBtnBase = "px-3 py-1.5 text-xs rounded-lg transition-colors whitespace-nowrap inline-flex items-center gap-1.5";
const actionBtnDisabled = "bg-gray-600 cursor-not-allowed text-gray-400";
const [showLangMenu, setShowLangMenu] = useState(false);
const langMenuRef = useRef<HTMLDivElement>(null);
const [showHistoryMenu, setShowHistoryMenu] = useState(false);
const historyMenuRef = useRef<HTMLDivElement>(null);
const [isExpandedEditorOpen, setIsExpandedEditorOpen] = useState(false);
const handleCloseExpandedEditor = useCallback(() => {
setIsExpandedEditorOpen(false);
}, []);
useEffect(() => {
if (!showLangMenu) return;
const handleClickOutside = (e: MouseEvent) => {
if (langMenuRef.current && !langMenuRef.current.contains(e.target as Node)) {
setShowLangMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showLangMenu]);
useEffect(() => {
if (!showHistoryMenu) return;
const handleClickOutside = (e: MouseEvent) => {
if (historyMenuRef.current && !historyMenuRef.current.contains(e.target as Node)) {
setShowHistoryMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showHistoryMenu]);
const handleSelectLang = (langCode: string) => {
setShowLangMenu(false);
onTranslate(langCode);
};
const formatDate = (ts: number) => {
const d = new Date(ts);
return `${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
};
return (
<div className="relative z-10 bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
<div className="mb-4 space-y-3">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
</h2>
<div className="flex gap-2 flex-wrap justify-end items-center">
{/* 历史文案 */}
<div className="relative" ref={historyMenuRef}>
<button
onClick={() => setShowHistoryMenu((prev) => !prev)}
className={`${actionBtnBase} bg-gray-600 hover:bg-gray-500 text-white`}
>
<History className="h-3.5 w-3.5" />
</button>
{showHistoryMenu && (
<div className="absolute left-0 top-full mt-1 z-50 bg-gray-800 border border-white/10 rounded-lg shadow-xl py-1 min-w-[220px] max-h-[280px] overflow-y-auto">
{savedScripts.length === 0 ? (
<div className="px-3 py-3 text-xs text-gray-500 text-center"></div>
) : (
savedScripts.map((script) => (
<div
key={script.id}
className="flex items-center gap-1 px-3 py-1.5 hover:bg-white/10 transition-colors group"
>
<button
onClick={() => {
onLoadScript(script.content);
setShowHistoryMenu(false);
}}
className="flex-1 text-left min-w-0"
>
<div className="text-xs text-gray-200 truncate">{script.name}</div>
<div className="text-[10px] text-gray-500">{formatDate(script.savedAt)}</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteScript(script.id);
}}
className="opacity-40 group-hover:opacity-100 p-1 text-gray-500 hover:text-red-400 transition-all shrink-0"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))
)}
</div>
)}
</div>
<button
onClick={onOpenExtractModal}
className={`${actionBtnBase} bg-purple-600 hover:bg-purple-700 text-white`}
>
<FileText className="h-3.5 w-3.5" />
</button>
<div className="relative" ref={langMenuRef}>
<button
onClick={() => setShowLangMenu((prev) => !prev)}
disabled={isTranslating || !text.trim()}
className={`${actionBtnBase} ${
isTranslating || !text.trim()
? actionBtnDisabled
: "bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white"
}`}
>
{isTranslating ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
...
</>
) : (
<>
<Languages className="h-3.5 w-3.5" />
AI多语言
</>
)}
</button>
{showLangMenu && (
<div className="absolute right-0 top-full mt-1 z-50 bg-gray-800 border border-white/10 rounded-lg shadow-xl py-1 min-w-[160px]">
{hasOriginalText && (
<>
<button
onClick={() => { setShowLangMenu(false); onRestoreOriginal(); }}
className="w-full text-left px-3 py-1.5 text-xs text-amber-400 hover:bg-white/10 transition-colors flex items-center gap-1"
>
<RotateCcw className="h-3 w-3" />
</button>
<div className="border-t border-white/10 my-1" />
</>
)}
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => handleSelectLang(lang.code)}
className="w-full text-left px-3 py-1.5 text-xs text-gray-200 hover:bg-white/10 transition-colors"
>
{lang.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="relative">
<textarea
value={text}
onChange={(e) => onChangeText(e.target.value)}
placeholder="请输入你想说的话..."
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 pr-6 pb-6 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
/>
<button
type="button"
onClick={() => setIsExpandedEditorOpen(true)}
className="absolute right-0.5 bottom-2 h-5 w-5 text-gray-400/85 hover:text-white focus:outline-none transition-colors inline-flex items-center justify-center"
aria-label="扩展文案编辑器"
title="扩展编辑"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between mt-2 text-sm text-gray-400">
<span>{text.length} </span>
<div className="flex items-center gap-2">
<button
onClick={onOpenRewriteModal}
disabled={!text.trim()}
className={`${actionBtnBase} ${
!text.trim()
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-purple-600 hover:bg-purple-700 text-white"
}`}
>
<Sparkles className="h-3.5 w-3.5" />
AI智能改写
</button>
<button
onClick={onSaveScript}
disabled={!text.trim()}
className={`${actionBtnBase} ${
!text.trim()
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-amber-600 hover:bg-amber-700 text-white"
}`}
>
<Save className="h-3.5 w-3.5" />
</button>
</div>
</div>
<AppModal
isOpen={isExpandedEditorOpen}
onClose={handleCloseExpandedEditor}
panelClassName="w-full max-w-5xl max-h-[92vh] rounded-2xl border border-white/10 bg-[#171821]/95 shadow-[0_24px_80px_rgba(0,0,0,0.55)] overflow-hidden flex flex-col"
>
<AppModalHeader
title="扩展文案编辑"
subtitle="在更大空间里编写与调整文案"
onClose={handleCloseExpandedEditor}
actions={<span className="text-xs text-gray-400 tabular-nums">{text.length} </span>}
/>
<div className="flex-1 p-4 sm:p-5">
<textarea
value={text}
onChange={(e) => onChangeText(e.target.value)}
placeholder="请输入你想说的话..."
className="w-full h-[66vh] min-h-[320px] bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-white/25 transition-colors hide-scrollbar"
/>
</div>
</AppModal>
</div>
);
}