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
{
///
/// DataHub 本地化审计模块。
/// 通过 接口扫描项目中所有 ScriptableObject 的本地化 Key,
/// 与 Assets/_Game/Data/Localization/ JSON 表比对,列出缺失条目和命名不规范条目。
///
/// 菜单入口:DataHub → "本地化审计"
///
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 _issues = new();
private readonly List _namingIssues = new();
private readonly List _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 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 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(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(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(_ =>
{
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(_ =>
{
_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, out var lang))
{
if (LocalizationFileIO.Ping(lang, tableName) == null)
Debug.LogWarning($"[LocalizationAudit] 未找到表文件:{LocalizationPaths.AssetPath(lang, tableName)}");
}
else
{
LocalizationFileIO.PingAny(tableName);
}
}
}
}