Feat: 一键保存所有翻译文件
This commit is contained in:
parent
70c09558d1
commit
71424c8770
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue