Files
zeling_v2/Assets/_Game/Scripts/Editor/Modules/LocalizationAuditModule.cs
2026-06-06 09:00:11 +08:00

565 lines
24 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.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);
}
}
}
}