import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type React from "react"; import { Link, useParams } 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, } from "@/lib/db"; import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Languages, LocateFixed, MoreVertical, PencilLine, 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 { AiTranslateModal } from "@/components/biz/ai-translate-modal"; import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; export default function Editor() { const { id: projectId } = useParams(); 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); 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), }); const colWidth = useMemo(() => `${100 / (languages.length + 2)}%`, [languages.length]); const virtuosoComponents = useMemo(() => ({ Table: (props: React.TableHTMLAttributes) => , TableHead: (props: React.HTMLAttributes) => , TableRow: (props: React.HTMLAttributes) => , TableBody: (props: React.HTMLAttributes) => , TableFoot: (props: React.HTMLAttributes) => , }), []); const headerContent = useCallback(() => ( {languages.map((lang) => ( ))} ), [languages, colWidth]); const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { return ( <> {languages.map((lang) => { const isEditing = inline.isEditingCell(entry.path, lang); const isSaving = inline.isSavingCell(entry.path, lang); const displayValue = inline.getDisplayValue(entry.path, lang); return ( ); })} ); }, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]); useEffect(() => { if (!projectId || languages.length === 0) return; let cancelled = false; (async () => { const all: Record> = {}; for (const lang of languages) { const rec = await getLanguageTranslations(projectId, lang); all[lang] = rec?.values ?? {}; } if (!cancelled) setValuesByLang(all); })(); return () => { cancelled = true; }; }, [projectId, languages]); return (
{project?.name ?? "编辑器"}
{!structure ? ( ) : ( )} {languages.length > 0 && ( )}
{pageError && (
{pageError}
)} {loading ? (
加载中...
) : !structure ? (
该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
) : (
setQuery(e.target.value)} />
entry.path} scrollerRef={(el) => { scrollerRootRef.current = el; }} />
)}
e.path)} /> setAiModal((cur) => (cur ? { ...cur, open: v } : cur))} languages={languages} path={aiModal?.path ?? ""} onConfirm={async (translations) => { if (!projectId || !aiModal) return; const targetPath = aiModal.path; const updates: Record> = {}; try { await Promise.all( languages.map(async (lang) => { const prev = valuesByLang[lang] ?? {}; const next = { ...prev, [targetPath]: translations[lang] }; updates[lang] = next; await upsertLanguageTranslations(projectId, lang, next); }) ); setValuesByLang((old) => ({ ...old, ...updates })); } catch (e) { const msg = (e as Error)?.message ?? "保存翻译失败"; setPageError(msg); throw e; } }} /> setAddModal((cur) => (cur ? { ...cur, open: v } : cur))} title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"} placeholder="请输入条目名称(不含点)" onConfirm={async (name) => { if (!projectId || !structure || !addModal) return; const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name); await upsertStructure({ projectId, root: nextRoot }); setStructure({ projectId, root: nextRoot }); }} validate={(name) => { // 同级重名校验在结构函数中也会做,这里做基础提示即可 if (name.includes('.')) return "名称不能包含 '.'"; return null; }} /> setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))} title="重命名条目" placeholder="请输入新名称(不含点)" defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''} onConfirm={async (newName) => { if (!projectId || !structure || !renameModal) return; const { root: nextRoot, newPath } = renameEntryAtPath(structure.root, renameModal.path, newName); await upsertStructure({ projectId, root: nextRoot }); await renameEntryInAllLanguages(projectId, renameModal.path, newPath); setStructure({ projectId, root: nextRoot }); setValuesByLang((old) => { const copy: typeof old = {}; for (const [langKey, vals] of Object.entries(old)) { if (Object.prototype.hasOwnProperty.call(vals, renameModal.path)) { const { [renameModal.path]: val, ...rest } = vals; copy[langKey] = { ...rest, [newPath]: val }; } else { copy[langKey] = vals; } } return copy; }); }} validate={(name) => { if (name.includes('.')) return "名称不能包含 '.'"; return null; }} />
); }
翻译条目名称{lang}操作
{entry.path} {isEditing ? ( inline.setEditingValue(e.target.value)} onBlur={inline.saveEdit} onKeyDown={inline.handleKeyDown} disabled={isSaving} className="h-8" /> ) : ( )} setAddModal({ open: true, path: entry.path, position: "below" })}> 在下面新增 setAddModal({ open: true, path: entry.path, position: "above" })}> 在上面新增 setAiModal({ open: true, path: entry.path })}> AI 翻译 setRenameModal({ open: true, path: entry.path })}> 重命名 { if (!projectId || !structure) return; if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return; try { const nextRoot = removeEntryAtPath(structure.root, entry.path); await upsertStructure({ projectId, root: nextRoot }); await deleteEntryFromAllLanguages(projectId, entry.path); setStructure({ projectId, root: nextRoot }); setValuesByLang((old) => { const copy: typeof old = {}; for (const [langKey, vals] of Object.entries(old)) { const rest = { ...vals } as Record; delete rest[entry.path]; copy[langKey] = rest; } return copy; }); } catch (e) { setPageError((e as Error)?.message ?? "删除失败"); } }} > 删除