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; namespace BaseGames.Editor.Modules { /// /// DataHub 本地化审计模块。 /// 通过 接口扫描项目中所有 ScriptableObject 的本地化 Key, /// 与 Resources/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() { string root = "Assets/Resources/Localization"; if (!AssetDatabase.IsValidFolder(root)) return; foreach (var langFolder in AssetDatabase.GetSubFolders(root)) { string langName = Path.GetFileName(langFolder); if (Enum.TryParse(langName, out var lang)) _availableLanguages.Add(lang); } } // ── 通用 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) { string path = $"Assets/Resources/Localization/{language}/{tableName}.json"; var asset = AssetDatabase.LoadAssetAtPath(path); if (asset != null) { EditorGUIUtility.PingObject(asset); Selection.activeObject = asset; } else { Debug.LogWarning($"[LocalizationAudit] 未找到表文件:{path}"); } } } }