Feat: 一键保存所有翻译文件
This commit is contained in:
parent
70c09558d1
commit
71424c8770
|
|
@ -8,9 +8,9 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { unflattenValues } from "@/lib/i18n-structure";
|
|
||||||
import { useClipboard } from "@/hooks/use-clipboard";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection";
|
import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection";
|
||||||
|
import { generateLanguageJson } from "@/lib/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -43,64 +43,16 @@ function ExportLanguageModalImpl({
|
||||||
|
|
||||||
const jsonText = useMemo(() => {
|
const jsonText = useMemo(() => {
|
||||||
if (!selected) return "";
|
if (!selected) return "";
|
||||||
const flat = valuesByLang[selected] ?? {};
|
return generateLanguageJson(valuesByLang, selected, orderedPaths);
|
||||||
|
|
||||||
// 若提供结构顺序,则按结构顺序构建嵌套对象,保证键序与表格一致
|
|
||||||
if (orderedPaths && orderedPaths.length > 0) {
|
|
||||||
const root: Record<string, unknown> = {};
|
|
||||||
const added = new Set<string>();
|
|
||||||
const setByPath = (path: string, val: string) => {
|
|
||||||
const parts = path.split(".");
|
|
||||||
let cur: Record<string, unknown> = root;
|
|
||||||
for (let i = 0; i < parts.length; i += 1) {
|
|
||||||
const key = parts[i]!;
|
|
||||||
const isLeaf = i === parts.length - 1;
|
|
||||||
if (isLeaf) {
|
|
||||||
cur[key] = val;
|
|
||||||
} else {
|
|
||||||
const next = cur[key];
|
|
||||||
if (next && typeof next === "object" && !Array.isArray(next)) {
|
|
||||||
cur = next as Record<string, unknown>;
|
|
||||||
} else {
|
|
||||||
const obj: Record<string, unknown> = {};
|
|
||||||
cur[key] = obj;
|
|
||||||
cur = obj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const p of orderedPaths) {
|
|
||||||
const v = Object.prototype.hasOwnProperty.call(flat, p) ? flat[p] : "";
|
|
||||||
setByPath(p, v);
|
|
||||||
added.add(p);
|
|
||||||
}
|
|
||||||
// 追加结构外的剩余键,避免丢数据
|
|
||||||
for (const p of Object.keys(flat)) {
|
|
||||||
if (!added.has(p)) {
|
|
||||||
console.warn(`在结构图中未找到路径: ${p}`);
|
|
||||||
|
|
||||||
setByPath(p, flat[p]!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(root, null, 2);
|
|
||||||
} catch {
|
|
||||||
return "{}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回退:无结构顺序时使用普通反扁平
|
|
||||||
try {
|
|
||||||
return JSON.stringify(unflattenValues(flat), null, 2);
|
|
||||||
} catch {
|
|
||||||
return "{}";
|
|
||||||
}
|
|
||||||
}, [selected, valuesByLang, orderedPaths]);
|
}, [selected, valuesByLang, orderedPaths]);
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const blob = new Blob([jsonText || "{}"], {
|
|
||||||
|
const content = jsonText || "{}";
|
||||||
|
const contentWithNewline = content.endsWith("\n") ? content : content + "\n";
|
||||||
|
|
||||||
|
const blob = new Blob([contentWithNewline], {
|
||||||
type: "application/json;charset=utf-8",
|
type: "application/json;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
@ -123,7 +75,8 @@ function ExportLanguageModalImpl({
|
||||||
|
|
||||||
const handleWriteToFile = async () => {
|
const handleWriteToFile = async () => {
|
||||||
if (!selected || !jsonText) return;
|
if (!selected || !jsonText) return;
|
||||||
await writeLanguageToConnectedFile(projectId, selected, jsonText);
|
const contentWithNewline = jsonText.endsWith("\n") ? jsonText : jsonText + "\n";
|
||||||
|
await writeLanguageToConnectedFile(projectId, selected, contentWithNewline);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,71 @@
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { unflattenValues } from "@/lib/i18n-structure";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据语言与可选的结构顺序生成 JSON 字符串,带 2 空格缩进,并保证以单个换行符结尾。
|
||||||
|
*/
|
||||||
|
export function generateLanguageJson(
|
||||||
|
valuesByLang: Record<string, Record<string, string>>,
|
||||||
|
lang: string,
|
||||||
|
orderedPaths?: string[]
|
||||||
|
): string {
|
||||||
|
if (!lang) return "";
|
||||||
|
const flat = valuesByLang[lang] ?? {};
|
||||||
|
|
||||||
|
// 优先:按结构顺序构建嵌套对象,追加其余键,保证键序与表格一致
|
||||||
|
if (orderedPaths && orderedPaths.length > 0) {
|
||||||
|
const root: Record<string, unknown> = {};
|
||||||
|
const added = new Set<string>();
|
||||||
|
const setByPath = (path: string, val: string) => {
|
||||||
|
const parts = path.split(".");
|
||||||
|
let cur: Record<string, unknown> = root;
|
||||||
|
for (let i = 0; i < parts.length; i += 1) {
|
||||||
|
const key = parts[i]!;
|
||||||
|
const isLeaf = i === parts.length - 1;
|
||||||
|
if (isLeaf) {
|
||||||
|
cur[key] = val;
|
||||||
|
} else {
|
||||||
|
const next = cur[key];
|
||||||
|
if (next && typeof next === "object" && !Array.isArray(next)) {
|
||||||
|
cur = next as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
cur[key] = obj;
|
||||||
|
cur = obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const p of orderedPaths) {
|
||||||
|
const v = Object.prototype.hasOwnProperty.call(flat, p) ? flat[p] : "";
|
||||||
|
setByPath(p, v);
|
||||||
|
added.add(p);
|
||||||
|
}
|
||||||
|
for (const p of Object.keys(flat)) {
|
||||||
|
if (!added.has(p)) {
|
||||||
|
// 在结构图中未找到路径时也要补上,避免丢数据
|
||||||
|
setByPath(p, flat[p]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = JSON.stringify(root, null, 2);
|
||||||
|
return text.endsWith("\n") ? text : text + "\n";
|
||||||
|
} catch {
|
||||||
|
return "{}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:无结构顺序时使用普通反扁平
|
||||||
|
try {
|
||||||
|
const text = JSON.stringify(unflattenValues(flat), null, 2);
|
||||||
|
return text.endsWith("\n") ? text : text + "\n";
|
||||||
|
} catch {
|
||||||
|
return "{}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
updateProject,
|
updateProject,
|
||||||
deleteProjectDeep,
|
deleteProjectDeep,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Download, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Reply, Settings, Trash2 } from "lucide-react";
|
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 { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
||||||
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
|
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
|
||||||
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
||||||
|
|
@ -38,7 +38,8 @@ import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { clearAllConnections } from "@/store/file-connection";
|
import { clearAllConnections, useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection";
|
||||||
|
import { generateLanguageJson } from "@/lib/utils";
|
||||||
import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
|
import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
|
|
@ -63,8 +64,10 @@ export default function Editor() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set());
|
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set());
|
||||||
|
const [savingAll, setSavingAll] = useState(false);
|
||||||
|
|
||||||
const { copy } = useClipboard();
|
const { copy } = useClipboard();
|
||||||
|
const connSnap = useFileConnections(projectId ?? "");
|
||||||
|
|
||||||
function highlightRow(index: number) {
|
function highlightRow(index: number) {
|
||||||
const tryFindAndAnimate = (attempt = 0) => {
|
const tryFindAndAnimate = (attempt = 0) => {
|
||||||
|
|
@ -152,6 +155,42 @@ export default function Editor() {
|
||||||
|
|
||||||
const tableWidth = useMemo(() => (displayedLanguages.length * 200) + 36 + 60 + 300, [displayedLanguages.length]);
|
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[] {
|
function computeSuggestedLanguages(pathsInput: string[]): string[] {
|
||||||
if (languages.length === 0 || pathsInput.length === 0) return [];
|
if (languages.length === 0 || pathsInput.length === 0) return [];
|
||||||
|
|
||||||
|
|
@ -391,6 +430,17 @@ export default function Editor() {
|
||||||
导出
|
导出
|
||||||
</Button>
|
</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 && (
|
{project && (
|
||||||
<Button variant="outline" onClick={() => setSettingsOpen(true)}>
|
<Button variant="outline" onClick={() => setSettingsOpen(true)}>
|
||||||
<Settings />
|
<Settings />
|
||||||
|
|
@ -700,5 +750,3 @@ export default function Editor() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue