438 lines
17 KiB
TypeScript
438 lines
17 KiB
TypeScript
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<Project | null>(null);
|
||
const [structure, setStructure] = useState<ProjectStructure | null>(null);
|
||
const [languages, setLanguages] = useState<string[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [pageError, setPageError] = useState<string | null>(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<TableVirtuosoHandle | null>(null);
|
||
const scrollerRootRef = useRef<HTMLElement | Window | null>(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<HTMLFormElement>) {
|
||
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<Record<string, Record<string, string>>>({});
|
||
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<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} />,
|
||
TableHead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead className="bg-muted" {...props} />,
|
||
TableRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr className="border-t" {...props} />,
|
||
TableBody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />,
|
||
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
|
||
}), []);
|
||
|
||
const headerContent = useCallback(() => (
|
||
<tr>
|
||
<th style={{ width: colWidth }} className="text-left px-3 py-2">翻译条目名称</th>
|
||
{languages.map((lang) => (
|
||
<th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
|
||
))}
|
||
<th style={{ width: "5em" }} className="text-left px-3 py-2">操作</th>
|
||
</tr>
|
||
), [languages, colWidth]);
|
||
|
||
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
|
||
return (
|
||
<>
|
||
<td style={{ width: colWidth }} className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
|
||
{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 (
|
||
<td key={`${entry.path}:${lang}`} style={{ width: colWidth }} className="px-3 py-2 text-foreground/90 align-top">
|
||
{isEditing ? (
|
||
<Input
|
||
ref={inline.inputRef}
|
||
value={inline.editingValue}
|
||
onChange={(e) => inline.setEditingValue(e.target.value)}
|
||
onBlur={inline.saveEdit}
|
||
onKeyDown={inline.handleKeyDown}
|
||
disabled={isSaving}
|
||
className="h-8"
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
|
||
onClick={() => inline.startEdit(entry.path, lang)}
|
||
title="点击编辑"
|
||
>
|
||
{displayValue || <span className="text-muted-foreground">点击编辑</span>}
|
||
</button>
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
<td style={{ width: "5em" }} className="px-3 py-2 align-top">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button size="sm" variant="outline">
|
||
操作
|
||
<MoreVertical />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-44">
|
||
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
|
||
<ArrowBigDownDash />
|
||
在下面新增
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
|
||
<ArrowBigUpDash />
|
||
在上面新增
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onClick={() => setAiModal({ open: true, path: entry.path })}>
|
||
<Languages />
|
||
AI 翻译
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
|
||
<PencilLine />
|
||
重命名
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onClick={async () => {
|
||
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<string, string>;
|
||
delete rest[entry.path];
|
||
copy[langKey] = rest;
|
||
}
|
||
return copy;
|
||
});
|
||
} catch (e) {
|
||
setPageError((e as Error)?.message ?? "删除失败");
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</td>
|
||
</>
|
||
);
|
||
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]);
|
||
|
||
useEffect(() => {
|
||
if (!projectId || languages.length === 0) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
const all: Record<string, Record<string, string>> = {};
|
||
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 (
|
||
<div className="h-screen flex flex-col">
|
||
<header className="h-14 border-b px-2 md:px-4">
|
||
<div className="grid grid-cols-3 h-full items-center">
|
||
<div className="flex items-center gap-2">
|
||
<Button asChild size="icon" variant="ghost" aria-label="返回">
|
||
<Link to="/">
|
||
<ArrowLeft className="size-5" />
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
<div className="text-center font-medium truncate">
|
||
{project?.name ?? "编辑器"}
|
||
</div>
|
||
<div className="flex items-center justify-end gap-2">
|
||
{!structure ? (
|
||
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
|
||
) : (
|
||
<Button variant="outline" onClick={() => { setImportOpen(true); }}>导入语言 JSON</Button>
|
||
)}
|
||
{languages.length > 0 && (
|
||
<Button variant="outline" onClick={() => setExportOpen(true)}>导出 JSON</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="flex-1 overflow-auto p-4 md:p-6">
|
||
{pageError && (
|
||
<div className="mb-4 text-sm text-red-600" role="alert">{pageError}</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="text-sm text-muted-foreground">加载中...</div>
|
||
) : !structure ? (
|
||
<div className="rounded-md border border-dashed p-6">
|
||
<div className="text-sm text-muted-foreground">
|
||
该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
|
||
</div>
|
||
<div className="mt-3">
|
||
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<form className="mb-3 flex items-center gap-2" onSubmit={scrollToQuery}>
|
||
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||
<Button
|
||
variant={fullMatch ? "default" : "outline"}
|
||
onClick={() => setFullMatch((v) => !v)}
|
||
title="切换全量匹配/模糊匹配"
|
||
>
|
||
<Brackets />
|
||
{fullMatch ? "全量匹配" : "模糊匹配"}
|
||
</Button>
|
||
<Button
|
||
variant={caseSensitive ? "default" : "outline"}
|
||
onClick={() => setCaseSensitive((v) => !v)}
|
||
title="切换大小写敏感"
|
||
>
|
||
<CaseSensitive />
|
||
{caseSensitive ? "区分大小写" : "忽略大小写"}
|
||
</Button>
|
||
<Button variant="outline" type="submit">
|
||
<LocateFixed />
|
||
定位
|
||
</Button>
|
||
</form>
|
||
<div className="border rounded-md">
|
||
<TableVirtuoso
|
||
ref={virtuosoRef}
|
||
data={entries}
|
||
style={{ height: "60vh" }}
|
||
fixedHeaderContent={headerContent}
|
||
itemContent={renderItemContent}
|
||
components={virtuosoComponents}
|
||
computeItemKey={(_index, entry) => entry.path}
|
||
scrollerRef={(el) => { scrollerRootRef.current = el; }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
<ImportLanguageModal
|
||
open={importOpen}
|
||
onOpenChange={setImportOpen}
|
||
projectId={projectId ?? ""}
|
||
hasStructure={!!structure}
|
||
onImported={refresh}
|
||
/>
|
||
<ExportLanguageModal
|
||
open={exportOpen}
|
||
onOpenChange={setExportOpen}
|
||
languages={languages}
|
||
valuesByLang={valuesByLang}
|
||
orderedPaths={entries.map((e) => e.path)}
|
||
/>
|
||
|
||
<AiTranslateModal
|
||
open={!!aiModal?.open}
|
||
onOpenChange={(v) => 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<string, Record<string, string>> = {};
|
||
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;
|
||
}
|
||
}}
|
||
/>
|
||
|
||
<EntryNameModal
|
||
open={!!addModal?.open}
|
||
onOpenChange={(v) => 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;
|
||
}}
|
||
/>
|
||
|
||
<EntryNameModal
|
||
open={!!renameModal?.open}
|
||
onOpenChange={(v) => 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;
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|