feat(translation): 为缺失文本翻译添加详细上下文信息

扩展翻译服务以收集缺失文本的详细上下文,包括视图路径、元素类型、属性名等。
重构 `ITranslationService` 接口,引入 `TranslationSourceInfo` 类封装上下文信息。
修改 `AutoTranslateInterceptor` 自动收集 UI 元素信息,`JsonTranslationService` 合并多来源上下文。
This commit is contained in:
辉鸭蛋
2026-02-11 23:58:53 +08:00
parent a1c2334951
commit 7dd0e76ff2
5 changed files with 243 additions and 19 deletions

View File

@@ -66,7 +66,7 @@ public sealed class TranslatingSerilogLoggerProvider : ILoggerProvider
} }
var (template, values) = ExtractTemplateAndValues(state, formatter, exception); var (template, values) = ExtractTemplateAndValues(state, formatter, exception);
var translatedTemplate = _translationService.Translate(template, MissingTextSource.Log); var translatedTemplate = _translationService.Translate(template, TranslationSourceInfo.From(MissingTextSource.Log));
if (values.Length == 0) if (values.Length == 0)
{ {

View File

@@ -10,10 +10,30 @@ public enum MissingTextSource
Unknown Unknown
} }
public sealed class TranslationSourceInfo
{
public MissingTextSource Source { get; set; } = MissingTextSource.Unknown;
public string? ViewXamlPath { get; set; }
public string? ViewType { get; set; }
public string? ElementType { get; set; }
public string? ElementName { get; set; }
public string? PropertyName { get; set; }
public string? BindingPath { get; set; }
public string? Notes { get; set; }
public static TranslationSourceInfo From(MissingTextSource source)
{
return new TranslationSourceInfo
{
Source = source
};
}
}
public interface ITranslationService public interface ITranslationService
{ {
string Translate(string text); string Translate(string text);
string Translate(string text, MissingTextSource source); string Translate(string text, TranslationSourceInfo sourceInfo);
CultureInfo GetCurrentCulture(); CultureInfo GetCurrentCulture();
} }

View File

