Feat: 一键保存所有翻译文件

This commit is contained in:
奇趣保罗 2025-11-26 17:58:13 +08:00
parent 70c09558d1
commit 71424c8770
3 changed files with 126 additions and 60 deletions

View File

@ -8,9 +8,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { unflattenValues } from "@/lib/i18n-structure";
import { useClipboard } from "@/hooks/use-clipboard";
import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection";
import { generateLanguageJson } from "@/lib/utils";
type Props = {
open: boolean;
@ -43,64 +43,16 @@ function ExportLanguageModalImpl({
const jsonText = useMemo(() => {
if (!selected) return "";
const flat = valuesByLang[selected] ?? {};
// 若提供结构顺序,则按结构顺序构建嵌套对象,保证键序与表格一致
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 "{}";
}
return generateLanguageJson(valuesByLang, selected, orderedPaths);
}, [selected, valuesByLang, orderedPaths]);
function handleDownload() {
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",
});
const url = URL.createObjectURL(blob);
@ -123,7 +75,8 @@ function ExportLanguageModalImpl({
const handleWriteToFile = async () => {
if (!selected || !jsonText) return;
await writeLanguageToConnectedFile(projectId, selected, jsonText);
const contentWithNewline = jsonText.endsWith("\n") ? jsonText : jsonText + "\n";
await writeLanguageToConnectedFile(projectId, selected, contentWithNewline);
};
return (

View File

@ -1,6 +1,71 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { unflattenValues } from "@/lib/i18n-structure";
export function cn(...inputs: ClassValue[]) {
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";
}
}

View File

@ -17,7 +17,7 @@ import {
updateProject,
deleteProjectDeep,
} 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 { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
@ -38,7 +38,8 @@ import { useClipboard } from "@/hooks/use-clipboard";
import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox";
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";
export default function Editor() {
@ -63,8 +64,10 @@ export default function Editor() {
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) => {
@ -152,6 +155,42 @@ export default function Editor() {
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 [];
@ -391,6 +430,17 @@ export default function Editor() {
</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 />
@ -700,5 +750,3 @@ export default function Editor() {
</div>
);
}