UI系统组件
This commit is contained in:
205
Assets/_Game/Scripts/Editor/Localization/LocalizationCsv.cs
Normal file
205
Assets/_Game/Scripts/Editor/Localization/LocalizationCsv.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
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(Excel 往返)读写共享工具。
|
||||
///
|
||||
/// CSV 约定:
|
||||
/// - 列顺序:<c>key, ChineseSimplified, English, Japanese, Korean</c>(表头用 <see cref="Language"/> 枚举名)。
|
||||
/// - 以 <b>UTF-8 BOM</b> 写出,使 Excel 双击打开时中日韩文不乱码。
|
||||
/// - 解析遵循 RFC 4180(带引号字段可含逗号 / 换行 / 双引号转义),并自动剥离前导 BOM。
|
||||
/// - 导入语义:合并(覆盖已有 key、追加新 key、不删除多余 key)。
|
||||
/// </summary>
|
||||
public static class LocalizationCsv
|
||||
{
|
||||
/// <summary>导出列顺序(与导入表头解析共用)。</summary>
|
||||
public static readonly Language[] Columns = LocalizationFileIO.AllLanguages;
|
||||
|
||||
// 带 BOM 的 UTF-8(Excel 友好)
|
||||
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
|
||||
|
||||
// ── 高层:表 ⇄ CSV 文件 ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 将给定的"语言→字典"导出为该表的 CSV 文件(含 BOM)。返回写出的资产路径。
|
||||
/// </summary>
|
||||
public static string ExportToFile(string table,
|
||||
IReadOnlyDictionary<Language, Dictionary<string, string>> langDicts)
|
||||
{
|
||||
// 汇总所有语言出现过的 key(排序,保证稳定 diff)
|
||||
var allKeys = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var lang in Columns)
|
||||
if (langDicts.TryGetValue(lang, out var d) && d != null)
|
||||
foreach (var k in d.Keys) allKeys.Add(k);
|
||||
|
||||
string csvText = BuildCsvText(allKeys, langDicts);
|
||||
|
||||
EditorScaffoldUtils.EnsureFolder(LocalizationPaths.ExportRoot);
|
||||
string assetPath = LocalizationPaths.CsvPath(table);
|
||||
File.WriteAllText(ToFullPath(assetPath), csvText, Utf8Bom);
|
||||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
||||
return assetPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取该表的 CSV 文件并解析为"语言→字典"(仅含表头出现的语言列)。
|
||||
/// CSV 文件不存在时返回 null。
|
||||
/// </summary>
|
||||
public static Dictionary<Language, Dictionary<string, string>> ParseFile(string table)
|
||||
{
|
||||
string assetPath = LocalizationPaths.CsvPath(table);
|
||||
string fullPath = ToFullPath(assetPath);
|
||||
if (!File.Exists(fullPath)) return null;
|
||||
|
||||
return ParseText(File.ReadAllText(fullPath, Encoding.UTF8));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将该表的 CSV 合并写回磁盘 JSON(供批处理工具用):
|
||||
/// 对 CSV 表头出现的每个语言,读现有 JSON → 合并 CSV → <see cref="LocalizationFileIO.Write"/>。
|
||||
/// 返回受影响的语言数;CSV 不存在时返回 0。
|
||||
/// </summary>
|
||||
public static int ImportFileToJson(string table)
|
||||
{
|
||||
var parsed = ParseFile(table);
|
||||
if (parsed == null) return 0;
|
||||
|
||||
foreach (var (lang, csvDict) in parsed)
|
||||
{
|
||||
var merged = LocalizationFileIO.Read(lang, table); // 现有 JSON 副本
|
||||
foreach (var kv in csvDict) merged[kv.Key] = kv.Value;
|
||||
LocalizationFileIO.Write(lang, table, merged);
|
||||
}
|
||||
return parsed.Count;
|
||||
}
|
||||
|
||||
// ── 低层:文本 ⇄ 行 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>构造 CSV 文本(表头 + 数据行,按 allKeys 顺序)。</summary>
|
||||
public static string BuildCsvText(IEnumerable<string> orderedKeys,
|
||||
IReadOnlyDictionary<Language, Dictionary<string, string>> langDicts)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append("key");
|
||||
foreach (var lang in Columns) sb.Append(',').Append(lang);
|
||||
sb.Append('\n');
|
||||
|
||||
foreach (var key in orderedKeys)
|
||||
{
|
||||
sb.Append(Escape(key));
|
||||
foreach (var lang in Columns)
|
||||
{
|
||||
string val = "";
|
||||
if (langDicts.TryGetValue(lang, out var d) && d != null)
|
||||
d.TryGetValue(key, out val);
|
||||
sb.Append(',').Append(Escape(val ?? ""));
|
||||
}
|
||||
sb.Append('\n');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>解析 CSV 文本为"语言→字典"(仅表头识别出的 Language 列)。</summary>
|
||||
public static Dictionary<Language, Dictionary<string, string>> ParseText(string text)
|
||||
{
|
||||
var rows = ParseRows(text);
|
||||
var result = new Dictionary<Language, Dictionary<string, string>>();
|
||||
if (rows.Count < 2) return result;
|
||||
|
||||
// 表头:列号 → Language
|
||||
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));
|
||||
result[lang] = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
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)
|
||||
result[lang][key] = col < cells.Count ? cells[col] : "";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>按 RFC 4180 规范转义单元格(含逗号 / 引号 / 换行时用双引号包裹)。</summary>
|
||||
public static string Escape(string value)
|
||||
{
|
||||
if (value == null) return "";
|
||||
bool needsQuote = value.IndexOf(',') >= 0 || value.IndexOf('"') >= 0
|
||||
|| value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0;
|
||||
if (!needsQuote) return value;
|
||||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
/// <summary>RFC 4180 CSV 解析器,支持带引号字段(含换行),并剥离前导 UTF-8 BOM。</summary>
|
||||
public static List<List<string>> ParseRows(string text)
|
||||
{
|
||||
var rows = new List<List<string>>();
|
||||
if (string.IsNullOrEmpty(text)) return rows;
|
||||
|
||||
// 剥离前导 BOM(File.ReadAllText 以 UTF8 读时若文件带 BOM 会保留为 U+FEFF)
|
||||
if (text[0] == '') text = text.Substring(1);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static string ToFullPath(string assetPath)
|
||||
{
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath)!;
|
||||
return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd6037390f5ea7b4b93dd91283193c9a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,7 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Localization;
|
||||
@@ -11,11 +9,12 @@ namespace BaseGames.Editor.Localization
|
||||
/// <summary>
|
||||
/// 本地化 CSV 导入/导出工具。
|
||||
///
|
||||
/// 每个表导出为独立 CSV,列顺序:key, ChineseSimplified, English, Japanese, Korean。
|
||||
/// 每个表导出为独立 CSV(含 UTF-8 BOM,Excel 中文不乱码),列顺序:key, ChineseSimplified, English, Japanese, Korean。
|
||||
/// 文件存放路径:Assets/_Game/Localization/Export/{TableName}.csv
|
||||
///
|
||||
/// 导入:读取 CSV → 回写 Resources/Localization/{Language}/{TableName}.json
|
||||
/// 导入:读取 CSV → 合并回写 Assets/_Game/Data/Localization/{Language}/{TableName}.json(经 LocalizationFileIO)
|
||||
///
|
||||
/// 注:日常单表编辑请用「BaseGames / Localization / 表格编辑器」,此窗口用于批量多表导入导出。
|
||||
/// 菜单:BaseGames / Localization / CSV 导入导出工具
|
||||
/// </summary>
|
||||
public class LocalizationCsvTool : EditorWindow
|
||||
@@ -29,9 +28,6 @@ namespace BaseGames.Editor.Localization
|
||||
|
||||
// ── 状态 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly Language[] s_allLanguages =
|
||||
(Language[])Enum.GetValues(typeof(Language));
|
||||
|
||||
private static readonly string[] s_allTables =
|
||||
{
|
||||
LocalizationTable.UI,
|
||||
@@ -45,6 +41,7 @@ namespace BaseGames.Editor.Localization
|
||||
};
|
||||
|
||||
private const string ExportDir = "Assets/_Game/Localization/Export";
|
||||
private const string DataDir = LocalizationPaths.DataRoot;
|
||||
|
||||
private readonly bool[] _exportSelected = new bool[s_allTables.Length];
|
||||
private readonly bool[] _importSelected = new bool[s_allTables.Length];
|
||||
@@ -61,7 +58,7 @@ namespace BaseGames.Editor.Localization
|
||||
EditorGUILayout.Space(6);
|
||||
GUILayout.Label("📤 导出 → CSV", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
$"将 Resources/Localization/ 中的 JSON 表导出为 CSV 文件。\n" +
|
||||
$"将 {DataDir}/ 中的 JSON 表导出为 CSV 文件(UTF-8 BOM,Excel 可直接打开)。\n" +
|
||||
$"目标目录:{ExportDir}/",
|
||||
MessageType.Info);
|
||||
|
||||
@@ -84,7 +81,7 @@ namespace BaseGames.Editor.Localization
|
||||
|
||||
GUILayout.Label("📥 导入 ← CSV", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
$"读取 {ExportDir}/ 中的 CSV,回写至 Resources/Localization/ JSON 文件。\n" +
|
||||
$"读取 {ExportDir}/ 中的 CSV,合并回写至 {DataDir}/ JSON 文件。\n" +
|
||||
"已有 Key 覆盖,新增 Key 追加,不删除多余 Key。",
|
||||
MessageType.Info);
|
||||
|
||||
@@ -114,7 +111,7 @@ namespace BaseGames.Editor.Localization
|
||||
|
||||
private void RunExport()
|
||||
{
|
||||
EnsureDirectory(ExportDir);
|
||||
LocalizationManager.ClearEditorPreviewCache();
|
||||
int exported = 0;
|
||||
|
||||
try
|
||||
@@ -126,50 +123,23 @@ namespace BaseGames.Editor.Localization
|
||||
|
||||
EditorUtility.DisplayProgressBar("导出 CSV", $"导出 {tableName}…", (float)ti / s_allTables.Length);
|
||||
|
||||
// 收集所有语言的字典(以第一个语言的 Key 集合为主键集)
|
||||
// 经统一门面读取各语言字典(正确路径),交给 LocalizationCsv 写出(含 BOM)
|
||||
var langDicts = new Dictionary<Language, Dictionary<string, string>>();
|
||||
var allKeys = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var lang in s_allLanguages)
|
||||
bool any = false;
|
||||
foreach (var lang in LocalizationFileIO.AllLanguages)
|
||||
{
|
||||
LocalizationManager.ClearEditorPreviewCache();
|
||||
var dict = LocalizationManager.GetEditorTable(lang, tableName);
|
||||
if (dict != null)
|
||||
{
|
||||
langDicts[lang] = dict;
|
||||
foreach (var k in dict.Keys) allKeys.Add(k);
|
||||
}
|
||||
var dict = LocalizationFileIO.Read(lang, tableName);
|
||||
langDicts[lang] = dict;
|
||||
if (dict.Count > 0) any = true;
|
||||
}
|
||||
|
||||
if (allKeys.Count == 0)
|
||||
if (!any)
|
||||
{
|
||||
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);
|
||||
LocalizationCsv.ExportToFile(tableName, langDicts);
|
||||
exported++;
|
||||
}
|
||||
}
|
||||
@@ -195,61 +165,18 @@ namespace BaseGames.Editor.Localization
|
||||
{
|
||||
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++)
|
||||
// 合并语义 + 正确格式/目录/Addressable 注册,全部由统一门面处理
|
||||
int affected = LocalizationCsv.ImportFileToJson(tableName);
|
||||
if (affected == 0)
|
||||
{
|
||||
if (Enum.TryParse<Language>(header[col].Trim(), out var lang))
|
||||
langCols.Add((col, lang));
|
||||
Debug.LogWarning($"[CsvTool] CSV 文件不存在或为空,跳过:{ExportDir}/{tableName}.csv");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 为每个语言准备合并后的字典
|
||||
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)
|
||||
@@ -275,104 +202,6 @@ namespace BaseGames.Editor.Localization
|
||||
|
||||
// ── 工具函数 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <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;
|
||||
|
||||
160
Assets/_Game/Scripts/Editor/Localization/LocalizationFileIO.cs
Normal file
160
Assets/_Game/Scripts/Editor/Localization/LocalizationFileIO.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEditor.AddressableAssets.Settings;
|
||||
using UnityEngine;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Editor.Localization
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地化 JSON 表的编辑器侧磁盘读写门面(唯一入口)。
|
||||
///
|
||||
/// 所有编辑器工具(表格编辑器、CSV 工具、审计模块、各 Inspector)读写本地化文件
|
||||
/// 都必须经此类,统一:路径(<see cref="LocalizationPaths"/>)、格式
|
||||
/// (<see cref="LocalizationSerializer"/>)、Addressable 注册、编辑器缓存刷新。
|
||||
///
|
||||
/// 这样运行时加载与编辑器写盘永不脱节——杜绝"编辑器看着正常、Play 加载不到"的隐藏 bug。
|
||||
/// </summary>
|
||||
public static class LocalizationFileIO
|
||||
{
|
||||
/// <summary>所有受支持语言(枚举顺序)。</summary>
|
||||
public static readonly Language[] AllLanguages =
|
||||
(Language[])Enum.GetValues(typeof(Language));
|
||||
|
||||
// ── 读 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定语言 + 表的 key→value 字典(返回可安全修改的副本,不存在时返回空字典)。
|
||||
/// 内部走 <see cref="LocalizationManager.GetEditorTable"/> 复用其静态缓存。
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> Read(Language language, string table)
|
||||
{
|
||||
var src = LocalizationManager.GetEditorTable(language, table);
|
||||
return src == null
|
||||
? new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
: new Dictionary<string, string>(src, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>该表的 JSON 文件是否已存在于磁盘。</summary>
|
||||
public static bool TableExists(Language language, string table)
|
||||
=> AssetDatabase.LoadAssetAtPath<TextAsset>(LocalizationPaths.AssetPath(language, table)) != null;
|
||||
|
||||
/// <summary>扫描数据根目录,返回磁盘上实际存在子目录的语言。</summary>
|
||||
public static List<Language> DiscoverLanguages()
|
||||
{
|
||||
var result = new List<Language>();
|
||||
if (!AssetDatabase.IsValidFolder(LocalizationPaths.DataRoot)) return result;
|
||||
|
||||
foreach (var langFolder in AssetDatabase.GetSubFolders(LocalizationPaths.DataRoot))
|
||||
{
|
||||
string langName = Path.GetFileName(langFolder);
|
||||
if (Enum.TryParse<Language>(langName, out var lang))
|
||||
result.Add(lang);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 写 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 将字典写回指定语言 + 表的 JSON 文件(正确的 <c>{entries:[…]}</c> 格式 + 正确目录)。
|
||||
/// 新文件会自动注册到 Addressables(地址 <see cref="LocalizationPaths.Address"/>),
|
||||
/// 写盘后清除编辑器预览缓存。
|
||||
/// </summary>
|
||||
/// <param name="language">目标语言。</param>
|
||||
/// <param name="table">目标表名(<see cref="LocalizationTable"/> 常量)。</param>
|
||||
/// <param name="dict">key→value 字典。</param>
|
||||
/// <param name="registerAddressable">是否确保 Addressable 注册(默认 true)。</param>
|
||||
public static void Write(Language language, string table,
|
||||
IReadOnlyDictionary<string, string> dict, bool registerAddressable = true)
|
||||
{
|
||||
string assetPath = LocalizationPaths.AssetPath(language, table);
|
||||
EditorScaffoldUtils.EnsureFolder(LocalizationPaths.LanguageFolder(language));
|
||||
|
||||
// JSON 文件不写 BOM(Addressables/TextAsset 解析期望纯 UTF-8)。
|
||||
string json = LocalizationSerializer.Serialize(dict, sortKeys: true);
|
||||
string fullPath = ToFullPath(assetPath);
|
||||
File.WriteAllText(fullPath, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
|
||||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
if (registerAddressable)
|
||||
EnsureAddressable(language, table, assetPath);
|
||||
|
||||
LocalizationManager.ClearEditorPreviewCache();
|
||||
}
|
||||
|
||||
// ── Ping ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>在 Project 窗口中定位指定语言 + 表的 JSON 文件。</summary>
|
||||
public static TextAsset Ping(Language language, string table)
|
||||
{
|
||||
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(LocalizationPaths.AssetPath(language, table));
|
||||
if (asset != null) EditorScaffoldUtils.PingAndSelect(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
/// <summary>在 Project 窗口中定位指定表的任一存在语言文件(优先简体中文 → 英文 → 其余)。</summary>
|
||||
public static TextAsset PingAny(string table)
|
||||
{
|
||||
foreach (var lang in OrderedForPing())
|
||||
{
|
||||
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(LocalizationPaths.AssetPath(lang, table));
|
||||
if (asset != null) { EditorScaffoldUtils.PingAndSelect(asset); return asset; }
|
||||
}
|
||||
Debug.LogWarning($"[LocalizationFileIO] 未找到本地化表文件:{LocalizationPaths.DataRoot}/…/{table}.json");
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 内部 ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<Language> OrderedForPing()
|
||||
{
|
||||
yield return Language.ChineseSimplified;
|
||||
yield return Language.English;
|
||||
foreach (var lang in AllLanguages)
|
||||
if (lang != Language.ChineseSimplified && lang != Language.English)
|
||||
yield return lang;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保该 JSON 文件已注册为对应 Addressable 地址。
|
||||
/// 复用 <see cref="BaseGames.Editor.Addressables.CoreSceneRegistrar"/> 的 FindEntry-or-Create 幂等范式。
|
||||
/// </summary>
|
||||
private static void EnsureAddressable(Language language, string table, string assetPath)
|
||||
{
|
||||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||||
if (settings == null)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[LocalizationFileIO] Addressable Settings 未初始化,未能注册 " +
|
||||
$"{LocalizationPaths.Address(language, table)}。运行时可能加载不到该表。");
|
||||
return;
|
||||
}
|
||||
|
||||
string guid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
if (string.IsNullOrEmpty(guid)) return;
|
||||
|
||||
string address = LocalizationPaths.Address(language, table);
|
||||
var entry = settings.FindAssetEntry(guid)
|
||||
?? settings.CreateOrMoveEntry(guid, settings.DefaultGroup, false, false);
|
||||
|
||||
// 已注册且地址正确则无需改动(避免无意义的 SetDirty)。
|
||||
if (entry.address == address) return;
|
||||
|
||||
entry.address = address;
|
||||
EditorUtility.SetDirty(settings);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
private static string ToFullPath(string assetPath)
|
||||
{
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath)!;
|
||||
return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96378ff704ef55a4db9c6d7ee55c12a0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -60,7 +60,7 @@ namespace BaseGames.Editor.Localization
|
||||
{
|
||||
EditorUtility.DisplayDialog("Key 选择器",
|
||||
$"表「{table}」尚无可用 Key。\n" +
|
||||
$"请先在 Resources/Localization/{{语言}}/{table}.json 中添加条目。",
|
||||
$"请先用「BaseGames / Localization / 表格编辑器」或在 {LocalizationPaths.DataRoot}/{{语言}}/{table}.json 中添加条目。",
|
||||
"确定");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Editor.Localization
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地化表格编辑器(策划录入中心)。
|
||||
///
|
||||
/// 以 (Key × 语言) 网格直接编辑一张本地化表,无需手改 JSON:
|
||||
/// - 表切换、搜索过滤、"仅显示缺失"。
|
||||
/// - 新增 / 重命名 / 删除 Key(跨所有语言同步)。
|
||||
/// - 缺失单元格红底高亮。
|
||||
/// - 保存:经 <see cref="LocalizationFileIO"/> 写回所有语言 JSON(正确 entries 格式 + 自动注册 Addressables)。
|
||||
/// - 工具栏内置 Excel/CSV 双向:导出 CSV(UTF-8 BOM)、导入 CSV(合并后即时刷新网格)。
|
||||
/// - 1000+ 行虚拟化渲染(仅绘制可见行)。
|
||||
///
|
||||
/// 菜单:BaseGames / Localization / 表格编辑器
|
||||
/// </summary>
|
||||
public class LocalizationTableEditorWindow : EditorWindow
|
||||
{
|
||||
[MenuItem("BaseGames/Localization/表格编辑器")]
|
||||
private static void Open()
|
||||
{
|
||||
var win = GetWindow<LocalizationTableEditorWindow>("本地化表格编辑器");
|
||||
win.minSize = new Vector2(720, 480);
|
||||
}
|
||||
|
||||
// ── 常量 ─────────────────────────────────────────────────────────────
|
||||
private static readonly Language[] s_langs = LocalizationFileIO.AllLanguages;
|
||||
private const float RowHeight = 22f;
|
||||
private const float KeyColWidth = 240f;
|
||||
private const float MinLangCol = 140f;
|
||||
private const float Scrollbar = 16f;
|
||||
|
||||
// ── 状态 ─────────────────────────────────────────────────────────────
|
||||
private string[] _tables;
|
||||
private int _tableIndex;
|
||||
private string _currentTable;
|
||||
|
||||
// 工作副本:语言 → (key → value)
|
||||
private readonly Dictionary<Language, Dictionary<string, string>> _data = new();
|
||||
private readonly List<string> _keys = new(); // 全部 key(已排序)
|
||||
private readonly List<string> _filtered = new(); // 过滤后用于显示
|
||||
|
||||
private string _search = "";
|
||||
private bool _onlyMissing;
|
||||
private bool _dirty;
|
||||
private Vector2 _scroll;
|
||||
|
||||
// 行内输入状态机(新增 / 重命名 Key)
|
||||
private enum InputMode { None, AddKey, RenameKey }
|
||||
private InputMode _inputMode = InputMode.None;
|
||||
private string _inputText = "";
|
||||
private string _renameTarget;
|
||||
|
||||
private GUIStyle _keyBtnStyle;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
private void OnEnable()
|
||||
{
|
||||
_tables = LocalizationTable.All;
|
||||
_tableIndex = 0;
|
||||
LoadTable(_tables[_tableIndex]);
|
||||
}
|
||||
|
||||
// ── 数据加载 / 保存 ───────────────────────────────────────────────────
|
||||
private void LoadTable(string table)
|
||||
{
|
||||
_currentTable = table;
|
||||
_data.Clear();
|
||||
LocalizationManager.ClearEditorPreviewCache();
|
||||
|
||||
var union = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var lang in s_langs)
|
||||
{
|
||||
var dict = LocalizationFileIO.Read(lang, table);
|
||||
_data[lang] = dict;
|
||||
foreach (var k in dict.Keys) union.Add(k);
|
||||
}
|
||||
|
||||
_keys.Clear();
|
||||
_keys.AddRange(union);
|
||||
_dirty = false;
|
||||
_inputMode = InputMode.None;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
foreach (var lang in s_langs)
|
||||
LocalizationFileIO.Write(lang, _currentTable, _data[lang]);
|
||||
AssetDatabase.SaveAssets();
|
||||
_dirty = false;
|
||||
ShowNotification(new GUIContent($"已保存 {_currentTable}({_keys.Count} 个 Key × {s_langs.Length} 语言)"));
|
||||
}
|
||||
|
||||
/// <summary>切换表 / 关闭前:若有未保存改动,询问保存。返回 false 表示用户取消操作。</summary>
|
||||
private bool ConfirmDiscardIfDirty(string actionDesc)
|
||||
{
|
||||
if (!_dirty) return true;
|
||||
int choice = EditorUtility.DisplayDialogComplex(
|
||||
"有未保存的改动",
|
||||
$"表「{_currentTable}」有未保存的改动。{actionDesc}前要保存吗?",
|
||||
"保存", "取消", "丢弃");
|
||||
switch (choice)
|
||||
{
|
||||
case 0: Save(); return true; // 保存
|
||||
case 2: return true; // 丢弃
|
||||
default: return false; // 取消
|
||||
}
|
||||
}
|
||||
|
||||
// ── GUI ───────────────────────────────────────────────────────────────
|
||||
private void OnGUI()
|
||||
{
|
||||
EnsureStyles();
|
||||
DrawToolbar();
|
||||
if (_inputMode != InputMode.None) DrawInputBar();
|
||||
DrawHeader();
|
||||
DrawGrid();
|
||||
}
|
||||
|
||||
private void DrawToolbar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||
|
||||
// 表切换
|
||||
EditorGUI.BeginChangeCheck();
|
||||
int newIndex = EditorGUILayout.Popup(_tableIndex, _tables, EditorStyles.toolbarPopup, GUILayout.Width(160));
|
||||
if (EditorGUI.EndChangeCheck() && newIndex != _tableIndex)
|
||||
{
|
||||
if (ConfirmDiscardIfDirty("切换表"))
|
||||
{
|
||||
_tableIndex = newIndex;
|
||||
LoadTable(_tables[_tableIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
EditorGUI.BeginChangeCheck();
|
||||
_search = EditorGUILayout.TextField(_search, EditorStyles.toolbarSearchField, GUILayout.MinWidth(120));
|
||||
if (EditorGUI.EndChangeCheck()) ApplyFilter();
|
||||
|
||||
bool prevOnlyMissing = _onlyMissing;
|
||||
_onlyMissing = GUILayout.Toggle(_onlyMissing, "仅显示缺失", EditorStyles.toolbarButton, GUILayout.Width(80));
|
||||
if (_onlyMissing != prevOnlyMissing) ApplyFilter();
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("+ 新增 Key", EditorStyles.toolbarButton, GUILayout.Width(80)))
|
||||
BeginAddKey();
|
||||
|
||||
if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(64)))
|
||||
ExportCsv();
|
||||
if (GUILayout.Button("导入 CSV", EditorStyles.toolbarButton, GUILayout.Width(64)))
|
||||
ImportCsv();
|
||||
|
||||
using (new EditorGUI.DisabledScope(!_dirty))
|
||||
{
|
||||
var saveContent = new GUIContent(_dirty ? "● 保存" : "保存");
|
||||
if (GUILayout.Button(saveContent, EditorStyles.toolbarButton, GUILayout.Width(64)))
|
||||
Save();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(44)))
|
||||
{
|
||||
if (ConfirmDiscardIfDirty("刷新")) LoadTable(_currentTable);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void DrawInputBar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
GUILayout.Label(_inputMode == InputMode.AddKey ? "新增 Key:" : $"重命名「{_renameTarget}」为:",
|
||||
GUILayout.Width(_inputMode == InputMode.AddKey ? 70 : 200));
|
||||
|
||||
GUI.SetNextControlName("LocInputField");
|
||||
_inputText = EditorGUILayout.TextField(_inputText);
|
||||
|
||||
bool submit = false;
|
||||
var e = Event.current;
|
||||
if (e.type == EventType.KeyDown && (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)
|
||||
&& GUI.GetNameOfFocusedControl() == "LocInputField")
|
||||
{ submit = true; e.Use(); }
|
||||
if (e.type == EventType.KeyDown && e.keyCode == KeyCode.Escape) { CancelInput(); e.Use(); }
|
||||
|
||||
if (GUILayout.Button("确认", GUILayout.Width(48)) || submit) CommitInput();
|
||||
if (GUILayout.Button("取消", GUILayout.Width(48))) CancelInput();
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
// 命名提示
|
||||
if (!string.IsNullOrEmpty(_inputText) && !IsValidKey(_inputText))
|
||||
EditorGUILayout.HelpBox("建议使用 UPPER_SNAKE_CASE(大写字母开头,大写字母/数字/下划线)。", MessageType.Warning);
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
float langW = LangColWidth();
|
||||
var rect = GUILayoutUtility.GetRect(0, RowHeight, GUILayout.ExpandWidth(true));
|
||||
EditorGUI.DrawRect(rect, new Color(0.18f, 0.18f, 0.18f, 1f));
|
||||
|
||||
var keyRect = new Rect(rect.x + 4, rect.y + 3, KeyColWidth - 6, RowHeight - 4);
|
||||
EditorGUI.LabelField(keyRect, $"Key({_filtered.Count}/{_keys.Count})", EditorStyles.boldLabel);
|
||||
|
||||
for (int c = 0; c < s_langs.Length; c++)
|
||||
{
|
||||
var lr = new Rect(rect.x + KeyColWidth + c * langW + 4, rect.y + 3, langW - 6, RowHeight - 4);
|
||||
int missing = CountMissing(s_langs[c]);
|
||||
string label = missing > 0 ? $"{s_langs[c]} (缺{missing})" : s_langs[c].ToString();
|
||||
EditorGUI.LabelField(lr, label, EditorStyles.boldLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawGrid()
|
||||
{
|
||||
float langW = LangColWidth();
|
||||
float viewH = position.height - GUILayoutUtility.GetLastRect().yMax;
|
||||
float contentH = _filtered.Count * RowHeight;
|
||||
|
||||
_scroll = GUI.BeginScrollView(
|
||||
new Rect(0, GUILayoutUtility.GetLastRect().yMax, position.width, viewH),
|
||||
_scroll,
|
||||
new Rect(0, 0, position.width - Scrollbar, contentH));
|
||||
|
||||
int first = Mathf.Max(0, (int)(_scroll.y / RowHeight) - 1);
|
||||
int last = Mathf.Min(_filtered.Count - 1, (int)((_scroll.y + viewH) / RowHeight) + 1);
|
||||
|
||||
for (int i = first; i <= last && i < _filtered.Count; i++)
|
||||
DrawRow(i, _filtered[i], langW);
|
||||
|
||||
GUI.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawRow(int index, string key, float langW)
|
||||
{
|
||||
float y = index * RowHeight;
|
||||
var rowRect = new Rect(0, y, position.width - Scrollbar, RowHeight);
|
||||
|
||||
if (index % 2 == 0)
|
||||
EditorGUI.DrawRect(rowRect, new Color(1f, 1f, 1f, 0.025f));
|
||||
|
||||
// Key 列(点击弹出 重命名/删除/复制 菜单)
|
||||
var keyRect = new Rect(2, y + 1, KeyColWidth - 4, RowHeight - 2);
|
||||
if (GUI.Button(keyRect, key, _keyBtnStyle))
|
||||
ShowKeyContextMenu(key);
|
||||
|
||||
// 各语言可编辑单元格
|
||||
for (int c = 0; c < s_langs.Length; c++)
|
||||
{
|
||||
var lang = s_langs[c];
|
||||
var cellRect = new Rect(KeyColWidth + c * langW + 1, y + 1, langW - 2, RowHeight - 2);
|
||||
|
||||
_data[lang].TryGetValue(key, out var val);
|
||||
if (string.IsNullOrEmpty(val))
|
||||
EditorGUI.DrawRect(cellRect, new Color(0.5f, 0.15f, 0.1f, 0.28f)); // 缺失高亮
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
string newVal = EditorGUI.DelayedTextField(cellRect, val ?? "");
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
_data[lang][key] = newVal;
|
||||
MarkDirty();
|
||||
if (_onlyMissing) ApplyFilter(); // 填上后可能从"缺失"列表消失
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key 增删改 ────────────────────────────────────────────────────────
|
||||
private void ShowKeyContextMenu(string key)
|
||||
{
|
||||
var menu = new GenericMenu();
|
||||
menu.AddItem(new GUIContent("重命名"), false, () => BeginRename(key));
|
||||
menu.AddItem(new GUIContent("删除"), false, () => DeleteKey(key));
|
||||
menu.AddItem(new GUIContent("复制 Key"), false, () => EditorGUIUtility.systemCopyBuffer = key);
|
||||
menu.ShowAsContext();
|
||||
}
|
||||
|
||||
private void BeginAddKey()
|
||||
{
|
||||
_inputMode = InputMode.AddKey;
|
||||
_inputText = "";
|
||||
_renameTarget = null;
|
||||
EditorGUI.FocusTextInControl("LocInputField");
|
||||
}
|
||||
|
||||
private void BeginRename(string key)
|
||||
{
|
||||
_inputMode = InputMode.RenameKey;
|
||||
_inputText = key;
|
||||
_renameTarget = key;
|
||||
EditorGUI.FocusTextInControl("LocInputField");
|
||||
}
|
||||
|
||||
private void CommitInput()
|
||||
{
|
||||
string newKey = _inputText?.Trim();
|
||||
if (string.IsNullOrEmpty(newKey)) { CancelInput(); return; }
|
||||
|
||||
if (_inputMode == InputMode.AddKey)
|
||||
{
|
||||
if (_keys.Contains(newKey))
|
||||
{
|
||||
EditorUtility.DisplayDialog("新增 Key", $"Key「{newKey}」已存在。", "确定");
|
||||
return;
|
||||
}
|
||||
foreach (var lang in s_langs) _data[lang][newKey] = "";
|
||||
_keys.Add(newKey);
|
||||
_keys.Sort(StringComparer.Ordinal);
|
||||
MarkDirty();
|
||||
}
|
||||
else if (_inputMode == InputMode.RenameKey && _renameTarget != null)
|
||||
{
|
||||
if (newKey == _renameTarget) { CancelInput(); return; }
|
||||
if (_keys.Contains(newKey))
|
||||
{
|
||||
EditorUtility.DisplayDialog("重命名", $"Key「{newKey}」已存在。", "确定");
|
||||
return;
|
||||
}
|
||||
foreach (var lang in s_langs)
|
||||
{
|
||||
_data[lang].TryGetValue(_renameTarget, out var v);
|
||||
_data[lang].Remove(_renameTarget);
|
||||
_data[lang][newKey] = v ?? "";
|
||||
}
|
||||
_keys.Remove(_renameTarget);
|
||||
_keys.Add(newKey);
|
||||
_keys.Sort(StringComparer.Ordinal);
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
_inputMode = InputMode.None;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void CancelInput()
|
||||
{
|
||||
_inputMode = InputMode.None;
|
||||
_inputText = "";
|
||||
_renameTarget = null;
|
||||
GUI.FocusControl(null);
|
||||
}
|
||||
|
||||
private void DeleteKey(string key)
|
||||
{
|
||||
if (!EditorUtility.DisplayDialog("删除 Key", $"确定删除「{key}」(所有语言)?", "删除", "取消"))
|
||||
return;
|
||||
foreach (var lang in s_langs) _data[lang].Remove(key);
|
||||
_keys.Remove(key);
|
||||
MarkDirty();
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
// ── CSV 双向 ─────────────────────────────────────────────────────────
|
||||
private void ExportCsv()
|
||||
{
|
||||
string path = LocalizationCsv.ExportToFile(_currentTable, _data);
|
||||
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
|
||||
if (asset != null) EditorGUIUtility.PingObject(asset);
|
||||
ShowNotification(new GUIContent($"已导出 {path}"));
|
||||
}
|
||||
|
||||
private void ImportCsv()
|
||||
{
|
||||
var parsed = LocalizationCsv.ParseFile(_currentTable);
|
||||
if (parsed == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog("导入 CSV",
|
||||
$"未找到 CSV 文件:\n{LocalizationPaths.CsvPath(_currentTable)}\n\n请先「导出 CSV」或将文件放到该路径。",
|
||||
"确定");
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并进内存网格(覆盖已有 key、追加新 key、不删多余),随后置脏待保存
|
||||
int added = 0, updated = 0;
|
||||
foreach (var (lang, csvDict) in parsed)
|
||||
{
|
||||
if (!_data.ContainsKey(lang)) _data[lang] = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var kv in csvDict)
|
||||
{
|
||||
bool isNew = !_keys.Contains(kv.Key);
|
||||
if (isNew && !_data[lang].ContainsKey(kv.Key)) added++;
|
||||
else updated++;
|
||||
_data[lang][kv.Key] = kv.Value;
|
||||
if (!_keys.Contains(kv.Key)) _keys.Add(kv.Key);
|
||||
}
|
||||
}
|
||||
_keys.Sort(StringComparer.Ordinal);
|
||||
MarkDirty();
|
||||
ApplyFilter();
|
||||
ShowNotification(new GUIContent($"已导入并刷新网格(新增 {added}、更新 {updated})。记得点「保存」写回。"));
|
||||
}
|
||||
|
||||
// ── 过滤 / 工具 ───────────────────────────────────────────────────────
|
||||
private void ApplyFilter()
|
||||
{
|
||||
_filtered.Clear();
|
||||
foreach (var key in _keys)
|
||||
{
|
||||
if (_onlyMissing && !HasMissing(key)) continue;
|
||||
if (!string.IsNullOrEmpty(_search) && !MatchesSearch(key)) continue;
|
||||
_filtered.Add(key);
|
||||
}
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private bool MatchesSearch(string key)
|
||||
{
|
||||
if (key.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||||
foreach (var lang in s_langs)
|
||||
if (_data[lang].TryGetValue(key, out var v) && !string.IsNullOrEmpty(v)
|
||||
&& v.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasMissing(string key)
|
||||
{
|
||||
foreach (var lang in s_langs)
|
||||
if (!_data[lang].TryGetValue(key, out var v) || string.IsNullOrEmpty(v)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private int CountMissing(Language lang)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var key in _keys)
|
||||
if (!_data[lang].TryGetValue(key, out var v) || string.IsNullOrEmpty(v)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
private float LangColWidth()
|
||||
{
|
||||
float avail = position.width - KeyColWidth - Scrollbar;
|
||||
return Mathf.Max(MinLangCol, avail / s_langs.Length);
|
||||
}
|
||||
|
||||
private static bool IsValidKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key) || !char.IsUpper(key[0])) return false;
|
||||
foreach (char c in key)
|
||||
if (!(char.IsUpper(c) || char.IsDigit(c) || c == '_')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void MarkDirty() => _dirty = true;
|
||||
|
||||
private void EnsureStyles()
|
||||
{
|
||||
_keyBtnStyle ??= new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
padding = new RectOffset(4, 2, 0, 0),
|
||||
fontSize = 11,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d25d80dd8535edd4c95436a6f9d2f5a4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -64,7 +64,7 @@ namespace BaseGames.Editor.Localization
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
$"Key「{key}」在表「{table}」中未找到(简体中文表与英文表均未命中)。\n" +
|
||||
$"请检查 Resources/Localization/{{Language}}/{table}.json 文件。",
|
||||
$"请检查 {LocalizationPaths.DataRoot}/{{Language}}/{table}.json 文件。",
|
||||
MessageType.Warning);
|
||||
}
|
||||
else
|
||||
@@ -101,7 +101,7 @@ namespace BaseGames.Editor.Localization
|
||||
Repaint();
|
||||
}
|
||||
if (GUILayout.Button($"跳转到表文件({table})", GUILayout.Width(160)))
|
||||
PingLocalizationFile(table);
|
||||
LocalizationFileIO.PingAny(table);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
@@ -120,24 +120,5 @@ namespace BaseGames.Editor.Localization
|
||||
catch { return template; }
|
||||
}
|
||||
|
||||
private static void PingLocalizationFile(string tableName)
|
||||
{
|
||||
string[] guids = AssetDatabase.FindAssets(
|
||||
$"t:TextAsset {tableName}",
|
||||
new[] { "Assets/Resources/Localization" });
|
||||
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (!path.EndsWith($"/{tableName}.json", System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
|
||||
if (asset == null) continue;
|
||||
EditorGUIUtility.PingObject(asset);
|
||||
Selection.activeObject = asset;
|
||||
return;
|
||||
}
|
||||
Debug.LogWarning($"[LocalizedTextEditor] 未找到本地化表文件:Resources/Localization/…/{tableName}.json");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user