using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEditor.IMGUI.Controls; using UnityEngine; // ======================== 扫描数据模型 ======================== public class ScanResultItem { public string PrefabPath { get; } public string GameObjectPath { get; } public List MissingLanguages { get; set; } = new List(); public TextComponentType ComponentType { get; } public string PrefabGUID { get; } public ScanResultItem(string path, string goPath, TextComponentType type, string guid) { PrefabPath = path; GameObjectPath = goPath; ComponentType = type; PrefabGUID = guid; } public string GetDisplayName() => $"{GameObjectPath} (缺少: {string.Join(", ", MissingLanguages)}) [{ComponentType}]"; } public class PrefabScanResult { public string PrefabPath { get; } public string PrefabGUID { get; } public List Items { get; } = new List(); public PrefabScanResult(string path, string guid) { PrefabPath = path; PrefabGUID = guid; } } public class ScanResultSummary { public string ScanDirectory { get; } public int TotalPrefabsScanned { get; set; } public int TotalAdaptersFound { get; set; } public int AdaptersWithMissingConfig { get; private set; } public List PrefabResults { get; } = new List(); public ScanResultSummary(string dir) => ScanDirectory = dir; public void AddResult(ScanResultItem item) { var prefabResult = PrefabResults.Find(p => p.PrefabPath == item.PrefabPath); if (prefabResult == null) { prefabResult = new PrefabScanResult(item.PrefabPath, item.PrefabGUID); PrefabResults.Add(prefabResult); } prefabResult.Items.Add(item); AdaptersWithMissingConfig++; } } // ======================== TreeView 实现 ======================== public class MetadataTreeViewItem : TreeViewItem { public object Metadata { get; } public MetadataTreeViewItem(int id, string name, object meta) : base(id, 0, name) => Metadata = meta; } public class ScanResultTreeView : TreeView { private ScanResultSummary m_Summary; public ScanResultTreeView(TreeViewState state, ScanResultSummary summary) : base(state) { m_Summary = summary; Reload(); ExpandAll(); } protected override TreeViewItem BuildRoot() { var root = new TreeViewItem { id = 0, depth = -1, displayName = "Root" }; // 【关键修复 1】:必须初始化 children 列表。 // 否则当扫描结果完美(0个错误)时,root.children 为 null 会导致 Unity 报错 root.children = new List(); if (m_Summary == null || m_Summary.PrefabResults.Count == 0) return root; int itemId = 1; foreach (var prefabResult in m_Summary.PrefabResults) { string prefabName = Path.GetFileNameWithoutExtension(prefabResult.PrefabPath); var prefabItem = new MetadataTreeViewItem(itemId++, $"{prefabName} ({prefabResult.Items.Count}个问题)", prefabResult); foreach (var adapterItem in prefabResult.Items) { var adapterTreeItem = new MetadataTreeViewItem(itemId++, adapterItem.GetDisplayName(), adapterItem); prefabItem.AddChild(adapterTreeItem); } root.AddChild(prefabItem); } // 【关键修复 2】:Unity 官方规范要求,手动使用 AddChild 构建树之后,必须调用此方法刷新深度和层级关系 SetupDepthsFromParentsAndChildren(root); return root; } protected override void RowGUI(RowGUIArgs args) { var item = args.item as MetadataTreeViewItem; if (item != null && item.Metadata is PrefabScanResult) GUI.Label(args.rowRect, item.displayName, EditorStyles.boldLabel); else if (item != null && item.Metadata is ScanResultItem adapterItem) GUI.Label(args.rowRect, $"{adapterItem.GameObjectPath} (缺少: {string.Join(", ", adapterItem.MissingLanguages)})"); else base.RowGUI(args); } protected override void DoubleClickedItem(int id) { var item = FindItem(id, rootItem) as MetadataTreeViewItem; if (item == null) return; if (item.Metadata is ScanResultItem adapterItem) PingGameObject(adapterItem.PrefabPath, adapterItem.GameObjectPath); else if (item.Metadata is PrefabScanResult prefabResult) { var obj = AssetDatabase.LoadAssetAtPath(prefabResult.PrefabPath); if (obj != null) { Selection.activeObject = obj; EditorGUIUtility.PingObject(obj); } } } private void PingGameObject(string prefabPath, string gameObjectPath) { var prefab = AssetDatabase.LoadAssetAtPath(prefabPath); if (prefab == null) return; Transform target = prefab.transform.Find(gameObjectPath); if (target != null) { Selection.activeObject = target.gameObject; EditorGUIUtility.PingObject(target.gameObject); } else { Selection.activeObject = prefab; EditorGUIUtility.PingObject(prefab); Debug.LogWarning($"[ScanTool] 找不到路径 '{gameObjectPath}',已选中整个预制体"); } } } // ======================== 扫描工具主窗口 ======================== public class TextLanguageAdapterScanTool : EditorWindow { private string m_ScanDirectory = "Assets"; private Vector2 m_ScrollPosition; private ScanResultSummary m_ScanResult; private bool m_IsScanning; private float m_ScanProgress; private string m_ScanStatus; private ScanResultTreeView m_TreeView; private TreeViewState m_TreeViewState; // 批量操作的UI状态 private int m_SourceLangIndex = 0; private int m_TargetLangIndex = 0; private bool m_OverwriteExisting = false; [MenuItem("程序/TextLanguageAdapter扫描与管理工具")] public static void ShowWindow() { var window = GetWindow("语言适配器扫描"); window.minSize = new Vector2(600f, 500f); } private void OnEnable() => TextLanguageAdapterHelper.Initialize(); private void OnGUI() { DrawHeader(); EditorGUILayout.Space(5f); DrawScanSettings(); EditorGUILayout.Space(5f); DrawScanButton(); EditorGUILayout.Space(5f); DrawResults(); EditorGUILayout.Space(5f); DrawBatchOperations(); } private void DrawHeader() { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField("TextLanguageAdapter 配置缺失扫描与批量操作工具", EditorStyles.boldLabel); EditorGUILayout.HelpBox("扫描指定目录下所有预制体,检测组件的语言配置是否完整,或执行批量语言配置复制。", MessageType.Info); } } private void DrawScanSettings() { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField("扫描设置", EditorStyles.boldLabel); using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField("目标目录:", GUILayout.Width(80f)); m_ScanDirectory = EditorGUILayout.TextField(m_ScanDirectory); if (GUILayout.Button("选择...", GUILayout.Width(70f))) { string path = EditorUtility.OpenFolderPanel("选择扫描目录", Application.dataPath, ""); if (!string.IsNullOrEmpty(path)) m_ScanDirectory = path.StartsWith(Application.dataPath) ? "Assets" + path.Substring(Application.dataPath.Length) : path; } } EditorGUILayout.Space(5f); EditorGUILayout.LabelField($"预设语言:"); EditorGUILayout.BeginHorizontal(); GUILayout.Space(15f); int displayCount = 0; foreach (var langId in TextLanguageAdapterHelper.PresetLanguageIds) { if (langId == TextLanguageAdapter.DefaultLangId) continue; EditorGUILayout.LabelField(langId, GUILayout.Width(50f)); if (++displayCount % 8 == 0) { EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); GUILayout.Space(15f); } } EditorGUILayout.EndHorizontal(); } } private void DrawScanButton() { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { using (new EditorGUILayout.HorizontalScope()) { using (new EditorGUI.DisabledScope(m_IsScanning || string.IsNullOrEmpty(m_ScanDirectory))) { if (GUILayout.Button("开始扫描", GUILayout.Height(30f))) StartScan(); } if (m_IsScanning) { EditorGUILayout.LabelField("扫描中...", GUILayout.Width(100f)); m_ScanProgress = EditorGUILayout.Slider(m_ScanProgress, 0f, 1f); Repaint(); } } if (!string.IsNullOrEmpty(m_ScanStatus)) EditorGUILayout.LabelField(m_ScanStatus); } } private void DrawResults() { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField("扫描结果", EditorStyles.boldLabel); if (m_ScanResult != null) { EditorGUILayout.LabelField($"预制体总数: {m_ScanResult.TotalPrefabsScanned} | Adapter总数: {m_ScanResult.TotalAdaptersFound} | 缺失配置: {m_ScanResult.AdaptersWithMissingConfig}"); EditorGUILayout.Space(5f); if (m_TreeView != null) { m_ScrollPosition = EditorGUILayout.BeginScrollView(m_ScrollPosition, GUILayout.MinHeight(150f)); var rect = EditorGUILayout.GetControlRect(false, m_TreeView.totalHeight); m_TreeView.OnGUI(rect); EditorGUILayout.EndScrollView(); } EditorGUILayout.Space(5f); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("展开全部")) m_TreeView?.ExpandAll(); if (GUILayout.Button("折叠全部")) m_TreeView?.CollapseAll(); } } else { EditorGUILayout.HelpBox("点击「开始扫描」按钮进行扫描", MessageType.None); } } } // ======================== 新增:批量操作功能 ======================== private void DrawBatchOperations() { using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField("批量操作 (针对目标目录下的所有预制体上的TextLanguageAdapter组件)", EditorStyles.boldLabel); if (TextLanguageAdapterHelper.PresetLanguageIds == null || TextLanguageAdapterHelper.PresetLanguageIds.Length == 0) return; using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField("旧语言:", GUILayout.Width(60f)); m_SourceLangIndex = EditorGUILayout.Popup(m_SourceLangIndex, TextLanguageAdapterHelper.PresetLanguageNames); GUILayout.Space(20f); EditorGUILayout.LabelField("新语言:", GUILayout.Width(60f)); m_TargetLangIndex = EditorGUILayout.Popup(m_TargetLangIndex, TextLanguageAdapterHelper.PresetLanguageNames); } m_OverwriteExisting = EditorGUILayout.Toggle("覆盖已存在的目标配置", m_OverwriteExisting); EditorGUILayout.Space(5f); bool isSameLanguage = m_SourceLangIndex == m_TargetLangIndex; using (new EditorGUI.DisabledScope(isSameLanguage || m_IsScanning || string.IsNullOrEmpty(m_ScanDirectory))) { if (GUILayout.Button("批量复制配置", GUILayout.Height(30f))) { string sourceLang = TextLanguageAdapterHelper.PresetLanguageIds[m_SourceLangIndex]; string targetLang = TextLanguageAdapterHelper.PresetLanguageIds[m_TargetLangIndex]; if (EditorUtility.DisplayDialog("高危操作确认", $"此操作将遍历【{m_ScanDirectory}】下所有预制体。\n\n" + $"把它们的 [{sourceLang}] 配置复制并应用到 [{targetLang}] 配置上。\n\n" + $"此操作不可撤销!建议提前使用 Git/SVN 提交代码。\n确定要继续吗?", "确定执行", "取消")) { ExecuteBatchCopy(sourceLang, targetLang); } } } if (isSameLanguage) { EditorGUILayout.HelpBox("旧语言与新语言不能相同", MessageType.Warning); } } } private void ExecuteBatchCopy(string sourceLang, string targetLang) { string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { m_ScanDirectory }); if (prefabGuids.Length == 0) return; int modifiedPrefabCount = 0; int modifiedAdapterCount = 0; try { for (int i = 0; i < prefabGuids.Length; i++) { string path = AssetDatabase.GUIDToAssetPath(prefabGuids[i]); EditorUtility.DisplayProgressBar("批量复制配置", $"处理中 ({i + 1}/{prefabGuids.Length}): {path}", (float)i / prefabGuids.Length); // 【核心修改】:直接加载资产内存,不使用 LoadPrefabContents 进行实例化 GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(path); bool isModified = false; foreach (var adapter in prefabAsset.GetComponentsInChildren(true)) { if (adapter.HasConfig(sourceLang)) { if (m_OverwriteExisting || !adapter.HasConfig(targetLang)) { // 必须标记对象为脏,否则 Unity 不会把资产的修改存盘 Undo.RecordObject(adapter, "Batch Copy Language Config"); var clonedConfig = adapter.GetConfig(sourceLang).Clone(); adapter.SetConfig(targetLang, clonedConfig); EditorUtility.SetDirty(adapter); isModified = true; modifiedAdapterCount++; } } } if (isModified) { modifiedPrefabCount++; } } } finally { EditorUtility.ClearProgressBar(); // 统一保存所有标记为 Dirty 的资产修改 AssetDatabase.SaveAssets(); PerformScan(); EditorUtility.DisplayDialog("批量操作完成", $"批量复制结束!\n\n修改的预制体数量: {modifiedPrefabCount} 个\n更新的适配器配置数量: {modifiedAdapterCount} 个", "确认"); } } // ======================== 核心扫描逻辑 ======================== private void StartScan() { m_IsScanning = true; m_ScanProgress = 0f; m_ScanStatus = "准备扫描..."; EditorApplication.CallbackFunction updateCallback = null; updateCallback = () => { if (!m_IsScanning) { EditorApplication.update -= updateCallback; return; } PerformScan(); m_IsScanning = false; EditorApplication.update -= updateCallback; }; EditorApplication.update += updateCallback; } private void PerformScan() { m_ScanResult = new ScanResultSummary(m_ScanDirectory); string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { m_ScanDirectory }); m_ScanResult.TotalPrefabsScanned = prefabGuids.Length; if (prefabGuids.Length == 0) { m_ScanStatus = "未找到任何预制体"; return; } for (int i = 0; i < prefabGuids.Length; i++) { string path = AssetDatabase.GUIDToAssetPath(prefabGuids[i]); ScanPrefab(path, prefabGuids[i]); m_ScanProgress = (float)(i + 1) / prefabGuids.Length; m_ScanStatus = $"正在扫描: {Path.GetFileName(path)} ({i + 1}/{prefabGuids.Length})"; if (i % 10 == 0) Repaint(); } m_TreeViewState ??= new TreeViewState(); m_TreeView = new ScanResultTreeView(m_TreeViewState, m_ScanResult); m_ScanStatus = $"扫描完成! 发现 {m_ScanResult.AdaptersWithMissingConfig} 个缺失配置"; Repaint(); } private void ScanPrefab(string path, string guid) { GameObject prefab = AssetDatabase.LoadAssetAtPath(path); if (prefab == null) return; var adapters = prefab.GetComponentsInChildren(true); m_ScanResult.TotalAdaptersFound += adapters.Length; foreach (var adapter in adapters) { List missing = new List(); foreach (var langId in TextLanguageAdapterHelper.PresetLanguageIds) { if (langId == TextLanguageAdapter.DefaultLangId) continue; if (!adapter.HasConfig(langId)) missing.Add(langId); } if (missing.Count > 0) { var item = new ScanResultItem(path, GetGameObjectPath(adapter.gameObject, prefab), adapter.TargetTextType, guid) { MissingLanguages = missing }; m_ScanResult.AddResult(item); } } } private string GetGameObjectPath(GameObject go, GameObject root) { var parts = new List(); Transform curr = go.transform; while (curr != null && curr != root.transform) { parts.Insert(0, curr.name); curr = curr.parent; } return string.Join("/", parts); } }