565 lines
24 KiB
C#
565 lines
24 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text.RegularExpressions;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
using BaseGames.Localization;
|
||
using BaseGames.Editor.Localization;
|
||
|
||
namespace BaseGames.Editor.Modules
|
||
{
|
||
/// <summary>
|
||
/// DataHub 本地化审计模块。
|
||
/// 通过 <see cref="ILocalizableAsset"/> 接口扫描项目中所有 ScriptableObject 的本地化 Key,
|
||
/// 与 Assets/_Game/Data/Localization/ JSON 表比对,列出缺失条目和命名不规范条目。
|
||
///
|
||
/// 菜单入口:DataHub → "本地化审计"
|
||
/// </summary>
|
||
public class LocalizationAuditModule : IDataModule, IDataModuleOrdered
|
||
{
|
||
public string ModuleId => "localization-audit";
|
||
public string DisplayName => "本地化审计";
|
||
public string IconName => "d_UnityEditor.InspectorWindow";
|
||
public int DisplayOrder => 135;
|
||
|
||
// Key 命名规范:UPPER_SNAKE_CASE(大写字母、数字、下划线,首字符必须是大写字母)
|
||
private static readonly Regex s_keyPattern = new(@"^[A-Z][A-Z0-9_]*$", RegexOptions.Compiled);
|
||
|
||
// ── 数据 ─────────────────────────────────────────────────────────────
|
||
|
||
private readonly List<AuditIssue> _issues = new();
|
||
private readonly List<NamingIssue> _namingIssues = new();
|
||
private readonly List<Language> _availableLanguages = new();
|
||
private int _totalLanguageCount;
|
||
private bool _hasScanned;
|
||
|
||
private class AuditIssue
|
||
{
|
||
public string key;
|
||
public string table;
|
||
public string soPath;
|
||
public string fieldName;
|
||
public UnityEngine.Object asset;
|
||
public readonly List<string> missingLanguages = new();
|
||
}
|
||
|
||
private class NamingIssue
|
||
{
|
||
public string key;
|
||
public string table;
|
||
public string soPath;
|
||
public string fieldName;
|
||
public UnityEngine.Object asset;
|
||
}
|
||
|
||
// ── UI 引用 ───────────────────────────────────────────────────────────
|
||
|
||
private VisualElement _listItems;
|
||
private Label _summaryLabel;
|
||
private VisualElement _detailRoot;
|
||
private VisualElement _namingSection;
|
||
private bool _filterMissingAll, _filterMissingPartial, _filterNamingIssue;
|
||
private string _filterTableName = "";
|
||
|
||
// ── IDataModule ───────────────────────────────────────────────────────
|
||
|
||
public void Initialize() { }
|
||
|
||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||
{
|
||
// 扫描 + 导出 按钮行
|
||
var btnRow = new VisualElement();
|
||
btnRow.style.flexDirection = FlexDirection.Row;
|
||
btnRow.style.marginTop = 8;
|
||
btnRow.style.marginLeft = 8;
|
||
btnRow.style.marginRight = 8;
|
||
btnRow.style.marginBottom = 4;
|
||
container.Add(btnRow);
|
||
|
||
var scanBtn = new Button(RunScan) { text = "🔍 扫描本地化缺失" };
|
||
scanBtn.style.flexGrow = 1;
|
||
scanBtn.style.marginRight = 4;
|
||
btnRow.Add(scanBtn);
|
||
|
||
var exportBtn = new Button(ExportReport) { text = "📄 导出报告" };
|
||
exportBtn.style.width = 90;
|
||
btnRow.Add(exportBtn);
|
||
|
||
_summaryLabel = new Label("尚未扫描,点击左侧按钮开始。");
|
||
_summaryLabel.style.fontSize = 10;
|
||
_summaryLabel.style.opacity = 0.6f;
|
||
_summaryLabel.style.paddingLeft = 10;
|
||
_summaryLabel.style.marginBottom = 4;
|
||
container.Add(_summaryLabel);
|
||
|
||
// 过滤行
|
||
var filterRow = new VisualElement();
|
||
filterRow.style.flexDirection = FlexDirection.Row;
|
||
filterRow.style.flexWrap = Wrap.Wrap;
|
||
filterRow.style.paddingLeft = 6;
|
||
filterRow.style.paddingRight = 6;
|
||
filterRow.style.paddingBottom = 3;
|
||
container.Add(filterRow);
|
||
|
||
filterRow.Add(DataHubEditorKit.MakeFilterChip("全部语言缺失", v => { _filterMissingAll = v; RebuildList(); }));
|
||
filterRow.Add(DataHubEditorKit.MakeFilterChip("部分语言缺失", v => { _filterMissingPartial = v; RebuildList(); }));
|
||
// "命名不规范"Chip 现在只控制命名折叠区展开/折叠,不再隐藏缺失列表
|
||
filterRow.Add(DataHubEditorKit.MakeFilterChip("展开命名问题", v => { _filterNamingIssue = v; RebuildList(); }));
|
||
|
||
// 表名过滤输入框
|
||
var tableField = new TextField("表名过滤") { value = "" };
|
||
tableField.style.paddingLeft = 6;
|
||
tableField.style.paddingRight = 6;
|
||
tableField.style.marginBottom = 3;
|
||
tableField.RegisterValueChangedCallback(e =>
|
||
{
|
||
_filterTableName = e.newValue?.Trim() ?? "";
|
||
RebuildList();
|
||
});
|
||
container.Add(tableField);
|
||
|
||
var scroll = new ScrollView { style = { flexGrow = 1 } };
|
||
container.Add(scroll);
|
||
|
||
_listItems = new VisualElement();
|
||
scroll.Add(_listItems);
|
||
|
||
_namingSection = new VisualElement();
|
||
scroll.Add(_namingSection);
|
||
}
|
||
|
||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||
{
|
||
_detailRoot = container;
|
||
RebuildDetail(null);
|
||
}
|
||
|
||
public void OnActivated() { }
|
||
|
||
// ── 扫描 ──────────────────────────────────────────────────────────────
|
||
|
||
private void RunScan()
|
||
{
|
||
_issues.Clear();
|
||
_namingIssues.Clear();
|
||
_availableLanguages.Clear();
|
||
LocalizationManager.ClearEditorPreviewCache();
|
||
_hasScanned = true;
|
||
|
||
DiscoverLanguages();
|
||
_totalLanguageCount = _availableLanguages.Count;
|
||
|
||
ScanAllLocalizableAssets();
|
||
|
||
int total = _issues.Count;
|
||
int misAll = _issues.Count(i => i.missingLanguages.Count == _totalLanguageCount);
|
||
int naming = _namingIssues.Count;
|
||
|
||
_summaryLabel.text = total == 0 && naming == 0
|
||
? $"✅ 全部通过!已检查 {_totalLanguageCount} 个语言。"
|
||
: $"⚠ {total} 个缺失问题(全语言缺失 {misAll} 个),{naming} 个命名不规范。";
|
||
|
||
RebuildList();
|
||
}
|
||
|
||
// ── 语言发现 ──────────────────────────────────────────────────────────
|
||
|
||
private void DiscoverLanguages()
|
||
{
|
||
// 统一经 LocalizationFileIO 扫描真相源目录(Assets/_Game/Data/Localization)
|
||
_availableLanguages.AddRange(LocalizationFileIO.DiscoverLanguages());
|
||
}
|
||
|
||
// ── 通用 ILocalizableAsset 扫描 ───────────────────────────────────────
|
||
|
||
private void ScanAllLocalizableAssets()
|
||
{
|
||
string[] guids = AssetDatabase.FindAssets("t:ScriptableObject");
|
||
int total = guids.Length;
|
||
|
||
try
|
||
{
|
||
for (int i = 0; i < total; i++)
|
||
{
|
||
if (EditorUtility.DisplayCancelableProgressBar(
|
||
"本地化审计", $"扫描中… ({i + 1}/{total})", (float)(i + 1) / total))
|
||
break;
|
||
|
||
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
|
||
var so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
|
||
if (so == null || so is not ILocalizableAsset loc) continue;
|
||
|
||
foreach (var keyRef in loc.GetLocalizationKeys())
|
||
{
|
||
CheckKey(so, keyRef.Key, keyRef.Table, keyRef.FieldName);
|
||
CheckKeyNamingConvention(so, keyRef.Key, keyRef.Table, keyRef.FieldName, path);
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
EditorUtility.ClearProgressBar();
|
||
}
|
||
}
|
||
|
||
// ── 导出审计报告 ──────────────────────────────────────────────────────
|
||
|
||
private void ExportReport()
|
||
{
|
||
if (!_hasScanned)
|
||
{
|
||
EditorUtility.DisplayDialog("导出报告", "请先执行扫描,再导出报告。", "确定");
|
||
return;
|
||
}
|
||
|
||
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||
string dir = Path.Combine(Application.dataPath, "_Game", "Localization");
|
||
Directory.CreateDirectory(dir);
|
||
string filePath = Path.Combine(dir, $"AuditReport_{timestamp}.txt");
|
||
|
||
using var sw = new StreamWriter(filePath, false, System.Text.Encoding.UTF8);
|
||
|
||
sw.WriteLine("====================================================");
|
||
sw.WriteLine(" 本地化审计报告");
|
||
sw.WriteLine($" 生成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||
sw.WriteLine("====================================================");
|
||
sw.WriteLine();
|
||
sw.WriteLine($"缺失条目:{_issues.Count} 个");
|
||
sw.WriteLine($"命名不规范:{_namingIssues.Count} 个");
|
||
sw.WriteLine();
|
||
|
||
if (_issues.Count > 0)
|
||
{
|
||
sw.WriteLine("── 缺失翻译 ────────────────────────────────────────");
|
||
foreach (var issue in _issues)
|
||
{
|
||
sw.WriteLine($" [{issue.table}] {issue.key}");
|
||
sw.WriteLine($" 字段:{issue.fieldName}");
|
||
sw.WriteLine($" 路径:{issue.soPath}");
|
||
sw.WriteLine($" 缺失语言:{string.Join(", ", issue.missingLanguages)}");
|
||
sw.WriteLine();
|
||
}
|
||
}
|
||
|
||
if (_namingIssues.Count > 0)
|
||
{
|
||
sw.WriteLine("── 命名不规范(应为 UPPER_SNAKE_CASE)────────────────");
|
||
foreach (var ni in _namingIssues)
|
||
{
|
||
sw.WriteLine($" [{ni.table}] {ni.key}");
|
||
sw.WriteLine($" 字段:{ni.fieldName}");
|
||
sw.WriteLine($" 路径:{ni.soPath}");
|
||
sw.WriteLine();
|
||
}
|
||
}
|
||
|
||
sw.WriteLine("====================================================");
|
||
sw.Flush();
|
||
|
||
AssetDatabase.Refresh();
|
||
|
||
string relPath = $"Assets/_Game/Localization/AuditReport_{timestamp}.txt";
|
||
var asset = AssetDatabase.LoadAssetAtPath<TextAsset>(relPath);
|
||
if (asset != null) EditorGUIUtility.PingObject(asset);
|
||
|
||
EditorUtility.DisplayDialog("导出成功",
|
||
$"报告已保存至:\n{filePath}", "打开文件夹");
|
||
EditorUtility.RevealInFinder(filePath);
|
||
}
|
||
|
||
// ── Key 检查 ──────────────────────────────────────────────────────────
|
||
|
||
|
||
private void CheckKey(UnityEngine.Object asset, string key, string table, string fieldName)
|
||
{
|
||
if (string.IsNullOrEmpty(key)) return;
|
||
|
||
var issue = new AuditIssue
|
||
{
|
||
key = key,
|
||
table = table,
|
||
fieldName = fieldName,
|
||
soPath = AssetDatabase.GetAssetPath(asset),
|
||
asset = asset,
|
||
};
|
||
|
||
foreach (var lang in _availableLanguages)
|
||
{
|
||
var dict = LocalizationManager.GetEditorTable(lang, table);
|
||
if (dict == null || !dict.ContainsKey(key))
|
||
issue.missingLanguages.Add(lang.ToString());
|
||
}
|
||
|
||
if (issue.missingLanguages.Count > 0)
|
||
_issues.Add(issue);
|
||
}
|
||
|
||
private void CheckKeyNamingConvention(UnityEngine.Object asset, string key, string table,
|
||
string fieldName, string path)
|
||
{
|
||
if (string.IsNullOrEmpty(key)) return;
|
||
if (s_keyPattern.IsMatch(key)) return;
|
||
|
||
_namingIssues.Add(new NamingIssue
|
||
{
|
||
key = key,
|
||
table = table,
|
||
fieldName = fieldName,
|
||
soPath = path,
|
||
asset = asset,
|
||
});
|
||
}
|
||
|
||
// ── 列表 / 详情 UI ────────────────────────────────────────────────────
|
||
|
||
private AuditIssue _selectedIssue;
|
||
|
||
private void RebuildList()
|
||
{
|
||
_listItems.Clear();
|
||
_namingSection.Clear();
|
||
if (!_hasScanned) return;
|
||
|
||
// ── 缺失问题列表(始终显示,不受命名 Chip 影响)─────────────────
|
||
var filtered = _issues.AsEnumerable();
|
||
|
||
if (_filterMissingAll)
|
||
filtered = filtered.Where(i => i.missingLanguages.Count == _totalLanguageCount);
|
||
else if (_filterMissingPartial)
|
||
filtered = filtered.Where(i => i.missingLanguages.Count > 0 && i.missingLanguages.Count < _totalLanguageCount);
|
||
|
||
if (!string.IsNullOrEmpty(_filterTableName))
|
||
filtered = filtered.Where(i => i.table.IndexOf(_filterTableName, StringComparison.OrdinalIgnoreCase) >= 0);
|
||
|
||
var list = filtered.ToList();
|
||
|
||
if (list.Count == 0)
|
||
{
|
||
_listItems.Add(new Label("无缺失问题或无匹配结果。") { style = { paddingLeft = 10, opacity = 0.5f } });
|
||
}
|
||
else
|
||
{
|
||
foreach (var issue in list)
|
||
{
|
||
var row = BuildMissingRow(issue);
|
||
_listItems.Add(row);
|
||
}
|
||
}
|
||
|
||
// ── 命名不规范列表(独立折叠区)────────────────────────────────
|
||
if (_namingIssues.Count > 0)
|
||
{
|
||
var foldout = new Foldout
|
||
{
|
||
text = $"命名不规范 Key({_namingIssues.Count} 个)",
|
||
value = _filterNamingIssue,
|
||
};
|
||
foldout.style.paddingLeft = 4;
|
||
_namingSection.Add(foldout);
|
||
|
||
var namingFiltered = _namingIssues.AsEnumerable();
|
||
if (!string.IsNullOrEmpty(_filterTableName))
|
||
namingFiltered = namingFiltered.Where(i => i.table.IndexOf(_filterTableName, StringComparison.OrdinalIgnoreCase) >= 0);
|
||
|
||
foreach (var ni in namingFiltered)
|
||
{
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.paddingLeft = 8;
|
||
row.style.paddingRight = 8;
|
||
row.style.paddingTop = 3;
|
||
row.style.paddingBottom = 3;
|
||
row.style.borderBottomWidth = 1;
|
||
row.style.borderBottomColor = new StyleColor(new Color(0.3f, 0.3f, 0.3f, 0.4f));
|
||
row.style.backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.45f, 0.25f));
|
||
|
||
var capturedNi = ni;
|
||
row.RegisterCallback<ClickEvent>(_ =>
|
||
{
|
||
if (capturedNi.asset != null)
|
||
{
|
||
EditorGUIUtility.PingObject(capturedNi.asset);
|
||
Selection.activeObject = capturedNi.asset;
|
||
}
|
||
RebuildNamingDetail(capturedNi);
|
||
});
|
||
|
||
var lbl = new Label($"[{ni.table}] {ni.key} ({ni.fieldName})");
|
||
lbl.style.flexGrow = 1;
|
||
lbl.style.fontSize = 11;
|
||
row.Add(lbl);
|
||
|
||
var hint = new Label("应为 UPPER_SNAKE_CASE");
|
||
hint.style.fontSize = 10;
|
||
hint.style.opacity = 0.6f;
|
||
row.Add(hint);
|
||
|
||
foldout.Add(row);
|
||
}
|
||
}
|
||
}
|
||
|
||
private VisualElement BuildMissingRow(AuditIssue issue)
|
||
{
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.paddingLeft = 8;
|
||
row.style.paddingRight = 8;
|
||
row.style.paddingTop = 4;
|
||
row.style.paddingBottom = 4;
|
||
row.style.borderBottomWidth = 1;
|
||
row.style.borderBottomColor = new StyleColor(new Color(0.3f, 0.3f, 0.3f, 0.4f));
|
||
|
||
var captured = issue;
|
||
row.RegisterCallback<ClickEvent>(_ =>
|
||
{
|
||
_selectedIssue = captured;
|
||
RebuildDetail(captured);
|
||
if (captured.asset != null)
|
||
EditorGUIUtility.PingObject(captured.asset);
|
||
});
|
||
|
||
bool allMissing = issue.missingLanguages.Count == _totalLanguageCount;
|
||
row.style.backgroundColor = new StyleColor(allMissing
|
||
? new Color(0.45f, 0.15f, 0.05f, 0.35f)
|
||
: new Color(0.40f, 0.35f, 0.00f, 0.25f));
|
||
|
||
var left = new Label($"[{issue.table}] {issue.key}");
|
||
left.style.flexGrow = 1;
|
||
left.style.fontSize = 11;
|
||
left.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
row.Add(left);
|
||
|
||
var right = new Label(string.Join(", ", issue.missingLanguages));
|
||
right.style.fontSize = 10;
|
||
right.style.opacity = 0.7f;
|
||
row.Add(right);
|
||
|
||
return row;
|
||
}
|
||
|
||
private void RebuildDetail(AuditIssue issue)
|
||
{
|
||
if (_detailRoot == null) return;
|
||
_detailRoot.Clear();
|
||
|
||
if (issue == null)
|
||
{
|
||
_detailRoot.Add(new Label("← 选择左侧条目查看详情。") { style = { paddingLeft = 16, paddingTop = 16, opacity = 0.5f } });
|
||
return;
|
||
}
|
||
|
||
var title = new Label($"Key:{issue.key}");
|
||
title.style.fontSize = 14;
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
title.style.paddingLeft = 12;
|
||
title.style.paddingTop = 10;
|
||
title.style.paddingBottom = 6;
|
||
_detailRoot.Add(title);
|
||
|
||
AddDetailRow(_detailRoot, "表名", issue.table);
|
||
AddDetailRow(_detailRoot, "字段", issue.fieldName);
|
||
AddDetailRow(_detailRoot, "资产路径", issue.soPath);
|
||
|
||
_detailRoot.Add(new Label("缺失语言:") { style = { paddingLeft = 12, paddingTop = 8, fontSize = 11, unityFontStyleAndWeight = FontStyle.Bold } });
|
||
foreach (var lang in issue.missingLanguages)
|
||
_detailRoot.Add(new Label($" • {lang}") { style = { paddingLeft = 20, fontSize = 11 } });
|
||
|
||
_detailRoot.Add(new VisualElement { style = { height = 8 } });
|
||
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, paddingLeft = 10, paddingRight = 10 } };
|
||
_detailRoot.Add(btnRow);
|
||
|
||
var pingBtn = new Button(() =>
|
||
{
|
||
if (issue.asset != null)
|
||
{
|
||
EditorGUIUtility.PingObject(issue.asset);
|
||
Selection.activeObject = issue.asset;
|
||
}
|
||
}) { text = "定位 SO 资产", style = { flexGrow = 1, marginRight = 4 } };
|
||
btnRow.Add(pingBtn);
|
||
|
||
var copyBtn = new Button(() =>
|
||
{
|
||
EditorGUIUtility.systemCopyBuffer = issue.key;
|
||
Debug.Log($"[LocalizationAudit] 已复制 Key:{issue.key}");
|
||
}) { text = "复制 Key", style = { flexGrow = 1 } };
|
||
btnRow.Add(copyBtn);
|
||
|
||
foreach (var lang in issue.missingLanguages)
|
||
{
|
||
var capturedLang = lang;
|
||
var openBtn = new Button(() => PingTableFile(capturedLang, issue.table))
|
||
{
|
||
text = $"打开 {lang}/{issue.table}.json",
|
||
style = { marginTop = 4, marginLeft = 10, marginRight = 10 },
|
||
};
|
||
_detailRoot.Add(openBtn);
|
||
}
|
||
}
|
||
|
||
private void RebuildNamingDetail(NamingIssue ni)
|
||
{
|
||
if (_detailRoot == null) return;
|
||
_detailRoot.Clear();
|
||
|
||
var title = new Label($"命名不规范:{ni.key}");
|
||
title.style.fontSize = 14;
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
title.style.paddingLeft = 12;
|
||
title.style.paddingTop = 10;
|
||
title.style.paddingBottom = 6;
|
||
_detailRoot.Add(title);
|
||
|
||
AddDetailRow(_detailRoot, "表名", ni.table);
|
||
AddDetailRow(_detailRoot, "字段", ni.fieldName);
|
||
AddDetailRow(_detailRoot, "资产路径", ni.soPath);
|
||
|
||
_detailRoot.Add(new Label("规范要求:UPPER_SNAKE_CASE(如 QUEST_FIND_HERB_NAME)")
|
||
{ style = { paddingLeft = 12, paddingTop = 8, fontSize = 11, opacity = 0.7f } });
|
||
|
||
_detailRoot.Add(new VisualElement { style = { height = 8 } });
|
||
var pingBtn = new Button(() =>
|
||
{
|
||
if (ni.asset != null)
|
||
{
|
||
EditorGUIUtility.PingObject(ni.asset);
|
||
Selection.activeObject = ni.asset;
|
||
}
|
||
}) { text = "定位 SO 资产", style = { marginLeft = 10, marginRight = 10 } };
|
||
_detailRoot.Add(pingBtn);
|
||
|
||
var copyBtn = new Button(() =>
|
||
{
|
||
EditorGUIUtility.systemCopyBuffer = ni.key;
|
||
Debug.Log($"[LocalizationAudit] 已复制 Key:{ni.key}");
|
||
}) { text = "复制 Key", style = { marginLeft = 10, marginRight = 10, marginTop = 4 } };
|
||
_detailRoot.Add(copyBtn);
|
||
}
|
||
|
||
private static void AddDetailRow(VisualElement parent, string label, string value)
|
||
{
|
||
var row = new VisualElement { style = { flexDirection = FlexDirection.Row, paddingLeft = 12, paddingTop = 2, paddingBottom = 2 } };
|
||
row.Add(new Label(label + ":") { style = { width = 80, opacity = 0.6f, fontSize = 11 } });
|
||
row.Add(new Label(value) { style = { flexGrow = 1, fontSize = 11 } });
|
||
parent.Add(row);
|
||
}
|
||
|
||
private static void PingTableFile(string language, string tableName)
|
||
{
|
||
if (Enum.TryParse<Language>(language, out var lang))
|
||
{
|
||
if (LocalizationFileIO.Ping(lang, tableName) == null)
|
||
Debug.LogWarning($"[LocalizationAudit] 未找到表文件:{LocalizationPaths.AssetPath(lang, tableName)}");
|
||
}
|
||
else
|
||
{
|
||
LocalizationFileIO.PingAny(tableName);
|
||
}
|
||
}
|
||
}
|
||
}
|