diff --git a/src/components/biz/export-language-modal.tsx b/src/components/biz/export-language-modal.tsx index b0b7e1e..7bb88c3 100644 --- a/src/components/biz/export-language-modal.tsx +++ b/src/components/biz/export-language-modal.tsx @@ -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 = {}; - const added = new Set(); - const setByPath = (path: string, val: string) => { - const parts = path.split("."); - let cur: Record = 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; - } else { - const obj: Record = {}; - 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 ( diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a5ef193..d2d8950 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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>, + lang: string, + orderedPaths?: string[] +): string { + if (!lang) return ""; + const flat = valuesByLang[lang] ?? {}; + + // 优先:按结构顺序构建嵌套对象,追加其余键,保证键序与表格一致 + if (orderedPaths && orderedPaths.length > 0) { + const root: Record = {}; + const added = new Set(); + const setByPath = (path: string, val: string) => { + const parts = path.split("."); + let cur: Record = 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; + } else { + const obj: Record = {}; + 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"; + } +} diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index 229ae7a..01758f2 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -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>(new Set()); const [visibleLangs, setVisibleLangs] = useState>(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() { 导出 )} + {projectId && ( + + )} {project && (