I18n-Translate-It/src/pages/editor.tsx

753 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

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<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);
const [aiBulkOpen, setAiBulkOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(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<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),
});
// 同步可见语言集合
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<string>();
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<string, string> = {};
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<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} style={{ ...style, width: tableWidth }} />,
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} />,
}), [tableWidth]);
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]);
const MAX_AI_ITEMS = 50;
const headerContent = useCallback(() => (
<tr>
<th style={{ width: 36 }} className="sticky left-0 text-left px-3 py-2 bg-muted">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
const next = new Set<string>();
if (checked) {
for (const en of entries) next.add(en.path);
}
setSelected(next);
}}
/>
</th>
<th style={{ width: 300 }} className="text-left px-3 py-2"></th>
{displayedLanguages.map((lang) => (
<th key={lang} style={{ width: 200 }} className="text-left px-3 py-2">{lang}</th>
))}
<th style={{ width: 60 }} className="sticky right-0 text-left px-3 py-2 bg-muted"></th>
</tr>
), [displayedLanguages, allSelected, entries]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
const handleCopy = () => {
copy(entry.path);
toast.success("复制成功");
};
return (
<>
<td className="sticky left-0 px-3 py-2 bg-white">
<Checkbox
checked={selected.has(entry.path)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(entry.path); else next.delete(entry.path);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono wrap-break-word">
<button type="button" className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
{entry.path}
</button>
</td>
{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 (
<td key={`${entry.path}:${lang}`} className="px-3 py-2 text-foreground/90 align-top">
{isEditing ? (
<Textarea
ref={inline.inputRef}
value={inline.editingValue}
onChange={(e) => inline.setEditingValue(e.target.value)}
onBlur={inline.saveEdit}
onKeyDown={inline.handleKeyDown}
disabled={isSaving}
className="leading-normal px-2 py-0"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent"
onClick={() => inline.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-destructive"></span>}
</button>
)}
</td>
);
})}
<td className="sticky right-0 px-3 py-2 align-top bg-white">
<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>
</>
);
}, [displayedLanguages, inline, projectId, structure, setStructure, setValuesByLang, copy, selected]);
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]);
useEffect(() => {
return () => {
if (projectId) {
clearAllConnections(projectId);
}
};
}, [projectId]);
return (
<div className="h-screen flex flex-col">
<header className="h-14 px-2 md:px-4 from-blue-400 to-cyan-400 bg-linear-to-r text-white">
<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 text-foreground">
<HeaderConnectionIndicator projectId={projectId ?? ""} />
{!structure ? (
<Button onClick={() => { setImportOpen(true); }}></Button>
) : (
<Button variant="outline" onClick={() => { setImportOpen(true); }}>
<Reply />
</Button>
)}
{languages.length > 0 && (
<Button variant="outline" onClick={() => setExportOpen(true)}>
<Download />
</Button>
)}
{projectId && (
<Button
variant="outline"
onClick={handleSaveAllConnected}
disabled={savingAll || !structure || Object.keys(connSnap.connections).length === 0}
title={Object.keys(connSnap.connections).length === 0 ? "暂无已连接的语言" : "将所有已连接语言写入各自文件"}
>
<Save />
{savingAll ? "保存中..." : "一键保存"}
</Button>
)}
{project && (
<Button variant="outline" onClick={() => setSettingsOpen(true)}>
<Settings />
</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 className="h-full flex flex-col">
<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 type="submit">
<LocateFixed />
</Button>
</form>
<div className="mb-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" title="过滤显示的语言列">
<Filter />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => setVisibleLangs(new Set(languages))}>
</DropdownMenuItem>
<DropdownMenuSeparator />
{languages.map((lang) => {
const checked = visibleLangs.has(lang);
return (
<DropdownMenuCheckboxItem
key={lang}
onSelect={(e) => e.preventDefault()}
checked={checked}
onCheckedChange={(v) => {
setVisibleLangs((prev) => {
const next = new Set(prev);
if (v) next.add(lang); else next.delete(lang);
return next;
});
}}
>
{lang}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
disabled={selected.size === 0 || selected.size > MAX_AI_ITEMS}
onClick={() => setAiBulkOpen(true)}
title={selected.size > MAX_AI_ITEMS ? `最多支持 ${MAX_AI_ITEMS}` : "对所选条目进行 AI 翻译"}
>
<Languages />
AI {selected.size}
</Button>
{selected.size > MAX_AI_ITEMS && (
<span className="text-xs text-red-600"> {MAX_AI_ITEMS} </span>
)}
<Button
variant="outline-destructive"
disabled={selected.size === 0}
onClick={async () => {
if (!projectId || !structure) return;
if (selected.size === 0) return;
if (!confirm(`确认删除所选 ${selected.size} 个条目?此操作会移除所有语言下的这些键`)) return;
try {
let nextRoot = structure.root;
for (const p of selected) {
nextRoot = removeEntryAtPath(nextRoot, p);
}
await upsertStructure({ projectId, root: nextRoot });
// 从所有语言删除这些键
await Promise.all(Array.from(selected).map((p) => deleteEntryFromAllLanguages(projectId, p)));
setStructure({ projectId, root: nextRoot });
setValuesByLang((old) => {
const copy: typeof old = {};
for (const [langKey, vals] of Object.entries(old)) {
const next = { ...vals } as Record<string, string>;
for (const p of selected) delete next[p];
copy[langKey] = next;
}
return copy;
});
setSelected(new Set());
} catch (e) {
setPageError((e as Error)?.message ?? "批量删除失败");
}
}}
title="删除所选条目"
>
<Trash2 />
</Button>
</div>
<div className="flex-1 border rounded-md">
<TableVirtuoso
ref={virtuosoRef}
data={entries}
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}
languages={languages}
/>
<ExportLanguageModal
open={exportOpen}
onOpenChange={setExportOpen}
projectId={projectId ?? ""}
languages={languages}
valuesByLang={valuesByLang}
orderedPaths={entries.map((e) => e.path)}
/>
<ProjectSettingsModal
open={settingsOpen}
onOpenChange={setSettingsOpen}
project={project}
onSave={async (update) => {
if (!projectId) return;
const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences });
setProject(next);
}}
onDelete={async () => {
if (!projectId) return;
await deleteProjectDeep(projectId);
navigate("/");
}}
/>
<AiTranslateModal
open={!!aiModal?.open}
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
languages={languages}
paths={aiModal?.path ? [aiModal.path] : []}
initialSelectedLanguages={aiModal?.path ? computeSuggestedLanguages([aiModal.path]) : []}
getExistingByPath={getExistingByPath}
prompt={project?.preferences?.aiPrompt}
model={project?.preferences?.aiModel}
onConfirm={async (translations, options) => {
if (!projectId || !aiModal) return;
const updates: Record<string, Record<string, string>> = {};
try {
const targetLangs = options?.selectedLanguages ?? languages;
await Promise.all(
targetLangs.map(async (lang) => {
const prev = valuesByLang[lang] ?? {};
const next = { ...prev } as Record<string, string>;
const langMap = translations[lang] || {};
for (const [k, v] of Object.entries(langMap)) {
const existing = prev[k];
const isFilled = typeof existing === "string" && existing.trim() !== "";
if (!options?.overwrite && isFilled) continue; // 仅填充缺失
next[k] = v as string;
}
updates[lang] = next;
await upsertLanguageTranslations(projectId, lang, next);
})
);
setValuesByLang((old) => ({ ...old, ...updates }));
} catch (e) {
const msg = (e as Error)?.message ?? "保存翻译失败";
setPageError(msg);
throw e;
}
}}
/>
<AiTranslateModal
open={aiBulkOpen}
onOpenChange={setAiBulkOpen}
languages={languages}
paths={Array.from(selected)}
initialSelectedLanguages={computeSuggestedLanguages(Array.from(selected))}
getExistingByPath={getExistingByPath}
prompt={project?.preferences?.aiPrompt}
model={project?.preferences?.aiModel}
onConfirm={async (translations, options) => {
if (!projectId || selected.size === 0) return;
try {
const updatesByLang: Record<string, Record<string, string>> = {};
const targetLangs = options?.selectedLanguages ?? languages;
await Promise.all(
targetLangs.map(async (lang) => {
const prev = valuesByLang[lang] ?? {};
const next = { ...prev } as Record<string, string>;
const langMap = translations[lang] || {};
for (const [k, v] of Object.entries(langMap)) {
const existing = prev[k];
const isFilled = typeof existing === "string" && existing.trim() !== "";
if (!options?.overwrite && isFilled) continue; // 仅填充缺失
next[k] = v as string;
}
updatesByLang[lang] = next;
await upsertLanguageTranslations(projectId, lang, next);
})
);
setValuesByLang((old) => ({ ...old, ...updatesByLang }));
} catch (e) {
setPageError((e as Error)?.message ?? "批量 AI 翻译保存失败");
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);
console.log("nextRoot", addModal, nextRoot);
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>
);
}