using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; using BaseGames.Localization; namespace BaseGames.Editor.Localization { /// /// 本地化 CSV 导入/导出工具。 /// /// 每个表导出为独立 CSV,列顺序:key, ChineseSimplified, English, Japanese, Korean。 /// 文件存放路径:Assets/_Game/Localization/Export/{TableName}.csv /// /// 导入:读取 CSV → 回写 Resources/Localization/{Language}/{TableName}.json /// /// 菜单:BaseGames / Localization / CSV 导入导出工具 /// public class LocalizationCsvTool : EditorWindow { [MenuItem("BaseGames/Localization/CSV 导入导出工具")] private static void Open() { var win = GetWindow("本地化 CSV 工具"); win.minSize = new Vector2(480, 360); } // ── 状态 ───────────────────────────────────────────────────────────── private static readonly Language[] s_allLanguages = (Language[])Enum.GetValues(typeof(Language)); private static readonly string[] s_allTables = { LocalizationTable.UI, LocalizationTable.Dialogue, LocalizationTable.Quest, LocalizationTable.Spells, LocalizationTable.Skills, LocalizationTable.Items, LocalizationTable.Character, LocalizationTable.Tutorial, }; private const string ExportDir = "Assets/_Game/Localization/Export"; private readonly bool[] _exportSelected = new bool[s_allTables.Length]; private readonly bool[] _importSelected = new bool[s_allTables.Length]; private string _statusMessage = ""; private MessageType _statusType = MessageType.None; private Vector2 _scroll; // ── GUI ─────────────────────────────────────────────────────────────── private void OnGUI() { _scroll = EditorGUILayout.BeginScrollView(_scroll); EditorGUILayout.Space(6); GUILayout.Label("📤 导出 → CSV", EditorStyles.boldLabel); EditorGUILayout.HelpBox( $"将 Resources/Localization/ 中的 JSON 表导出为 CSV 文件。\n" + $"目标目录:{ExportDir}/", MessageType.Info); EditorGUI.indentLevel++; for (int i = 0; i < s_allTables.Length; i++) _exportSelected[i] = EditorGUILayout.Toggle(s_allTables[i], _exportSelected[i]); EditorGUI.indentLevel--; EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("全选")) SetAll(_exportSelected, true); if (GUILayout.Button("全不选")) SetAll(_exportSelected, false); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(4); if (GUILayout.Button("▶ 导出选中表", GUILayout.Height(30))) RunExport(); EditorGUILayout.Space(16); EditorGUILayout.LabelField("", GUI.skin.horizontalSlider); GUILayout.Label("📥 导入 ← CSV", EditorStyles.boldLabel); EditorGUILayout.HelpBox( $"读取 {ExportDir}/ 中的 CSV,回写至 Resources/Localization/ JSON 文件。\n" + "已有 Key 覆盖,新增 Key 追加,不删除多余 Key。", MessageType.Info); EditorGUI.indentLevel++; for (int i = 0; i < s_allTables.Length; i++) _importSelected[i] = EditorGUILayout.Toggle(s_allTables[i], _importSelected[i]); EditorGUI.indentLevel--; EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("全选")) SetAll(_importSelected, true); if (GUILayout.Button("全不选")) SetAll(_importSelected, false); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(4); if (GUILayout.Button("▶ 导入选中表", GUILayout.Height(30))) RunImport(); EditorGUILayout.Space(8); if (!string.IsNullOrEmpty(_statusMessage)) EditorGUILayout.HelpBox(_statusMessage, _statusType); EditorGUILayout.Space(6); EditorGUILayout.EndScrollView(); } // ── 导出 ───────────────────────────────────────────────────────────── private void RunExport() { EnsureDirectory(ExportDir); int exported = 0; try { for (int ti = 0; ti < s_allTables.Length; ti++) { if (!_exportSelected[ti]) continue; string tableName = s_allTables[ti]; EditorUtility.DisplayProgressBar("导出 CSV", $"导出 {tableName}…", (float)ti / s_allTables.Length); // 收集所有语言的字典(以第一个语言的 Key 集合为主键集) var langDicts = new Dictionary>(); var allKeys = new SortedSet(StringComparer.Ordinal); foreach (var lang in s_allLanguages) { LocalizationManager.ClearEditorPreviewCache(); var dict = LocalizationManager.GetEditorTable(lang, tableName); if (dict != null) { langDicts[lang] = dict; foreach (var k in dict.Keys) allKeys.Add(k); } } if (allKeys.Count == 0) { Debug.LogWarning($"[CsvTool] 表「{tableName}」无数据,跳过导出。"); continue; } var sb = new StringBuilder(); // 表头 sb.Append("key"); foreach (var lang in s_allLanguages) sb.Append(',').Append(lang); sb.AppendLine(); // 数据行 foreach (var key in allKeys) { sb.Append(CsvEscape(key)); foreach (var lang in s_allLanguages) { string val = ""; if (langDicts.TryGetValue(lang, out var d)) d.TryGetValue(key, out val); sb.Append(',').Append(CsvEscape(val ?? "")); } sb.AppendLine(); } string csvPath = $"{ExportDir}/{tableName}.csv"; File.WriteAllText(csvPath, sb.ToString(), Encoding.UTF8); exported++; } } finally { EditorUtility.ClearProgressBar(); } AssetDatabase.Refresh(); SetStatus($"✅ 成功导出 {exported} 个表到 {ExportDir}/", MessageType.Info); } // ── 导入 ───────────────────────────────────────────────────────────── private void RunImport() { int imported = 0; int errors = 0; try { for (int ti = 0; ti < s_allTables.Length; ti++) { if (!_importSelected[ti]) continue; string tableName = s_allTables[ti]; string csvPath = Path.Combine(Path.GetDirectoryName(Application.dataPath)!, $"{ExportDir}/{tableName}.csv"); EditorUtility.DisplayProgressBar("导入 CSV", $"导入 {tableName}…", (float)ti / s_allTables.Length); if (!File.Exists(csvPath)) { Debug.LogWarning($"[CsvTool] CSV 文件不存在,跳过:{ExportDir}/{tableName}.csv"); continue; } try { var rows = ParseCsv(File.ReadAllText(csvPath, Encoding.UTF8)); if (rows.Count < 2) continue; // 表头行解析出列对应的语言 var header = rows[0]; var langCols = new List<(int col, Language lang)>(); for (int col = 1; col < header.Count; col++) { if (Enum.TryParse(header[col].Trim(), out var lang)) langCols.Add((col, lang)); } // 为每个语言准备合并后的字典 var mergedDicts = new Dictionary>(); foreach (var (_, lang) in langCols) { LocalizationManager.ClearEditorPreviewCache(); var existing = LocalizationManager.GetEditorTable(lang, tableName) ?? new Dictionary(StringComparer.Ordinal); mergedDicts[lang] = new Dictionary(existing, StringComparer.Ordinal); } // 用 CSV 数据覆盖/追加 for (int row = 1; row < rows.Count; row++) { var cells = rows[row]; if (cells.Count == 0 || string.IsNullOrWhiteSpace(cells[0])) continue; string key = cells[0]; foreach (var (col, lang) in langCols) { string val = col < cells.Count ? cells[col] : ""; mergedDicts[lang][key] = val; } } // 写回 JSON foreach (var (lang, dict) in mergedDicts) { string jsonPath = GetJsonPath(lang, tableName); EnsureDirectory(Path.GetDirectoryName(jsonPath)); File.WriteAllText(jsonPath, DictToJson(dict), Encoding.UTF8); } imported++; } catch (Exception ex) { Debug.LogError($"[CsvTool] 导入「{tableName}」失败:{ex.Message}"); errors++; } } } finally { EditorUtility.ClearProgressBar(); } AssetDatabase.Refresh(); LocalizationManager.ClearEditorPreviewCache(); string msg = errors == 0 ? $"✅ 成功导入 {imported} 个表。" : $"⚠ 导入 {imported} 个表,{errors} 个失败(见控制台)。"; SetStatus(msg, errors == 0 ? MessageType.Info : MessageType.Warning); } // ── 工具函数 ───────────────────────────────────────────────────────── /// 将 value 按 RFC 4180 规范包裹(含逗号/双引号/换行时用双引号包裹,内部双引号转义为 "")。 private static string CsvEscape(string value) { if (value == null) return ""; bool needsQuote = value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'); if (!needsQuote) return value; return "\"" + value.Replace("\"", "\"\"") + "\""; } /// 简单 CSV 解析器,支持 RFC 4180 带引号字段(含换行)。 private static List> ParseCsv(string text) { var rows = new List>(); var row = new List(); var cell = new StringBuilder(); bool inQuote = false; int i = 0; while (i < text.Length) { char c = text[i]; if (inQuote) { if (c == '"') { if (i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i += 2; } else { inQuote = false; i++; } } else { cell.Append(c); i++; } } else { if (c == '"') { inQuote = true; i++; } else if (c == ',') { row.Add(cell.ToString()); cell.Clear(); i++; } else if (c == '\r') { row.Add(cell.ToString()); cell.Clear(); rows.Add(row); row = new List(); if (i + 1 < text.Length && text[i + 1] == '\n') i++; i++; } else if (c == '\n') { row.Add(cell.ToString()); cell.Clear(); rows.Add(row); row = new List(); i++; } else { cell.Append(c); i++; } } } if (cell.Length > 0 || row.Count > 0) { row.Add(cell.ToString()); rows.Add(row); } return rows; } /// 将字符串字典序列化为最小 JSON 对象(UTF-8,适合直接写入 Resources JSON)。 private static string DictToJson(Dictionary dict) { var sb = new StringBuilder(); sb.AppendLine("{"); int written = 0; foreach (var kv in dict) { sb.Append(" ").Append(JsonString(kv.Key)).Append(": ").Append(JsonString(kv.Value)); if (++written < dict.Count) sb.Append(','); sb.AppendLine(); } sb.Append('}'); return sb.ToString(); } private static string JsonString(string s) { if (s == null) return "\"\""; s = s.Replace("\\", "\\\\") .Replace("\"", "\\\"") .Replace("\n", "\\n") .Replace("\r", "\\r") .Replace("\t", "\\t"); return $"\"{s}\""; } private static string GetJsonPath(Language lang, string tableName) => $"Assets/Resources/Localization/{lang}/{tableName}.json"; private static void EnsureDirectory(string dir) { if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); } private static void SetAll(bool[] arr, bool v) { for (int i = 0; i < arr.Length; i++) arr[i] = v; } private void SetStatus(string msg, MessageType type) { _statusMessage = msg; _statusType = type; Repaint(); } } }