Files
zeling_v2/Assets/_Game/Scripts/Editor/Localization/LocalizationCsvTool.cs
2026-05-25 11:54:37 +08:00

389 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using BaseGames.Localization;
namespace BaseGames.Editor.Localization
{
/// <summary>
/// 本地化 CSV 导入/导出工具。
///
/// 每个表导出为独立 CSV列顺序key, ChineseSimplified, English, Japanese, Korean。
/// 文件存放路径Assets/_Game/Localization/Export/{TableName}.csv
///
/// 导入:读取 CSV → 回写 Resources/Localization/{Language}/{TableName}.json
///
/// 菜单BaseGames / Localization / CSV 导入导出工具
/// </summary>
public class LocalizationCsvTool : EditorWindow
{
[MenuItem("BaseGames/Localization/CSV 导入导出工具")]
private static void Open()
{
var win = GetWindow<LocalizationCsvTool>("本地化 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<Language, Dictionary<string, string>>();
var allKeys = new SortedSet<string>(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<Language>(header[col].Trim(), out var lang))
langCols.Add((col, lang));
}
// 为每个语言准备合并后的字典
var mergedDicts = new Dictionary<Language, Dictionary<string, string>>();
foreach (var (_, lang) in langCols)
{
LocalizationManager.ClearEditorPreviewCache();
var existing = LocalizationManager.GetEditorTable(lang, tableName)
?? new Dictionary<string, string>(StringComparer.Ordinal);
mergedDicts[lang] = new Dictionary<string, string>(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);
}
// ── 工具函数 ─────────────────────────────────────────────────────────
/// <summary>将 value 按 RFC 4180 规范包裹(含逗号/双引号/换行时用双引号包裹,内部双引号转义为 "")。</summary>
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("\"", "\"\"") + "\"";
}
/// <summary>简单 CSV 解析器,支持 RFC 4180 带引号字段(含换行)。</summary>
private static List<List<string>> ParseCsv(string text)
{
var rows = new List<List<string>>();
var row = new List<string>();
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<string>();
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<string>(); i++; }
else
{ cell.Append(c); i++; }
}
}
if (cell.Length > 0 || row.Count > 0)
{
row.Add(cell.ToString());
rows.Add(row);
}
return rows;
}
/// <summary>将字符串字典序列化为最小 JSON 对象UTF-8适合直接写入 Resources JSON。</summary>
private static string DictToJson(Dictionary<string, string> 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();
}
}
}