import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type React from "react"; import { Link, useParams, useNavigate } from "react-router"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { getProject, getStructure, listLanguages, type Project, type ProjectStructure, getLanguageTranslations, upsertLanguageTranslations, upsertStructure, deleteEntryFromAllLanguages, renameEntryInAllLanguages, updateProject, deleteProjectDeep, } from "@/lib/db"; import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Download, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Reply, Save, Settings, Trash2 } from "lucide-react"; import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit"; import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure"; import { ImportLanguageModal } from "@/components/biz/import-language-modal"; import { ExportLanguageModal } from "@/components/biz/export-language-modal"; import { EntryNameModal } from "@/components/biz/entry-name-modal"; import { ProjectSettingsModal } from "@/components/biz/project-settings-modal"; import { AiTranslateModal } from "@/components/biz/ai-translate-modal"; import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { useClipboard } from "@/hooks/use-clipboard"; import { toast } from "sonner"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { clearAllConnections, useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection"; import { generateLanguageJson } from "@/lib/utils"; import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator"; export default function Editor() { const { id: projectId } = useParams(); const navigate = useNavigate(); const [project, setProject] = useState(null); const [structure, setStructure] = useState(null); const [languages, setLanguages] = useState([]); const [loading, setLoading] = useState(true); const [pageError, setPageError] = useState(null); const [importOpen, setImportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null); const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null); const virtuosoRef = useRef(null); const scrollerRootRef = useRef(null); const [query, setQuery] = useState(""); const [caseSensitive, setCaseSensitive] = useState(false); const [fullMatch, setFullMatch] = useState(false); const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null); const [aiBulkOpen, setAiBulkOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [selected, setSelected] = useState>(new Set()); const [visibleLangs, setVisibleLangs] = useState>(new Set()); const [savingAll, setSavingAll] = useState(false); const { copy } = useClipboard(); const connSnap = useFileConnections(projectId ?? ""); function highlightRow(index: number) { const tryFindAndAnimate = (attempt = 0) => { const root = scrollerRootRef.current as HTMLElement | null; if (!root) return; const row = root.querySelector(`tr[data-item-index="${index}"]`) as HTMLTableRowElement | null; if (row) { row.animate( [ { backgroundColor: "rgba(250, 204, 21, 0.6)" }, { backgroundColor: "transparent" }, { backgroundColor: "rgba(250, 204, 21, 0.6)" }, { backgroundColor: "transparent" }, ], { duration: 1200, easing: "ease-in-out" } ); return; } if (attempt < 10) { setTimeout(() => tryFindAndAnimate(attempt + 1), 50); } }; // 等一帧,确保滚动定位后的 DOM 稳定 requestAnimationFrame(() => tryFindAndAnimate(0)); } function scrollToQuery(ev: React.FormEvent) { ev.preventDefault(); ev.stopPropagation(); if (!query) return; const idx = entries.findIndex((e) => { const hay = caseSensitive ? e.path : e.path.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); return fullMatch ? hay === needle : hay.includes(needle); }); if (idx >= 0) { virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) }); } } async function refresh() { if (!projectId) return; setLoading(true); setPageError(null); try { const p = await getProject(projectId); if (!p) throw new Error("项目不存在"); setProject(p); const s = await getStructure(projectId); if (s) setStructure(s); const langs = await listLanguages(projectId); setLanguages(langs); } catch (e) { setPageError((e as Error)?.message ?? "加载失败"); } finally { setLoading(false); } } useEffect(() => { void refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); const entries: FlatEntry[] = useMemo(() => { if (!structure) return []; return flattenEntries(structure.root); }, [structure]); const [valuesByLang, setValuesByLang] = useState>>({}); const inline = useTranslationInlineEdit({ projectId, valuesByLang, setValuesByLang, onError: (m) => setPageError(m), }); // 同步可见语言集合 useEffect(() => { setVisibleLangs(new Set(languages)); }, [languages]); const displayedLanguages = useMemo(() => languages.filter((l) => visibleLangs.has(l)), [languages, visibleLangs]); const tableWidth = useMemo(() => (displayedLanguages.length * 200) + 36 + 60 + 300, [displayedLanguages.length]); const handleSaveAllConnected = useCallback(async () => { if (!projectId || !structure) return; const connectionEntries = Object.entries(connSnap.connections); if (connectionEntries.length === 0) { toast.info("没有已连接的语言"); return; } setSavingAll(true); try { const orderedPaths = flattenEntries(structure.root).map((e) => e.path); const results = await Promise.allSettled( connectionEntries.map(async ([lang]) => { const jsonText = generateLanguageJson(valuesByLang, lang, orderedPaths); const ok = await writeLanguageToConnectedFile(projectId, lang, jsonText); if (!ok) throw new Error(lang); return lang; }) ); const failed: string[] = []; let success = 0; for (const r of results) { if (r.status === "fulfilled") success += 1; else failed.push((r.reason as Error)?.message || "未知语言"); } if (failed.length === 0) { toast.success(`全部保存成功(${success})`); } else if (success === 0) { toast.error(`保存失败(${failed.length}):${failed.join(", ")}`); } else { toast.warning(`部分成功(成功 ${success},失败 ${failed.length}):${failed.join(", ")}`); } } finally { setSavingAll(false); } }, [projectId, structure, connSnap.connections, valuesByLang]); function computeSuggestedLanguages(pathsInput: string[]): string[] { if (languages.length === 0 || pathsInput.length === 0) return []; const missing = new Set(); for (const lang of languages) { const langMap = valuesByLang[lang] || {}; let hasMissing = false; for (const p of pathsInput) { const val = langMap[p]; if (typeof val !== "string" || val.trim() === "") { hasMissing = true; break; } } if (hasMissing) missing.add(lang); } const arr = Array.from(missing); return arr.length > 0 ? arr : languages; // 如无缺失则默认全选 } const getExistingByPath = useCallback((path: string) => { const result: Record = {}; for (const lang of languages) { const text = valuesByLang[lang]?.[path]; if (typeof text === "string" && text.trim() !== "") { result[lang] = text; } } return result; }, [languages, valuesByLang]); const virtuosoComponents = useMemo(() => ({ Table: ({ style, ...props }: React.TableHTMLAttributes) => , TableHead: (props: React.HTMLAttributes) => , TableRow: (props: React.HTMLAttributes) => , TableBody: (props: React.HTMLAttributes) => , TableFoot: (props: React.HTMLAttributes) => , }), [tableWidth]); const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]); const MAX_AI_ITEMS = 50; const headerContent = useCallback(() => ( {displayedLanguages.map((lang) => ( ))} ), [displayedLanguages, allSelected, entries]); const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { const handleCopy = () => { copy(entry.path); toast.success("复制成功"); }; return ( <> {displayedLanguages.map((lang) => { const isEditing = inline.isEditingCell(entry.path, lang); const isSaving = inline.isSavingCell(entry.path, lang); const displayValue = inline.getDisplayValue(entry.path, lang); return (
{ const next = new Set(); if (checked) { for (const en of entries) next.add(en.path); } setSelected(next); }} /> 翻译条目名称{lang}操作
{ setSelected((prev) => { const next = new Set(prev); if (checked) next.add(entry.path); else next.delete(entry.path); return next; }); }} /> {isEditing ? (