Files
aibis-dream/Assets/Editor/YarnLocalizationValidation/YarnL10nValidationWindow.cs
T
dingyuntian 7d7c8893fc feat(editor): 添加 Yarn 本地化校验工具
- 新增 Editor 窗口:AIBIS → Yarn 本地化校验
- 新增 BatchMode CLI:Tools/validate_yarn_l10n.py
- 新增文档:Docs/本地化校验工具.md
- 校验 Yarn 原文 string table 与各语言 CSV 的 line id 一致性
2026-07-04 23:12:21 +08:00

472 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public class YarnL10nValidationWindow : EditorWindow
{
private const string MenuPath = "AIBIS/Yarn 本地化校验";
private const string WindowTitle = "Yarn Localization Validator";
private const string FolderPathPrefKey = "AibisDream.YarnL10nValidation.FolderPath";
private const string ScanSubdirsPrefKey = "AibisDream.YarnL10nValidation.ScanSubdirs";
private string _folderPath = "Assets/Yarn/FP/FP_Day1_mid";
private DefaultAsset _folderAsset;
private bool _scanSubdirectories;
private Vector2 _scrollPosition;
private YarnL10nValidationResult _lastResult;
private string _filterType = "全部";
private string _filterLanguage = "全部";
private string _searchText = string.Empty;
private string[] _typeOptions = { "全部" };
private string[] _languageOptions = { "全部" };
private int _filterTypeIndex;
private int _filterLanguageIndex;
[MenuItem(MenuPath)]
public static void Open()
{
var window = GetWindow<YarnL10nValidationWindow>(WindowTitle);
window.minSize = new Vector2(640, 480);
}
private void OnEnable()
{
_folderPath = EditorPrefs.GetString(FolderPathPrefKey, _folderPath);
_scanSubdirectories = EditorPrefs.GetBool(ScanSubdirsPrefKey, false);
SyncFolderAssetFromPath();
}
private void OnGUI()
{
EditorGUILayout.LabelField("Yarn 本地化校验", EditorStyles.boldLabel);
EditorGUILayout.Space(4);
DrawFolderSelector();
EditorGUILayout.Space(4);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("校验", GUILayout.Height(28)))
{
RunValidation();
}
using (new EditorGUI.DisabledScope(_lastResult == null || _lastResult.Issues.Count == 0))
{
if (GUILayout.Button("导出 CSV", GUILayout.Height(28)))
{
ExportReport();
}
}
if (GUILayout.Button("定位文件夹", GUILayout.Height(28)))
{
PingFolder();
}
EditorGUILayout.EndHorizontal();
_scanSubdirectories = EditorGUILayout.ToggleLeft("扫描子目录(批量)", _scanSubdirectories);
if (GUI.changed)
{
EditorPrefs.SetBool(ScanSubdirsPrefKey, _scanSubdirectories);
}
EditorGUILayout.Space(6);
DrawSummary();
DrawFilters();
DrawIssueList();
}
private void DrawFolderSelector()
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("章节文件夹", GUILayout.Width(80));
var newAsset = (DefaultAsset)EditorGUILayout.ObjectField(_folderAsset, typeof(DefaultAsset), false);
if (newAsset != _folderAsset)
{
_folderAsset = newAsset;
if (_folderAsset != null)
{
_folderPath = AssetDatabase.GetAssetPath(_folderAsset);
SaveFolderPreferences();
}
}
var newPath = EditorGUILayout.TextField(_folderPath);
if (!string.Equals(newPath, _folderPath, StringComparison.Ordinal))
{
_folderPath = newPath;
SyncFolderAssetFromPath();
SaveFolderPreferences();
}
if (GUILayout.Button("浏览", GUILayout.Width(60)))
{
var startDirectory = GetBrowseStartDirectory();
var selected = EditorUtility.OpenFolderPanel("选择 Yarn 章节文件夹", startDirectory, string.Empty);
if (!string.IsNullOrEmpty(selected))
{
_folderPath = YarnL10nValidator.NormalizeProjectRelativePath(selected);
SyncFolderAssetFromPath();
SaveFolderPreferences();
}
}
EditorGUILayout.EndHorizontal();
}
private void SaveFolderPreferences()
{
EditorPrefs.SetString(FolderPathPrefKey, _folderPath);
}
private string GetBrowseStartDirectory()
{
var absolutePath = YarnL10nValidator.ToAbsolutePath(_folderPath);
if (!string.IsNullOrEmpty(absolutePath) && !IsFilesystemRoot(absolutePath))
{
var parent = Directory.GetParent(absolutePath);
if (parent != null)
{
return parent.FullName;
}
}
if (!string.IsNullOrEmpty(absolutePath) && Directory.Exists(absolutePath))
{
return absolutePath;
}
var assetsPath = Application.dataPath;
return Directory.Exists(assetsPath) ? assetsPath : Environment.CurrentDirectory;
}
private static bool IsFilesystemRoot(string absolutePath)
{
var normalized = Path.GetFullPath(absolutePath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var root = Path.GetPathRoot(normalized)?
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return !string.IsNullOrEmpty(root) &&
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
}
private void SyncFolderAssetFromPath()
{
_folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(_folderPath);
}
private void RunValidation()
{
SaveFolderPreferences();
_lastResult = _scanSubdirectories
? YarnL10nValidator.ValidateScanAll(_folderPath)
: YarnL10nValidator.ValidateFolder(_folderPath);
RefreshFilterOptions();
Repaint();
}
private void RefreshFilterOptions()
{
if (_lastResult == null || _lastResult.Issues.Count == 0)
{
_typeOptions = new[] { "全部" };
_languageOptions = new[] { "全部" };
}
else
{
var types = _lastResult.Issues
.Select(issue => issue.IssueTypeLabel)
.Where(label => !string.IsNullOrEmpty(label))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(label => label, StringComparer.OrdinalIgnoreCase)
.ToList();
types.Insert(0, "全部");
_typeOptions = types.ToArray();
var languages = _lastResult.Issues
.Select(issue => issue.Language)
.Where(language => !string.IsNullOrEmpty(language))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(language => language, StringComparer.OrdinalIgnoreCase)
.ToList();
languages.Insert(0, "全部");
_languageOptions = languages.ToArray();
}
_filterTypeIndex = ClampFilterIndex(_filterType, _typeOptions, ref _filterType);
_filterLanguageIndex = ClampFilterIndex(_filterLanguage, _languageOptions, ref _filterLanguage);
}
private static int ClampFilterIndex(string currentValue, string[] options, ref string selectedValue)
{
if (options.Length == 0)
{
selectedValue = "全部";
return 0;
}
for (var i = 0; i < options.Length; i++)
{
if (string.Equals(options[i], currentValue, StringComparison.OrdinalIgnoreCase))
{
selectedValue = options[i];
return i;
}
}
selectedValue = options[0];
return 0;
}
private void ExportReport()
{
var defaultName = YarnL10nReportExporter.BuildDefaultExportFileName(_folderPath);
var savePath = EditorUtility.SaveFilePanel("导出校验报告", Application.dataPath, defaultName, "csv");
if (string.IsNullOrEmpty(savePath))
{
return;
}
YarnL10nReportExporter.ExportToCsv(_lastResult, savePath);
EditorUtility.RevealInFinder(savePath);
}
private void PingFolder()
{
SyncFolderAssetFromPath();
if (_folderAsset == null)
{
EditorUtility.DisplayDialog("Yarn 本地化校验", "无法定位文件夹,请检查路径。", "确定");
return;
}
EditorGUIUtility.PingObject(_folderAsset);
Selection.activeObject = _folderAsset;
}
private void DrawSummary()
{
EditorGUILayout.LabelField("摘要", EditorStyles.boldLabel);
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
if (_lastResult == null)
{
EditorGUILayout.LabelField("尚未执行校验。");
return;
}
EditorGUILayout.LabelField($"Issue 总数: {_lastResult.TotalIssueCount}");
EditorGUILayout.LabelField($"章节数: {_lastResult.Chapters.Count}");
foreach (var chapter in _lastResult.Chapters)
{
var localeParts = chapter.Locales.Values
.Select(locale => $"{locale.Language}: 缺 {locale.MissingCount} / 多余 {locale.OrphanCount} / 变更 {locale.SourceChangedCount}")
.ToArray();
var localeSummary = localeParts.Length > 0 ? string.Join(" | ", localeParts) : "无翻译语言";
EditorGUILayout.LabelField($"{chapter.ChapterFolder} — 原文 {chapter.SourceLineCount} 行 | {localeSummary}");
}
}
EditorGUILayout.Space(6);
}
private void DrawFilters()
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("类型", GUILayout.Width(40));
_filterTypeIndex = EditorGUILayout.Popup(_filterTypeIndex, _typeOptions, GUILayout.Width(180));
_filterType = _typeOptions[_filterTypeIndex];
EditorGUILayout.LabelField("语言", GUILayout.Width(40));
_filterLanguageIndex = EditorGUILayout.Popup(_filterLanguageIndex, _languageOptions, GUILayout.Width(100));
_filterLanguage = _languageOptions[_filterLanguageIndex];
EditorGUILayout.LabelField("搜索", GUILayout.Width(40));
_searchText = EditorGUILayout.TextField(_searchText);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
}
private void DrawIssueList()
{
EditorGUILayout.LabelField("结果列表", EditorStyles.boldLabel);
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
if (_lastResult == null || _lastResult.Issues.Count == 0)
{
EditorGUILayout.LabelField(_lastResult == null ? "无结果。" : "未发现 issue。");
}
else
{
var index = 0;
foreach (var issue in FilterIssues(_lastResult.Issues))
{
DrawIssueRow(issue, index);
index++;
}
}
EditorGUILayout.EndScrollView();
}
private IEnumerable<YarnL10nIssue> FilterIssues(IEnumerable<YarnL10nIssue> issues)
{
foreach (var issue in issues)
{
if (!MatchesFilter(issue))
{
continue;
}
yield return issue;
}
}
private bool MatchesFilter(YarnL10nIssue issue)
{
if (!string.IsNullOrWhiteSpace(_filterType) &&
!string.Equals(_filterType, "全部", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(issue.IssueTypeLabel, _filterType, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(_filterLanguage) &&
!string.Equals(_filterLanguage, "全部", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(issue.Language, _filterLanguage, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrWhiteSpace(_searchText))
{
return true;
}
var haystack = string.Join(" ",
issue.GetDisplayType(),
issue.Language,
issue.LineId,
issue.SourceText,
issue.LocText,
issue.YarnFile,
issue.ChapterFolder,
issue.Message);
return haystack.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0;
}
private void DrawIssueRow(YarnL10nIssue issue, int index)
{
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
EditorGUILayout.LabelField($"{issue.GetDisplayType()} | {issue.Language} | {issue.LineId}", EditorStyles.boldLabel);
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
EditorGUILayout.LabelField("章节", issue.ChapterFolder);
}
if (!string.IsNullOrEmpty(issue.YarnFile))
{
EditorGUILayout.LabelField("源文件", $"{issue.YarnFile}:{issue.YarnLineNumber}");
}
if (!string.IsNullOrEmpty(issue.CsvFile))
{
EditorGUILayout.LabelField("CSV", issue.CsvFile);
}
if (!string.IsNullOrEmpty(issue.SourceText))
{
EditorGUILayout.LabelField("原文", issue.SourceText);
}
if (!string.IsNullOrEmpty(issue.LocText))
{
EditorGUILayout.LabelField("翻译", issue.LocText);
}
if (!string.IsNullOrEmpty(issue.Message))
{
EditorGUILayout.LabelField("说明", issue.Message);
}
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("定位", GUILayout.Width(80)))
{
PingIssue(issue);
}
EditorGUILayout.EndHorizontal();
}
}
private void PingIssue(YarnL10nIssue issue)
{
if (issue.Type == YarnL10nIssueType.OrphanLocalization && !string.IsNullOrEmpty(issue.CsvFile))
{
var csvAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(issue.CsvFile);
if (csvAsset != null)
{
EditorGUIUtility.PingObject(csvAsset);
Selection.activeObject = csvAsset;
return;
}
}
if (!string.IsNullOrEmpty(issue.YarnFile))
{
var yarnPath = ResolveYarnAssetPath(issue);
var yarnAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(yarnPath);
if (yarnAsset != null)
{
EditorGUIUtility.PingObject(yarnAsset);
Selection.activeObject = yarnAsset;
if (int.TryParse(issue.YarnLineNumber, out var lineNumber) && lineNumber > 0)
{
var absolutePath = YarnL10nValidator.ToAbsolutePath(yarnPath);
InternalEditorUtility.OpenFileAtLineExternal(absolutePath, lineNumber);
}
return;
}
}
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
var folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(issue.ChapterFolder);
if (folderAsset != null)
{
EditorGUIUtility.PingObject(folderAsset);
Selection.activeObject = folderAsset;
}
}
}
private static string ResolveYarnAssetPath(YarnL10nIssue issue)
{
if (issue.YarnFile.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return issue.YarnFile;
}
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
return $"{issue.ChapterFolder}/{issue.YarnFile}";
}
return issue.YarnFile;
}
}
}