@@ -20,7 +20,7 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
{ {
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly object _sync = new(); private readonly object _sync = new();
private readonly ConcurrentDictionary<string, MissingTextSource> _missingKeys = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, TranslationSourceInfo> _missingKeys = new(StringComparer.Ordinal);
private readonly Timer _flushTimer; private readonly Timer _flushTimer;
private readonly OtherConfig _otherConfig; private readonly OtherConfig _otherConfig;
@@ -56,10 +56,10 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
public string Translate(string text) public string Translate(string text)
{ {
return Translate(text, MissingTextSource.Unknown); return Translate(text, TranslationSourceInfo.From(MissingTextSource.Unknown));
} }
public string Translate(string text, MissingTextSource source) public string Translate(string text, TranslationSourceInfo sourceInfo)
{ {
if (string.IsNullOrEmpty(text)) if (string.IsNullOrEmpty(text))
{ {
@@ -84,10 +84,11 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
return translated; return translated;
} }
var normalizedSource = NormalizeSourceInfo(sourceInfo);
_missingKeys.AddOrUpdate( _missingKeys.AddOrUpdate(
text, text,
source, normalizedSource,
(_, existingSource) => existingSource == MissingTextSource.Unknown ? source : existingSource); (_, existingSource) => MergeSourceInfo(existingSource, normalizedSource));
Interlocked.Exchange(ref _dirtyMissing, 1); Interlocked.Exchange(ref _dirtyMissing, 1);
return text; return text;
} }
@@ -189,18 +190,45 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
foreach (var pair in missingSnapshot) foreach (var pair in missingSnapshot)
{ {
var key = pair.Key; var key = pair.Key;
var source = pair.Value; var sourceInfo = pair.Value;
var source = SourceToString(sourceInfo.Source);
if (!existing.TryGetValue(key, out var existingItem)) if (!existing.TryGetValue(key, out var existingItem))
{ {
existing[key] = new MissingItem(key, string.Empty, SourceToString(source)); existing[key] = new MissingItem
{
Key = key,
Value = string.Empty,
Source = source,
SourceInfo = sourceInfo
};
updated = true; updated = true;
continue; continue;
} }
if (string.IsNullOrWhiteSpace(existingItem.Source) || string.Equals(existingItem.Source, SourceToString(MissingTextSource.Unknown), StringComparison.Ordinal)) if (string.IsNullOrWhiteSpace(existingItem.Source) || string.Equals(existingItem.Source, SourceToString(MissingTextSource.Unknown), StringComparison.Ordinal))
{ {
existing[key] = new MissingItem(key, existingItem.Value ?? string.Empty, SourceToString(source)); existing[key] = new MissingItem
{
Key = key,
Value = existingItem.Value ?? string.Empty,
Source = source,
SourceInfo = sourceInfo
};
updated = true;
continue;
}
var mergedSourceInfo = MergeSourceInfo(existingItem.SourceInfo, sourceInfo);
if (!ReferenceEquals(mergedSourceInfo, existingItem.SourceInfo))
{
existing[key] = new MissingItem
{
Key = key,
Value = existingItem.Value ?? string.Empty,
Source = existingItem.Source ?? source,
SourceInfo = mergedSourceInfo
};
updated = true; updated = true;
} }
} }
@@ -212,7 +240,13 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
var items = existing.Values var items = existing.Values
.OrderBy(i => i.Key, StringComparer.Ordinal) .OrderBy(i => i.Key, StringComparer.Ordinal)
.Select(i => new MissingItem(i.Key, i.Value ?? string.Empty, i.Source ?? SourceToString(MissingTextSource.Unknown))) .Select(i => new MissingItem
{
Key = i.Key,
Value = i.Value ?? string.Empty,
Source = i.Source ?? SourceToString(MissingTextSource.Unknown),
SourceInfo = NormalizeSourceInfo(i.SourceInfo)
})
.ToList(); .ToList();
var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented); var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented);
WriteAtomically(filePath, jsonOut); WriteAtomically(filePath, jsonOut);
@@ -231,16 +265,98 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
continue; continue;
} }
var normalized = new MissingItem( var normalized = new MissingItem
item.Key, {
item.Value ?? string.Empty, Key = item.Key,
string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source); Value = item.Value ?? string.Empty,
Source = string.IsNullOrWhiteSpace(item.Source) ? SourceToString(MissingTextSource.Unknown) : item.Source,
SourceInfo = NormalizeSourceInfo(item.SourceInfo)
};
dict[item.Key] = normalized; dict[item.Key] = normalized;
} }
return dict; return dict;
} }
private static TranslationSourceInfo NormalizeSourceInfo(TranslationSourceInfo? sourceInfo)
{
if (sourceInfo == null)
{
return TranslationSourceInfo.From(MissingTextSource.Unknown);
}
return new TranslationSourceInfo
{
Source = sourceInfo.Source,
ViewXamlPath = sourceInfo.ViewXamlPath,
ViewType = sourceInfo.ViewType,
ElementType = sourceInfo.ElementType,
ElementName = sourceInfo.ElementName,
PropertyName = sourceInfo.PropertyName,
BindingPath = sourceInfo.BindingPath,
Notes = sourceInfo.Notes
};
}
private static TranslationSourceInfo MergeSourceInfo(TranslationSourceInfo? existing, TranslationSourceInfo? incoming)
{
if (incoming == null)
{
return NormalizeSourceInfo(existing);
}
if (existing == null)
{
return NormalizeSourceInfo(incoming);
}
if (existing.Source == MissingTextSource.Unknown && incoming.Source != MissingTextSource.Unknown)
{
return incoming;
}
if (existing.Source != MissingTextSource.Unknown && incoming.Source == MissingTextSource.Unknown)
{
return existing;
}
if (existing.Source != incoming.Source)
{
return existing;
}
var merged = new TranslationSourceInfo
{
Source = existing.Source,
ViewXamlPath = string.IsNullOrWhiteSpace(existing.ViewXamlPath) ? incoming.ViewXamlPath : existing.ViewXamlPath,
ViewType = string.IsNullOrWhiteSpace(existing.ViewType) ? incoming.ViewType : existing.ViewType,
ElementType = string.IsNullOrWhiteSpace(existing.ElementType) ? incoming.ElementType : existing.ElementType,
ElementName = string.IsNullOrWhiteSpace(existing.ElementName) ? incoming.ElementName : existing.ElementName,
PropertyName = string.IsNullOrWhiteSpace(existing.PropertyName) ? incoming.PropertyName : existing.PropertyName,
BindingPath = string.IsNullOrWhiteSpace(existing.BindingPath) ? incoming.BindingPath : existing.BindingPath,
Notes = string.IsNullOrWhiteSpace(existing.Notes) ? incoming.Notes : existing.Notes
};
if (IsSameSourceInfo(merged, existing))
{
return existing;
}
return merged;
}
private static bool IsSameSourceInfo(TranslationSourceInfo left, TranslationSourceInfo right)
{
return left.Source == right.Source
&& string.Equals(left.ViewXamlPath, right.ViewXamlPath, StringComparison.Ordinal)
&& string.Equals(left.ViewType, right.ViewType, StringComparison.Ordinal)
&& string.Equals(left.ElementType, right.ElementType, StringComparison.Ordinal)
&& string.Equals(left.ElementName, right.ElementName, StringComparison.Ordinal)
&& string.Equals(left.PropertyName, right.PropertyName, StringComparison.Ordinal)
&& string.Equals(left.BindingPath, right.BindingPath, StringComparison.Ordinal)
&& string.Equals(left.Notes, right.Notes, StringComparison.Ordinal);
}
private static string SourceToString(MissingTextSource source) private static string SourceToString(MissingTextSource source)
{ {
return source switch return source switch
@@ -320,7 +436,13 @@ public sealed class JsonTranslationService : ITranslationService, IDisposable
FlushMissingIfDirty(); FlushMissingIfDirty();
} }
private sealed record MissingItem(string Key, string Value, string Source); private sealed class MissingItem
{
public string Key { get; set; } = string.Empty;
public string? Value { get; set; }
public string? Source { get; set; }
public TranslationSourceInfo? SourceInfo { get; set; }
}
private void OnOtherConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnOtherConfigPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {

View File

@@ -525,7 +525,7 @@ namespace BetterGenshinImpact.View.Behavior
} }
} }
var translated = translator.Translate(original, MissingTextSource.UiStaticLiteral); var translated = translator.Translate(original, BuildSourceInfo(obj, property, MissingTextSource.UiStaticLiteral));
if (!ReferenceEquals(value, translated) && !string.Equals(value, translated, StringComparison.Ordinal)) if (!ReferenceEquals(value, translated) && !string.Equals(value, translated, StringComparison.Ordinal))
{ {
obj.SetValue(property, translated); obj.SetValue(property, translated);
@@ -599,7 +599,7 @@ namespace BetterGenshinImpact.View.Behavior
} }
} }
var translated = translator.Translate(original, MissingTextSource.UiStaticLiteral); var translated = translator.Translate(original, BuildSourceInfo(obj, property, MissingTextSource.UiStaticLiteral));
if (!ReferenceEquals(currentValue, translated) && !string.Equals(currentValue, translated, StringComparison.Ordinal)) if (!ReferenceEquals(currentValue, translated) && !string.Equals(currentValue, translated, StringComparison.Ordinal))
{ {
setter(translated); setter(translated);
@@ -691,6 +691,85 @@ namespace BetterGenshinImpact.View.Behavior
BindingOperations.GetBindingExpressionBase(obj, property)?.UpdateTarget(); BindingOperations.GetBindingExpressionBase(obj, property)?.UpdateTarget();
} }
} }
private static TranslationSourceInfo BuildSourceInfo(DependencyObject element, DependencyProperty property, MissingTextSource source)
{
var viewElement = FindViewElement(element);
var viewType = viewElement?.GetType();
var xamlPath = GetViewXamlPath(viewType);
return new TranslationSourceInfo
{
Source = source,
ViewXamlPath = xamlPath,
ViewType = viewType?.FullName,
ElementType = element.GetType().FullName,
ElementName = GetElementName(element),
PropertyName = property.Name
};
}
private static DependencyObject? FindViewElement(DependencyObject? element)
{
var current = element;
while (current != null)
{
if (current is Window || current is Page || current is UserControl)
{
return current;
}
var parent = LogicalTreeHelper.GetParent(current);
if (parent == null && current is FrameworkElement fe)
{
parent = fe.Parent ?? fe.TemplatedParent as DependencyObject;
}
if (parent == null && current is FrameworkContentElement fce)
{
parent = fce.Parent;
}
if (parent == null)
{
parent = VisualTreeHelper.GetParent(current);
}
current = parent;
}
return null;
}
private static string? GetViewXamlPath(Type? viewType)
{
if (viewType == null)
{
return null;
}
var ns = viewType.Namespace ?? string.Empty;
const string viewMarker = ".View.";
var index = ns.IndexOf(viewMarker, StringComparison.Ordinal);
if (index < 0)
{
return null;
}
var relativeNamespace = ns[(index + viewMarker.Length)..];
var folder = string.IsNullOrWhiteSpace(relativeNamespace) ? "View" : $"View/{relativeNamespace.Replace('.', '/')}";
return $"{folder}/{viewType.Name}.xaml";
}
private static string? GetElementName(DependencyObject element)
{
return element switch
{
FrameworkElement fe when !string.IsNullOrWhiteSpace(fe.Name) => fe.Name,
FrameworkContentElement fce when !string.IsNullOrWhiteSpace(fce.Name) => fce.Name,
_ => null
};
}
} }
} }
} }

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Windows;
using System.Windows.Data; using System.Windows.Data;
using BetterGenshinImpact.Service.Interface; using BetterGenshinImpact.Service.Interface;
@@ -16,11 +17,13 @@ public sealed class TrConverter : IValueConverter
} }
var translator = App.GetService<ITranslationService>(); var translator = App.GetService<ITranslationService>();
return translator?.Translate(s, MissingTextSource.UiDynamicBinding) ?? s; var source = parameter is MissingTextSource sourceParam ? sourceParam : MissingTextSource.UiDynamicBinding;
return translator?.Translate(s, TranslationSourceInfo.From(source)) ?? s;
} }
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{ {
return value ?? string.Empty; return value ?? string.Empty;
} }
} }