diff --git a/BetterGenshinImpact/App.xaml b/BetterGenshinImpact/App.xaml index a9c8ca584e..dd12713789 100644 --- a/BetterGenshinImpact/App.xaml +++ b/BetterGenshinImpact/App.xaml @@ -25,6 +25,7 @@ + diff --git a/BetterGenshinImpact/App.xaml.cs b/BetterGenshinImpact/App.xaml.cs index b4a1f38caa..02f735bd5b 100644 --- a/BetterGenshinImpact/App.xaml.cs +++ b/BetterGenshinImpact/App.xaml.cs @@ -86,7 +86,13 @@ public partial class App : Application } Log.Logger = loggerConfiguration.CreateLogger(); - services.AddLogging(c => c.AddSerilog()); + services.AddSingleton(); + services.AddSingleton(); + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.Services.AddSingleton(); + }); services.AddLocalization(); diff --git a/BetterGenshinImpact/BetterGenshinImpact.csproj b/BetterGenshinImpact/BetterGenshinImpact.csproj index 056924e35f..c3ebcbb766 100644 --- a/BetterGenshinImpact/BetterGenshinImpact.csproj +++ b/BetterGenshinImpact/BetterGenshinImpact.csproj @@ -84,6 +84,7 @@ + @@ -200,6 +201,9 @@ Always + + PreserveNewest + diff --git a/BetterGenshinImpact/Core/Config/OtherConfig.cs b/BetterGenshinImpact/Core/Config/OtherConfig.cs index 491e68886b..ac65d2b77b 100644 --- a/BetterGenshinImpact/Core/Config/OtherConfig.cs +++ b/BetterGenshinImpact/Core/Config/OtherConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using BetterGenshinImpact.Core.Recognition; using BetterGenshinImpact.Model; using CommunityToolkit.Mvvm.ComponentModel; @@ -121,4 +121,4 @@ public partial class Ocr : ObservableObject /// [ObservableProperty] private string _uiCultureInfoName = "zh-Hans"; -} \ No newline at end of file +} diff --git a/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs b/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs index 4d411ff0db..75266cd1fa 100644 --- a/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs +++ b/BetterGenshinImpact/Core/Script/ScriptRepoUpdater.cs @@ -21,7 +21,6 @@ using System.Net.Http; using System.Threading.Tasks; using System.Windows; -using Windows.UI.Xaml.Automation; using BetterGenshinImpact.View.Windows; using LibGit2Sharp; using LibGit2Sharp.Handlers; diff --git a/BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs b/BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs new file mode 100644 index 0000000000..6b72fc443e --- /dev/null +++ b/BetterGenshinImpact/Helpers/TranslatingSerilogLoggerProvider.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BetterGenshinImpact.Service.Interface; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; + +namespace BetterGenshinImpact.Helpers; + +public sealed class TranslatingSerilogLoggerProvider : ILoggerProvider +{ + private readonly ITranslationService _translationService; + + public TranslatingSerilogLoggerProvider(ITranslationService translationService) + { + _translationService = translationService; + } + + public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) + { + return new TranslatingSerilogLogger(categoryName, _translationService); + } + + public void Dispose() + { + } + + private sealed class TranslatingSerilogLogger : Microsoft.Extensions.Logging.ILogger + { + private readonly ITranslationService _translationService; + private readonly Serilog.ILogger _logger; + + public TranslatingSerilogLogger(string categoryName, ITranslationService translationService) + { + _translationService = translationService; + _logger = Serilog.Log.Logger.ForContext("SourceContext", categoryName); + } + + public IDisposable BeginScope(TState state) where TState : notnull + { + return NullScope.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel != LogLevel.None; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var serilogLevel = ConvertLevel(logLevel); + if (serilogLevel == null) + { + return; + } + + var (template, values) = ExtractTemplateAndValues(state, formatter, exception); + var translatedTemplate = _translationService.Translate(template, TranslationSourceInfo.From(MissingTextSource.Log)); + + if (values.Length == 0) + { + _logger.Write(serilogLevel.Value, exception, translatedTemplate); + return; + } + + _logger.Write(serilogLevel.Value, exception, translatedTemplate, values); + } + + private (string Template, object?[] Values) ExtractTemplateAndValues( + TState state, + Func formatter, + Exception? exception) + { + if (state is IReadOnlyList> kvps) + { + var original = kvps.FirstOrDefault(kv => string.Equals(kv.Key, "{OriginalFormat}", StringComparison.Ordinal)); + var template = original.Value as string; + if (string.IsNullOrEmpty(template)) + { + template = formatter(state, exception); + } + + var values = kvps + .Where(kv => + !string.Equals(kv.Key, "{OriginalFormat}", StringComparison.Ordinal) && + !string.Equals(kv.Key, "EventId", StringComparison.Ordinal)) + .Select(kv => kv.Value) + .ToArray(); + + return (template ?? string.Empty, values); + } + + return (formatter(state, exception), Array.Empty()); + } + + private static LogEventLevel? ConvertLevel(LogLevel level) + { + return level switch + { + LogLevel.Trace => LogEventLevel.Verbose, + LogLevel.Debug => LogEventLevel.Debug, + LogLevel.Information => LogEventLevel.Information, + LogLevel.Warning => LogEventLevel.Warning, + LogLevel.Error => LogEventLevel.Error, + LogLevel.Critical => LogEventLevel.Fatal, + _ => null + }; + } + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } + } +} diff --git a/BetterGenshinImpact/Service/Interface/IMissingTranslationReporter.cs b/BetterGenshinImpact/Service/Interface/IMissingTranslationReporter.cs new file mode 100644 index 0000000000..5673ead1db --- /dev/null +++ b/BetterGenshinImpact/Service/Interface/IMissingTranslationReporter.cs @@ -0,0 +1,6 @@ +namespace BetterGenshinImpact.Service.Interface; + +public interface IMissingTranslationReporter +{ + bool TryEnqueue(string language, string key, TranslationSourceInfo sourceInfo); +} diff --git a/BetterGenshinImpact/Service/Interface/ITranslationService.cs b/BetterGenshinImpact/Service/Interface/ITranslationService.cs new file mode 100644 index 0000000000..a890ac8c20 --- /dev/null +++ b/BetterGenshinImpact/Service/Interface/ITranslationService.cs @@ -0,0 +1,40 @@ +using System.Globalization; + +namespace BetterGenshinImpact.Service.Interface; + +public enum MissingTextSource +{ + Log, + UiStaticLiteral, + UiDynamicBinding, + 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 +{ + string Translate(string text); + string Translate(string text, TranslationSourceInfo sourceInfo); + CultureInfo GetCurrentCulture(); + void Reload(); +} + diff --git a/BetterGenshinImpact/Service/JsonTranslationService.cs b/BetterGenshinImpact/Service/JsonTranslationService.cs new file mode 100644 index 0000000000..a28cb06e83 --- /dev/null +++ b/BetterGenshinImpact/Service/JsonTranslationService.cs @@ -0,0 +1,687 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Service.Interface; +using CommunityToolkit.Mvvm.Messaging; +using CommunityToolkit.Mvvm.Messaging.Messages; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BetterGenshinImpact.Service; + +public sealed class JsonTranslationService : ITranslationService, IDisposable +{ + private readonly IConfigService _configService; + private readonly IMissingTranslationReporter _missingTranslationReporter; + private readonly object _sync = new(); + private readonly ConcurrentDictionary _missingKeys = new(StringComparer.Ordinal); + private readonly Timer _flushTimer; + private readonly OtherConfig _otherConfig; + + private string _loadedCultureName = string.Empty; + private IReadOnlyDictionary _map = new Dictionary(StringComparer.Ordinal); + private volatile IReadOnlySet _existingMissingKeys = new HashSet(StringComparer.Ordinal); + private int _dirtyMissing; + + public JsonTranslationService(IConfigService configService, IMissingTranslationReporter missingTranslationReporter) + { + _configService = configService; + _missingTranslationReporter = missingTranslationReporter; + _otherConfig = _configService.Get().OtherConfig; + _otherConfig.PropertyChanged += OnOtherConfigPropertyChanged; + _flushTimer = new Timer(_ => FlushMissingIfDirty(), null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3)); + } + + public CultureInfo GetCurrentCulture() + { + var name = _configService.Get().OtherConfig.UiCultureInfoName; + if (string.IsNullOrWhiteSpace(name)) + { + return CultureInfo.InvariantCulture; + } + + try + { + return new CultureInfo(name); + } + catch + { + return CultureInfo.InvariantCulture; + } + } + + public void Reload() + { + var cultureName = _otherConfig.UiCultureInfoName ?? string.Empty; + string previousLoaded; + string currentLoaded; + + lock (_sync) + { + previousLoaded = _loadedCultureName; + FlushMissingIfDirty(previousLoaded); + + if (string.IsNullOrWhiteSpace(cultureName) || IsChineseCultureName(cultureName)) + { + _loadedCultureName = string.Empty; + _map = new Dictionary(StringComparer.Ordinal); + _existingMissingKeys = new HashSet(StringComparer.Ordinal); + } + else + { + _loadedCultureName = cultureName; + _map = LoadMap(cultureName); + _existingMissingKeys = LoadExistingMissingKeys(cultureName); + } + + _missingKeys.Clear(); + Interlocked.Exchange(ref _dirtyMissing, 0); + currentLoaded = _loadedCultureName; + } + + WeakReferenceMessenger.Default.Send( + new PropertyChangedMessage(this, nameof(OtherConfig.UiCultureInfoName), previousLoaded, currentLoaded)); + } + + public string Translate(string text) + { + return Translate(text, TranslationSourceInfo.From(MissingTextSource.Unknown)); + } + + public string Translate(string text, TranslationSourceInfo sourceInfo) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + if (!ContainsCjk(text)) + { + return text; + } + + var culture = GetCurrentCulture(); + if (IsChineseCulture(culture)) + { + return text; + } + + if (string.IsNullOrWhiteSpace(culture.Name)) + { + return text; + } + + EnsureMapLoaded(culture.Name); + + if (_map.TryGetValue(text, out var translated) && !string.IsNullOrWhiteSpace(translated)) + { + return translated; + } + + var normalizedSource = NormalizeSourceInfo(sourceInfo); + _missingKeys.AddOrUpdate( + text, + normalizedSource, + (_, existingSource) => MergeSourceInfo(existingSource, normalizedSource)); + Interlocked.Exchange(ref _dirtyMissing, 1); + if (!_existingMissingKeys.Contains(text)) + { + _missingTranslationReporter.TryEnqueue(culture.Name, text, normalizedSource); + } + return text; + } + + private void EnsureMapLoaded(string cultureName) + { + if (string.Equals(_loadedCultureName, cultureName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + lock (_sync) + { + if (string.Equals(_loadedCultureName, cultureName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var previousCultureName = _loadedCultureName; + FlushMissingIfDirty(previousCultureName); + _map = LoadMap(cultureName); + _loadedCultureName = cultureName; + _existingMissingKeys = LoadExistingMissingKeys(cultureName); + _missingKeys.Clear(); + Interlocked.Exchange(ref _dirtyMissing, 0); + } + } + + private IReadOnlyDictionary LoadMap(string cultureName) + { + var path = GetMapFilePath(cultureName); + if (!File.Exists(path)) + { + return new Dictionary(StringComparer.Ordinal); + } + + try + { + var json = File.ReadAllText(path, Encoding.UTF8); + var dict = JsonConvert.DeserializeObject>(json) ?? new Dictionary(); + return new Dictionary(dict, StringComparer.Ordinal); + } + catch (Exception e) + { + Debug.WriteLine(e); + return new Dictionary(StringComparer.Ordinal); + } + } + + private void FlushMissingIfDirty() + { + FlushMissingIfDirty(_loadedCultureName); + } + + private void FlushMissingIfDirty(string cultureName) + { + if (string.IsNullOrWhiteSpace(cultureName)) + { + Interlocked.Exchange(ref _dirtyMissing, 0); + return; + } + + if (IsChineseCultureName(cultureName)) + { + Interlocked.Exchange(ref _dirtyMissing, 0); + return; + } + + if (Interlocked.Exchange(ref _dirtyMissing, 0) == 0) + { + return; + } + + try + { + FlushMissing(cultureName); + } + catch + { + Interlocked.Exchange(ref _dirtyMissing, 1); + } + } + + private void FlushMissing(string cultureName) + { + var missingSnapshot = _missingKeys.ToArray(); + if (missingSnapshot.Length == 0) + { + return; + } + + var filePath = GetMissingFilePath(cultureName); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + + Dictionary existing; + try + { + existing = File.Exists(filePath) ? LoadMissing(filePath) : new Dictionary(StringComparer.Ordinal); + } + catch + { + existing = new Dictionary(StringComparer.Ordinal); + } + + var updated = false; + foreach (var pair in missingSnapshot) + { + var key = pair.Key; + var sourceInfo = pair.Value; + var source = SourceToCompactString(sourceInfo.Source); + var missingSourceInfo = StripSource(sourceInfo); + + if (!existing.TryGetValue(key, out var existingItem)) + { + existing[key] = new MissingItem + { + Key = key, + Value = string.Empty, + Source = source, + SourceInfo = missingSourceInfo + }; + updated = true; + continue; + } + + if (string.IsNullOrWhiteSpace(existingItem.Source) || string.Equals(existingItem.Source, SourceToCompactString(MissingTextSource.Unknown), StringComparison.Ordinal)) + { + existing[key] = new MissingItem + { + Key = key, + Value = existingItem.Value ?? string.Empty, + Source = source, + SourceInfo = missingSourceInfo + }; + updated = true; + continue; + } + + var mergedSourceInfo = MergeSourceInfo(existingItem.SourceInfo, missingSourceInfo); + if (!ReferenceEquals(mergedSourceInfo, existingItem.SourceInfo)) + { + existing[key] = new MissingItem + { + Key = key, + Value = existingItem.Value ?? string.Empty, + Source = existingItem.Source ?? source, + SourceInfo = mergedSourceInfo + }; + updated = true; + } + } + + if (!updated) + { + return; + } + + var items = existing.Values + .OrderBy(i => i.Key, StringComparer.Ordinal) + .Select(i => new MissingItem + { + Key = i.Key, + Value = i.Value ?? string.Empty, + Source = string.IsNullOrWhiteSpace(i.Source) ? SourceToCompactString(MissingTextSource.Unknown) : i.Source, + SourceInfo = NormalizeSourceInfoForMissing(i.SourceInfo) + }) + .ToList(); + var jsonOut = JsonConvert.SerializeObject(items, Formatting.Indented); + WriteAtomically(filePath, jsonOut); + } + + private static Dictionary LoadMissing(string filePath) + { + var json = File.ReadAllText(filePath, Encoding.UTF8); + var list = JsonConvert.DeserializeObject>(json) ?? []; + + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var item in list) + { + if (string.IsNullOrWhiteSpace(item.Key)) + { + continue; + } + + var normalized = new MissingItem + { + Key = item.Key, + Value = item.Value ?? string.Empty, + Source = string.IsNullOrWhiteSpace(item.Source) ? SourceToCompactString(MissingTextSource.Unknown) : item.Source, + SourceInfo = NormalizeSourceInfoForMissing(item.SourceInfo) + }; + dict[item.Key] = normalized; + } + + return dict; + } + + private static IReadOnlySet LoadExistingMissingKeys(string cultureName) + { + var filePath = GetMissingFilePath(cultureName); + if (!File.Exists(filePath)) + { + return new HashSet(StringComparer.Ordinal); + } + + try + { + return new HashSet(LoadMissing(filePath).Keys, StringComparer.Ordinal); + } + catch + { + return new HashSet(StringComparer.Ordinal); + } + } + + 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 NormalizeSourceInfoForMissing(TranslationSourceInfo? sourceInfo) + { + return StripSource(NormalizeSourceInfo(sourceInfo)); + } + + private static TranslationSourceInfo StripSource(TranslationSourceInfo sourceInfo) + { + return new TranslationSourceInfo + { + Source = MissingTextSource.Unknown, + 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 SourceToCompactString(MissingTextSource source) + { + return ((int)source).ToString(CultureInfo.InvariantCulture); + } + + private static void WriteAtomically(string filePath, string content) + { + var directory = Path.GetDirectoryName(filePath)!; + Directory.CreateDirectory(directory); + var tmp = Path.Combine(directory, $"{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp"); + File.WriteAllText(tmp, content, Encoding.UTF8); + + if (File.Exists(filePath)) + { + File.Replace(tmp, filePath, null); + } + else + { + File.Move(tmp, filePath); + } + } + + private static bool IsChineseCulture(CultureInfo culture) + { + if (culture == CultureInfo.InvariantCulture) + { + return false; + } + + var name = culture.Name; + return name.StartsWith("zh", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsChineseCultureName(string cultureName) + { + return cultureName.StartsWith("zh", StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsCjk(string text) + { + foreach (var ch in text) + { + if ((ch >= 0x4E00 && ch <= 0x9FFF) || (ch >= 0x3400 && ch <= 0x4DBF)) + { + return true; + } + } + + return false; + } + + private static string GetI18nDirectory() + { + return Global.Absolute(@"User\I18n"); + } + + private static string GetMapFilePath(string cultureName) + { + return Path.Combine(GetI18nDirectory(), $"{cultureName}.json"); + } + + private static string GetMissingFilePath(string cultureName) + { + return Path.Combine(GetI18nDirectory(), $"missing.{cultureName}.json"); + } + + public void Dispose() + { + _flushTimer.Dispose(); + _otherConfig.PropertyChanged -= OnOtherConfigPropertyChanged; + FlushMissingIfDirty(); + } + + private sealed class MissingItem + { + public string Key { get; set; } = string.Empty; + public string? Value { get; set; } + [JsonConverter(typeof(MissingSourceStringConverter))] + public string? Source { get; set; } + [JsonConverter(typeof(MissingSourceInfoWithoutSourceConverter))] + public TranslationSourceInfo? SourceInfo { get; set; } + } + + private sealed class MissingSourceStringConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, string? value, JsonSerializer serializer) + { + if (string.IsNullOrEmpty(value)) + { + writer.WriteNull(); + return; + } + + writer.WriteValue(value); + } + + public override string? ReadJson(JsonReader reader, Type objectType, string? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + + if (reader.TokenType == JsonToken.Integer) + { + try + { + return Convert.ToInt32(reader.Value).ToString(CultureInfo.InvariantCulture); + } + catch + { + return SourceToCompactString(MissingTextSource.Unknown); + } + } + + if (reader.TokenType == JsonToken.String) + { + var s = reader.Value as string; + if (string.IsNullOrWhiteSpace(s)) + { + return SourceToCompactString(MissingTextSource.Unknown); + } + + if (int.TryParse(s, out var parsed)) + { + return parsed.ToString(CultureInfo.InvariantCulture); + } + + return s switch + { + "Log" => SourceToCompactString(MissingTextSource.Log), + "UiStaticLiteral" => SourceToCompactString(MissingTextSource.UiStaticLiteral), + "UiDynamicBinding" => SourceToCompactString(MissingTextSource.UiDynamicBinding), + _ => SourceToCompactString(MissingTextSource.Unknown) + }; + } + + return SourceToCompactString(MissingTextSource.Unknown); + } + } + + private sealed class MissingSourceInfoWithoutSourceConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, TranslationSourceInfo? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + writer.WriteStartObject(); + WriteIfNotNull(writer, "ViewXamlPath", value.ViewXamlPath); + WriteIfNotNull(writer, "ViewType", value.ViewType); + WriteIfNotNull(writer, "ElementType", value.ElementType); + WriteIfNotNull(writer, "ElementName", value.ElementName); + WriteIfNotNull(writer, "PropertyName", value.PropertyName); + WriteIfNotNull(writer, "BindingPath", value.BindingPath); + WriteIfNotNull(writer, "Notes", value.Notes); + writer.WriteEndObject(); + } + + public override TranslationSourceInfo? ReadJson(JsonReader reader, Type objectType, TranslationSourceInfo? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + + var obj = JObject.Load(reader); + return new TranslationSourceInfo + { + Source = MissingTextSource.Unknown, + ViewXamlPath = obj.Value("ViewXamlPath") ?? obj.Value("viewXamlPath"), + ViewType = obj.Value("ViewType") ?? obj.Value("viewType"), + ElementType = obj.Value("ElementType") ?? obj.Value("elementType"), + ElementName = obj.Value("ElementName") ?? obj.Value("elementName"), + PropertyName = obj.Value("PropertyName") ?? obj.Value("propertyName"), + BindingPath = obj.Value("BindingPath") ?? obj.Value("bindingPath"), + Notes = obj.Value("Notes") ?? obj.Value("notes") + }; + } + + private static void WriteIfNotNull(JsonWriter writer, string propertyName, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + writer.WritePropertyName(propertyName); + writer.WriteValue(value); + } + } + + private void OnOtherConfigPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(OtherConfig.UiCultureInfoName)) + { + return; + } + + var cultureName = _otherConfig.UiCultureInfoName ?? string.Empty; + string previousLoaded; + string currentLoaded; + + lock (_sync) + { + previousLoaded = _loadedCultureName; + FlushMissingIfDirty(previousLoaded); + + if (string.IsNullOrWhiteSpace(cultureName) || IsChineseCultureName(cultureName)) + { + _loadedCultureName = string.Empty; + _map = new Dictionary(StringComparer.Ordinal); + _existingMissingKeys = new HashSet(StringComparer.Ordinal); + } + else + { + _loadedCultureName = cultureName; + _map = LoadMap(cultureName); + _existingMissingKeys = LoadExistingMissingKeys(cultureName); + } + + _missingKeys.Clear(); + Interlocked.Exchange(ref _dirtyMissing, 0); + currentLoaded = _loadedCultureName; + } + + if (!string.Equals(previousLoaded, currentLoaded, StringComparison.OrdinalIgnoreCase)) + { + WeakReferenceMessenger.Default.Send( + new PropertyChangedMessage(this, nameof(OtherConfig.UiCultureInfoName), previousLoaded, currentLoaded)); + } + } +} diff --git a/BetterGenshinImpact/Service/MissingTranslationCollectionSettings.cs b/BetterGenshinImpact/Service/MissingTranslationCollectionSettings.cs new file mode 100644 index 0000000000..eb2de236d4 --- /dev/null +++ b/BetterGenshinImpact/Service/MissingTranslationCollectionSettings.cs @@ -0,0 +1,22 @@ +using System; + +namespace BetterGenshinImpact.Service; + +public static class MissingTranslationCollectionSettings +{ + public static readonly bool Enabled = true; + public static readonly string SupabaseUrl = "https://obwddvnwzaolbdawduxg.supabase.co"; + public static readonly string SupabaseApiKey = "sb_publishable_PyvQSxxCi02aawC-G6vtgg_wzOctlgm"; + public static readonly string Table = "translation_missing"; + public static readonly int BatchSize = 200; + public static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5); + + public static bool IsValid => + Enabled + && !string.IsNullOrWhiteSpace(SupabaseUrl) + && !string.IsNullOrWhiteSpace(SupabaseApiKey) + && !string.IsNullOrWhiteSpace(Table) + && BatchSize > 0 + && FlushInterval > TimeSpan.Zero; +} + diff --git a/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs b/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs new file mode 100644 index 0000000000..48db465f4b --- /dev/null +++ b/BetterGenshinImpact/Service/SupabaseMissingTranslationReporter.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Diagnostics; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using BetterGenshinImpact.Helpers.Http; +using BetterGenshinImpact.Service.Interface; + +namespace BetterGenshinImpact.Service; + +public sealed class SupabaseMissingTranslationReporter : IMissingTranslationReporter, IDisposable +{ + private readonly Channel _channel; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _worker; + + public SupabaseMissingTranslationReporter() + { + _channel = Channel.CreateBounded( + new BoundedChannelOptions(10_000) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropWrite + }); + + _worker = Task.Run(() => WorkerAsync(_cts.Token), _cts.Token); + } + + public bool TryEnqueue(string language, string key, TranslationSourceInfo sourceInfo) + { + if (!MissingTranslationCollectionSettings.IsValid) + { + return false; + } + + if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(key)) + { + return false; + } + + return _channel.Writer.TryWrite( + new MissingTranslationEvent( + language, + key, + SourceToCompactString(sourceInfo.Source), + NormalizeSourceInfoForMissing(sourceInfo))); + } + + private async Task WorkerAsync(CancellationToken token) + { + var pending = new Dictionary(StringComparer.Ordinal); + + using var timer = new PeriodicTimer(MissingTranslationCollectionSettings.FlushInterval); + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + while (_channel.Reader.TryRead(out var ev)) + { + var k = MakeKey(ev.Language, ev.Key); + if (!pending.TryGetValue(k, out var existing)) + { + pending[k] = new MissingTranslationUpsertRow(ev.Language, ev.Key, ev.Source, ev.SourceInfo); + continue; + } + + pending[k] = existing.Merge(ev.Source, ev.SourceInfo); + } + + if (!MissingTranslationCollectionSettings.IsValid) + { + pending.Clear(); + continue; + } + + if (pending.Count > 0) + { + await FlushAsync(pending, token).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + + private async Task FlushAsync(Dictionary pending, CancellationToken token) + { + while (pending.Count > 0 && MissingTranslationCollectionSettings.IsValid && !token.IsCancellationRequested) + { + var batch = pending.Values.Take(MissingTranslationCollectionSettings.BatchSize).ToList(); + if (batch.Count == 0) + { + return; + } + + foreach (var row in batch) + { + pending.Remove(MakeKey(row.Language, row.Key)); + } + + var ok = await TryUpsertBatchAsync(batch, token).ConfigureAwait(false); + if (!ok) + { + foreach (var row in batch) + { + pending[MakeKey(row.Language, row.Key)] = row; + } + + return; + } + } + } + + private async Task TryUpsertBatchAsync(IReadOnlyList batch, CancellationToken token) + { + try + { + if (!MissingTranslationCollectionSettings.IsValid) + { + return false; + } + + var client = HttpClientFactory.GetClient( + "SupabaseMissingTranslation", + () => + { + var http = new HttpClient + { + Timeout = TimeSpan.FromSeconds(10) + }; + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return http; + }); + + var url = $"{MissingTranslationCollectionSettings.SupabaseUrl.TrimEnd('/')}/rest/v1/{MissingTranslationCollectionSettings.Table}"; + var requestUri = $"{url}?on_conflict=language,key"; + + var payload = JsonSerializer.Serialize( + batch.Select(r => new SupabaseMissingRowSnake(r.Language, r.Key, r.Source, r.SourceInfo)), + SupabaseJsonOptions); + + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + request.Headers.TryAddWithoutValidation("apikey", MissingTranslationCollectionSettings.SupabaseApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MissingTranslationCollectionSettings.SupabaseApiKey); + request.Headers.TryAddWithoutValidation("Prefer", "resolution=merge-duplicates,return=minimal"); + + using var response = await client.SendAsync(request, token).ConfigureAwait(false); + var responseText = string.Empty; + try + { + responseText = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false); + } + catch + { + } + + Debug.WriteLine( + $"[MissingTranslation][Supabase] status={(int)response.StatusCode} {response.StatusCode}, batch={batch.Count}, table={MissingTranslationCollectionSettings.Table}, body={TruncateForLog(responseText, 2000)}"); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public void Dispose() + { + _channel.Writer.TryComplete(); + _cts.Cancel(); + try + { + _worker.Wait(TimeSpan.FromSeconds(1)); + } + catch + { + } + _cts.Dispose(); + } + + private static string MakeKey(string language, string key) + { + return $"{language}\u001F{key}"; + } + + private static string TruncateForLog(string? text, int maxLength) + { + if (string.IsNullOrEmpty(text) || maxLength <= 0) + { + return string.Empty; + } + + if (text.Length <= maxLength) + { + return text; + } + + return text.Substring(0, maxLength) + "...(truncated)"; + } + + 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 NormalizeSourceInfoForMissing(TranslationSourceInfo? sourceInfo) + { + return StripSource(NormalizeSourceInfo(sourceInfo)); + } + + private static TranslationSourceInfo StripSource(TranslationSourceInfo sourceInfo) + { + return new TranslationSourceInfo + { + Source = MissingTextSource.Unknown, + 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 readonly JsonSerializerOptions SupabaseJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + private readonly record struct MissingTranslationEvent( + string Language, + string Key, + string Source, + TranslationSourceInfo SourceInfo); + + private sealed record MissingTranslationUpsertRow(string Language, string Key, string Source, TranslationSourceInfo SourceInfo) + { + public MissingTranslationUpsertRow Merge(string source, TranslationSourceInfo sourceInfo) + { + var mergedSource = string.Equals(Source, SourceToCompactString(MissingTextSource.Unknown), StringComparison.Ordinal) ? source : Source; + var mergedSourceInfo = MergeSourceInfo(SourceInfo, sourceInfo); + return new MissingTranslationUpsertRow(Language, Key, mergedSource, mergedSourceInfo); + } + } + + private readonly record struct SupabaseMissingRowSnake( + [property: JsonPropertyName("language")] string Language, + [property: JsonPropertyName("key")] string Key, + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("source_info"), JsonConverter(typeof(TranslationSourceInfoWithoutSourceJsonConverter))] TranslationSourceInfo SourceInfo); + + private sealed class TranslationSourceInfoWithoutSourceJsonConverter : JsonConverter + { + public override TranslationSourceInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return TranslationSourceInfo.From(MissingTextSource.Unknown); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + return new TranslationSourceInfo + { + Source = MissingTextSource.Unknown, + ViewXamlPath = root.TryGetProperty("ViewXamlPath", out var p1) ? p1.GetString() : null, + ViewType = root.TryGetProperty("ViewType", out var p2) ? p2.GetString() : null, + ElementType = root.TryGetProperty("ElementType", out var p3) ? p3.GetString() : null, + ElementName = root.TryGetProperty("ElementName", out var p4) ? p4.GetString() : null, + PropertyName = root.TryGetProperty("PropertyName", out var p5) ? p5.GetString() : null, + BindingPath = root.TryGetProperty("BindingPath", out var p6) ? p6.GetString() : null, + Notes = root.TryGetProperty("Notes", out var p7) ? p7.GetString() : null + }; + } + + public override void Write(Utf8JsonWriter writer, TranslationSourceInfo value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + WriteIfNotNull(writer, "ViewXamlPath", value.ViewXamlPath); + WriteIfNotNull(writer, "ViewType", value.ViewType); + WriteIfNotNull(writer, "ElementType", value.ElementType); + WriteIfNotNull(writer, "ElementName", value.ElementName); + WriteIfNotNull(writer, "PropertyName", value.PropertyName); + WriteIfNotNull(writer, "BindingPath", value.BindingPath); + WriteIfNotNull(writer, "Notes", value.Notes); + writer.WriteEndObject(); + } + + private static void WriteIfNotNull(Utf8JsonWriter writer, string propertyName, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + writer.WriteString(propertyName, value); + } + } + + private static string SourceToCompactString(MissingTextSource source) + { + return ((int)source).ToString(CultureInfo.InvariantCulture); + } +} diff --git a/BetterGenshinImpact/User/I18n/en.json b/BetterGenshinImpact/User/I18n/en.json new file mode 100644 index 0000000000..2cb348eb6d --- /dev/null +++ b/BetterGenshinImpact/User/I18n/en.json @@ -0,0 +1,704 @@ +{ + "BetterGI · 更好的原神 · 0.56.3-alpha.1 · Dev": "BetterGI - Better Genshin Impact - 0.56.3-alpha.1 - Dev", + "软件UI语言": "Software UI language", + "原神游戏语言": "Genshin Impact Game Language", + "更好的原神": "Better Genshin Impact", + "获取兑换码信息": "Get redemption code info", + "切换深浅主题或背景样式": "Switch light/dark theme or background style", + "最小化到托盘": "Minimize to tray", + "启动": "Start", + "实时触发": "Trigger in real time", + "独立任务": "Standalone tasks", + "一条龙": "One-stop", + "全自动": "Fully automatic", + "辅助操控": "Assisted controls", + "快捷键": "Hotkeys", + "通知": "Notifications", + "设置": "Settings", + "已加载默认背景图片": "Default background image loaded", + "修改后需要重启程序生效": "Restart the app to apply changes", + "AI推理设备设置": "AI inference device settings", + "默认50ms,普通用户不建议调整这个值,具体调整方式见文档": "Default 50 ms. Regular users are not recommended to change this value. See docs for details", + "触发器间隔(毫秒)": "Trigger interval (ms)", + "推荐选择 BitBlt,问题较少": "BitBlt is recommended (fewer issues)", + "截图模式": "Capture mode", + "停止": "Stop", + "点击展开启动相关配置": "Click to expand launch settings", + "启动截图器时,如果原神未启动,则自动启动原神。": "When starting the capturer, if Genshin isn't running, it will launch automatically", + "同时启动原神": "Also launch Genshin Impact", + "截图器启动后才能使用各项功能,": "You can use features only after the capturer starts,", + "BetterGI 截图器,启动!": "BetterGI capturer, launch!", + "恢复默认图片": "Restore default image", + "更换背景图片": "Change background image", + "更多...": "More...", + "测试图像捕获": "Test image capture", + "测试功能,测试几种截图模式的效果": "Experimental: test different capture modes", + "手动选择窗口(无法找到原神窗口时使用)": "Select window manually (use when the Genshin window can't be found)", + "原神已经启动的情况下,点击“启动”仍旧提示无法找到窗口时使用": "Use this if Genshin is already running but clicking \"Start\" still can't find the window", + "选择捕获窗口": "Select capture window", + "自动关闭 Windows11 窗口化优化以支持 BitBlt": "Automatically disable Windows 11 windowed optimizations to support BitBlt", + "修改设置后重启游戏生效": "Restart the game to apply changes", + "手动设置": "Manual settings", + "原神安装位置(不支持云原神的联动启动)": "Genshin install path (cloud version not supported for linked launch)", + "浏览": "Browse", + "示例:-screen-width 1920": "Example: -screen-width 1920", + "自动进入游戏": "Auto-enter game", + "原神启动后自动进入游戏(自动开门、领取月卡)": "After Genshin starts, auto-enter the game (auto open door, claim Welkin Moon)", + "使用 CMD 启动游戏": "Launch game via CMD", + "如果原神弹窗“检测到非法工具,请重启机器”,请尝试开启此选项": "If Genshin shows \"Illegal tool detected, please restart\", try enabling this option", + "使用Starward记录游戏时间": "Use Starward to record playtime", + "启动游戏后Starward将同步记录时间": "After launching the game, Starward will sync and record playtime", + "更好的原神,免费且开源": "Better Genshin Impact, free and open-source", + "如果你不知道什么是启动参数请不要填写。": "If you don't know what launch arguments are, leave this blank", + "点击查看文档与教程": "Click to view docs and tutorials", + "启动参数": "Launch arguments", + "打开文档": "Open documentation", + "来自哥伦比娅官方账号": "From the official Columbus account", + "来自哥伦比娅小姐的礼物~": "A gift from Ms. Columbine.", + "一键兑换": "One Click Redemption", + "复制": "make a copy of", + "有效期至": "valid until", + "40原石+5冬凌草": "40 raw stone + 5 dong quai", + "关闭": "cloture", + "实时获取前瞻兑换码": "Real-time access to forward-looking redemption codes", + "正在加载兑换码...": "Loading redemption codes...", + "查看最新的国服原神的兑换码,一键自动兑换": "Check out the latest redemption codes for CN Genshin Impact, one-click automatic redemption!", + "最新兑换码动态": "Latest Redemption Code News", + "兑换码": "redemption code", + "自动提交物品": "Auto submit items", + "方便在自动剧情的情况下,依旧能够看完全部对话框文字": "Helps you read all dialogue text even during auto story", + "点击对话框确认按钮之前的延迟(毫秒)": "Delay before clicking the dialogue confirm button (ms)", + "方便在自动剧情的情况下,依旧能够看清楚选项": "Helps you see choices clearly even during auto story", + "选择对话选项前的延迟(毫秒)": "Delay before choosing dialogue option (ms)", + "进入对话后,对话选项的选择方式": "How to choose dialogue options after entering dialogue", + "选择选项优先级": "Select option priority", + "多个选项用分号(中英文)分隔,或者换行": "Separate multiple options with semicolons (Chinese/English) or new lines", + "自定义优先选项文本": "Custom preferred option text", + "优先级高于下边的 选择选项优先级": "Higher priority than the option priority below", + "自定义优先对话选项": "Custom preferred dialogue options", + "如果你要听完整的主线语音的话,可以关闭此功能": "If you want to hear the full main quest voice lines, you can disable this feature", + "快速跳过对话文本": "Quickly skip dialogue text", + "游戏失焦时展示小窗,滚轮缩放,左键返回游戏,右键关闭": "When the game loses focus, show a small window. Mouse wheel to zoom; left click to return to the game; right click to close.", + "画中画窗口(建议和后台模式同时开启)": "Picture-in-picture window (recommended with background mode)", + "游戏请不要最小化,不在对话中的时候鼠标会自动吸附回游戏中": "Do not minimize the game. When not in dialogue, the mouse will snap back into the game automatically.", + "启用后台模式(会只用交互键进行剧情)": "Enable background mode (only uses the Interact key for quests)", + "默认为 F,自带了 E 和 G 按键,需要改成其他键的请阅读文档": "Default is F. Includes E and G keys. To change to other keys, please read the docs.", + "自定义拾取按键": "Custom pickup key", + "需要主动按下 F 交互的内容,请配合黑名单使用": "For interactions that require pressing F manually, use the blacklist", + "白名单": "Whitelist", + "排除 NPC 对话、各类交互选项、不需要拾取的物品等": "Exclude NPC dialogues, interaction options, items that don't need picking up, etc.", + "黑名单": "Blacklist", + "Paddle可识别所有文字,速度慢,消耗少;Yap可识别部分文字,快且准,消耗大": "Paddle recognizes all text (slower, lower usage); Yap recognizes some text (fast and accurate, higher usage)", + "选择自动拾取文字识别引擎": "Select OCR engine for auto pickup", + "在大地图上点击传送点(或列表中有传送点)时,自动点击传送": "When clicking a teleport point on the world map (or selecting one in the list), automatically click Teleport", + "快速传送": "Quick teleport", + "检测角色红血状态,自动使用便携营养袋回复生命值": "Detect low HP and automatically use the NRE (Menu 30) to restore HP", + "自动吃药": "Auto use food", + "半自动钓鱼需要手动抛竿(全自动钓鱼已迁移至独立任务下)": "Semi-auto fishing requires manual casting (auto fishing has been moved to Standalone tasks)", + "半自动钓鱼": "Semi-auto fishing", + "自动剧情开启的情况下此功能才会生效,自动选择邀约选项": "Only works when auto story is enabled; automatically select hangout options", + "自动邀约": "Auto hangout", + "快速跳过剧情文本、自动选择选项、自动提交物品等": "Quickly skip story text, auto-select options, auto-submit items, etc.", + "自动剧情": "Auto story", + "选项不是NPC对话且不在黑名单时,自动按下 F 拾取/交互": "If the option is not an NPC dialogue and not in the blacklist, press F automatically to pick up/interact", + "自动拾取": "Auto pickup", + "实时触发的自动化任务设置": "Settings for real-time triggered automation tasks", + "对话过程中出现提交物品界面,会自动提交,支持多个物品": "If an item submission screen appears during dialogue, submit automatically (supports multiple items)", + "自动关闭弹出页": "Auto close popups", + "关闭对话过程中出现的弹出页面,可能会误关地图、相机": "Close pop-up pages during dialogue; may accidentally close the map/camera", + "凯瑟琳 - 自动领取『每日委托』奖励": "Katheryne - Auto claim Daily Commission rewards", + "在与凯瑟琳对话时,会自动“领取『每日委托』奖励": "When talking to Katheryne, automatically \"Claim Daily Commission rewards\"", + "凯瑟琳 - 自动重新派遣": "Katheryne - Auto re-dispatch", + "自动领取已完成探索的奖励,并重新派遣": "Auto claim completed expedition rewards and re-dispatch", + "存在跳过按钮时自动点击": "Auto-click when a Skip button is present", + "邀约过程中左上角存在跳过按钮时候,自动点击跳过": "During hangout, if a Skip button appears in the top-left, click Skip automatically", + "选择邀约分支(不支持气泡联想选择)": "Select hangout branch (bubble suggestions not supported)", + "会按照选择分支的关键词进行选项选择": "Select options based on keywords of the chosen branch", + "选择邀约选项前的延迟(毫秒)": "Delay before selecting hangout option (ms)", + "方便在自动邀约的情况下,依旧能够看清楚选项": "Helps you see choices clearly even during auto hangout", + "全自动钓鱼已迁移至独立任务下": "Auto fishing has been moved to Standalone tasks", + "全自动钓鱼已迁移至独立任务下,请到独立任务页配合快捷键使用": "Auto fishing has been moved to Standalone tasks. Use it on the Standalone tasks page with hotkeys", + "触发时间间隔(毫秒)": "Trigger interval (ms)", + "多少时间检查一次是否红血或需要复活": "How often to check for low HP or revive needed", + "吃药时间间隔(毫秒)": "Potion interval (ms)", + "防止频繁吃药": "Prevent frequent food use", + "点击候选列表传送点的间隔时间(毫秒)": "Interval between clicking teleport points in the candidate list (ms)", + "普通用户请不要修改此配置,需要根据文字识别耗时配置,太低会导致点击失败": "Regular users should not change this. Adjust based on OCR time; too low may cause click failures.", + "等待右侧传送弹出界面的时间(毫秒)": "Time to wait for the right-side teleport popup UI (ms)", + "普通用户请不要修改此配置,不建议低于80ms,太低会导致传送按钮识别不到": "Regular users should not change this. Not recommended below 80ms; too low may fail to detect the teleport button.", + "启用快捷键传送": "Enable hotkey teleport", + "按下 ": "Press ", + "前往设置": "Go to Settings", + " 后才进行快速传送": "Quick teleport will only be performed after", + "[手动触发快速传送触发快捷键]": "[Hotkey to manually trigger quick teleport]", + "地图遮罩": "Map overlay", + "在遮罩窗口中显示大地图位置与标点信息": "Show world map position and marker info in the overlay window", + "自动烹饪": "Auto cooking", + "手动烹饪时,自动在完美状态下结束烹饪(不用的时候请关闭)": "When cooking manually, automatically stop cooking at perfect timing (turn off when not needed)", + "独立任务设置": "Standalone tasks settings", + "自动七圣召唤": "Auto Genius Invokation TCG", + "全自动打牌 - ": "Fully automatic TCG - ", + "自动伐木": "Auto woodcutting", + "装备「王树瑞佑」,通过循环重启游戏刷新并收集木材 - ": "Equip \"王树瑞佑\", refresh and gather wood by repeatedly restarting the game - ", + "自动战斗": "Auto combat", + "自动执行选择的战斗策略 - ": "Automatically execute selected combat strategy - ", + "自动秘境": "Auto domain", + "基于钟离的自动循环刷本 - ": "Zhongli-based auto domain loop - ", + "自动幽境危战": "Auto Stygian Onslaught", + "自动传送并进入幽境危战 - ": "Auto teleport and enter Stygian Onslaught - ", + "自动千音雅集": "Auto \"千音雅集\"", + "可以自动演奏单个,也可以全自动完成整个专辑 - ": "Can auto-play a single track or auto-complete the entire album - ", + "全自动钓鱼(单个鱼塘)": "Auto fishing (single fishing spot)", + "不要携带跟宠!在出现钓鱼F按钮的位置启动本任务 - ": "Do not bring a pet! Start this task at the position where the fishing \"F\" button appears - ", + "自动使用兑换码": "Auto redeem code", + "自动使用输入的兑换码": "Auto redeem entered code", + "自动分解圣遗物": "Auto dismantle artifacts", + "指定匹配表达式逐一筛选分解,支持5星圣遗物 - ": "Filter and salvage by match expressions (supports 5-star artifacts) - ", + "截取物品图标(开发者)": "Capture item icons (developer)", + "须要打开设置-启用保存截图功能,文件保存在 ": "You need to enable \"Save screenshots\" in Settings. Files are saved in ", + "点击查看使用教程": "Click to view the tutorial", + "选择卡组": "Select deck", + "选择你想要使用的卡组与策略": "Choose the deck and strategy you want to use", + "脚本仓库": "Script repository", + "设置延时(毫秒)": "Delay (ms)", + "如果频繁出现操作速度过快,操作动画未播放完毕的情况可以添加延时": "If actions are often too fast and animations haven't finished playing, add a delay", + "使用进出千星奇域刷新树木CD": "Refresh tree cooldown by entering/exiting the Thousand-Star Realm", + "需要确保已解锁并进入过千星奇域": "Ensure Imaginarium Theater is unlocked and entered at least once", + "循环次数": "Loop count", + "循环伐木多少次,输入 0 则为无限循环直到手动终止": "How many logging loops to run. Enter 0 for an infinite loop until manually stopped", + "启用OCR伐木数量限制(需1080P以上分辨率)": "Enable OCR wood limit (requires ≥1080p resolution)", + "伐木后OCR识别并累计木材数,达到上限后自动停止伐木": "After chopping, OCR recognizes and accumulates wood count; stop automatically when reaching the limit", + "伐木数量上限(原神每日每种木材最多2000)": "Wood limit (each wood type is capped at 2000 per day in Genshin)", + "启用伐木数量限制后生效,达到配置上限后自动停止伐木": "Takes effect after enabling the wood limit; stops automatically when reaching the configured cap", + "使用小道具后的额外延迟(毫秒)": "Extra delay after using a gadget (ms)", + "如果希望看到使用小道具后获得木材的提示,可以调整这个值": "If you want to see a prompt after gaining wood from using a gadget, adjust this value", + "选择战斗策略": "Select combat strategy", + "用于战斗": "For combat", + "打开目录": "Open folder", + "根据技能CD优化出招人员": "Optimize action order by skill CD", + "根据填入人或人和cd,来决定当此人元素战技cd未结束时,跳过此人出招,来优化战斗流程,可填入人名或人名数字(用逗号分隔),多种用分号分隔,例如:白术;钟离,12;,如果人名,则用内置cd检查(或填入数字也小于0),如果是人名和数字,则把数字当做出招cd(秒)。": "Based on the entered character(s) or character+CD, skip a character's action when their Elemental Skill CD hasn't finished to optimize combat flow. Enter a name or name,number (comma-separated), and separate multiple entries with semicolons, e.g. 白术;钟离,12;. If it's a name, use the built-in CD check (or a number <= 0). If it's name+number, treat the number as the action CD (seconds).", + "聚集材料动作": "Material gathering action", + "(琴二次拾取:首次拾取空,再次拾取)": "(Jean double pickup: first pickup is empty; pick up again)", + "战斗结束后,如存在(万叶/琴),则执行长E聚集材料动作": "After combat, if (Kazuha/Jean) exists, perform hold E to gather materials", + "琴二次拾取": "Jean second pickup", + "自动战斗超时(秒)": "Auto combat timeout (s)", + "到达指定时间后,自动停止战斗": "Stop combat automatically at the specified time", + "游泳检测(自动战斗过程中)": "Swimming detection (during auto combat)", + "先回战斗节点,失败则去七天神像": "Go back to the combat node first; if it fails, go to a Statue of The Seven", + "自动切换到指定队伍": "Auto switch to specified team", + "注意队伍名称是游戏内你手动设置的名称": "Note: the team name is the one you manually set in-game", + "指定要前往的秘境": "Select target domain", + "自动传送到刷取的秘境": "Auto teleport to the target domain", + "刷取至树脂耗尽": "Farm until resin is depleted", + "优先使用浓缩树脂,然后使用原粹树脂,其余树脂不使用": "Use Condensed Resin first, then Original Resin; don't use other resin", + "指定每种树脂刷取次数": "Set runs per resin type", + "开启后会根据配置的次数使用对应的树脂": "When enabled, uses resin according to the configured run counts", + "结束后自动分解圣遗物": "Auto dismantle artifacts after finishing", + "需要快速分解圣遗物的最高星级": "Max star rating for quick artifact dismantling", + "战斗完成后等待时间(秒)": "Wait after combat (s)", + "战斗结束后,寻找石化古树前的延迟时间,等一些角色技能完全结束": "Delay before searching for the Petrified Tree after combat ends (wait for skills to finish)", + "寻找古树时使用小步伐行走(正常用户请不要启用)": "Use small-step walking when searching for the Petrified Tree (normal users should not enable)", + "如果电脑性能较差,寻找古树时间过久,可以尝试使用这个功能": "If your PC is slow and finding the Petrified Tree takes too long, try this feature", + "步行前往开启秘境和领取奖励": "Walk to open the domain and claim rewards", + "如果电脑性能较差,开启秘境或者领取奖励的F点击不到,可以尝试此功能": "If your PC is slow and the \"F\" click to start a domain or claim rewards often misses, try this feature", + "寻找古树时确认位置左右移动的次数(正常用户不要修改)": "Number of left/right moves to confirm position when searching for the Petrified Tree (normal users should not change)", + "小步伐行走的时候左右确认位置的次数": "Number of left/right confirmations when small-step walking", + "请先装备 “便携营养袋” ,在红血时候后自动按Z键吃药": "Please equip the \"NRE (Menu 30)\". When HP is low, it will press Z to use food automatically.", + "角色死亡后重试次数": "Retries on character death", + "秘境战斗中,发生角色死亡重试的次数": "Retries on character death during domain combat", + "指定刷取的战场": "Selected battlefield", + "从上到下战场一、二、三": "From top to bottom: Battlefield 1, 2, 3", + "指定战斗队伍": "Select combat team", + "输入预设队伍的名称,留空则不更换队伍": "Enter the preset team name; leave blank to not switch teams", + "例如:队伍1": "Example: Team 1", + "【乐曲】 演奏单个乐曲": "【Track】Play a single track", + "进入演奏界面使用,下落模式必须选择垂落模式 - ": "Use on the performance screen; in falling-note mode you must choose vertical-drop mode - ", + "【专辑】 全自动完成整个专辑": "【Album】Auto-complete the entire album", + "进入专辑界面使用,自动演奏未完成乐曲 - ": "Use on the album screen; auto-play unfinished tracks - ", + "【专辑】 自动演奏未达成【大音天籁】的乐曲": "【Album】Auto-play tracks that haven't achieved 【Grand Ode】", + "关闭时,奖励已经领取就会跳过乐曲。开启时,达成了【大音天籁】才会跳过乐曲": "When off, tracks are skipped once rewards are claimed. When on, tracks are skipped only after achieving 【Grand Ode】", + "【专辑】 自动演奏的目标难度选择": "【Album】Target difficulty for auto-play", + "设置为【传说】,【大师】即可获取所有奖励,设置【所有】则会对乐曲的所有难度进行自动演奏": "Set to 【Legend】 or 【Master】 to get all rewards. Set to 【All】 to auto-play all difficulties.", + "上钩等待超时时间": "Bite wait timeout", + "超过这个时间将自动提竿,并重新识别并选择鱼饵进行抛竿": "Exceeding this time will auto reel in, then re-recognize and select bait, and cast again", + "整个任务超时时间": "Overall task timeout", + "超过这个时间将强制结束任务": "Exceeding this time will force-end the task", + "昼夜策略": "Day/night strategy", + "钓全天的鱼、还是只钓白天或夜晚的鱼、亦或不调整时间": "Fish all day, only day or night fish, or do not adjust time", + "关键帧保存截图(开发者)": "Save keyframe screenshots (developer)", + "在流程判断的关键时刻保存当时的截图,供分析判断。会大量写入,非调试时请关闭。需要启用保存截图功能": "Save screenshots at key decision moments for analysis. Writes a lot—turn off when not debugging. Requires enabling screenshot saving", + "torch库文件地址(仅限2.5.1版本)": "Torch library file path (2.5.1 only)", + "请 ": "Please ", + "获取剪切板上的兑换码": "Get redemption code from clipboard", + "在切换到软件界面时候,自动提取兑换码并提示": "When switching to the app window, automatically extract redemption codes and notify", + "测试识别效果": "Test recognition", + "请先将游戏界面切换至圣遗物分解界面": "Please switch the game UI to the artifact dismantling screen first", + "打开测试窗口": "Open test window", + "只要满足的圣遗物都会被选中": "All artifacts that meet the criteria will be selected", + "从脚本仓库复制": "Copy from script repository", + "按套装筛选": "Filter by set", + "利用游戏自带的筛选功能先行筛选": "Filter first using the game's built-in filter", + "先会进行一次快速分解选择": "A quick selection for dismantling will be performed first", + "最大检查数量": "Max check count", + "达到最大检查数量后也会停止": "Stops after reaching the max check count", + "识别失败策略": "Failure handling strategy", + "识别单个圣遗物面板信息失败时,是跳过还是终止": "When a single artifact panel recognition fails, skip or abort", + "界面名称": "UI name", + "不同界面的参数不一样,请选择你要扫描的界面": "Parameters differ by screen. Choose the screen you want to scan", + "使用星星作为名称后缀": "Use a star as the name suffix", + "有些物品具有相同的名称,但具有不同的图标和星星数": "Some items share the same name but have different icons and star counts", + "使用等级作为名称后缀(待开发)": "Use level as the name suffix (WIP)", + "有些物品具有相同的名称,但具有不同的图标和等级": "Some items share the same name but have different icons and levels", + "最大截取数量": "Max capture count", + "达到最大截取数量后会停止": "Stops after reaching the max capture count", + "请先将游戏界面切换至待测试分类界面": "Please switch the game UI to the category screen to be tested first", + "运行模型准确率测试": "Run model accuracy test", + "盾奶位角色优先释放技能": "Shielder/healer slot characters cast skills first", + "战斗过程实时检测和释放盾奶位角E战技(空选为关闭此功能)": "During combat, monitor and cast the shield/heal role's Elemental Skill (E) in real time (leave blank to disable)", + "盾奶位角色在队伍中的位置": "Shielder/healer slot position in the team", + "实时检测盾奶位战技CD": "Monitor shield/heal role skill cooldowns in real time", + "禁用该角色的战斗策略": "Disable this character's combat strategy", + "自动释放E或Q战技": "Auto cast E or Q skills", + "E战技": "Elemental Skill (E)", + "Q爆发": "Elemental Burst (Q)", + "检测盾奶位战技短按或长按,禁用:短按 / 启用:长按": "Detect shielder/healer slot skill short/long press. Disable: short press / Enable: long press", + " 到本地后填入torch_cpu.dll或torch_cuda.dll的完整地址。如未生效可尝试重启BGI。": "After downloading locally, enter the full path to torch_cpu.dll or torch_cuda.dll. If it doesn't take effect, try restarting BGI.", + "下载": "Download", + "一般填写套装内生之花名,可填入多个名称;留空则不用": "Usually enter the artifact set's Flower name; you can enter multiple names. Leave blank to disable", + "自动检测战斗结束": "Auto detect combat end", + "检测到战斗已经结束的情况下,停止自动战斗功能": "Stop auto combat when combat end is detected", + "自动拾取掉落物": "Auto pick up drops", + "战斗结束后尽可能拾取周围掉落物(与万叶配合更佳)": "After combat, pick up nearby drops when possible (works best with Kazuha)", + "原粹树脂刷取次数:": "Original Resin runs:", + "浓缩树脂刷取次数:": "Condensed Resin runs:", + "须臾树脂刷取次数:": "Transient Resin runs:", + "脆弱树脂刷取次数:": "Fragile Resin runs:", + "旋转寻找敌人位置": "Rotate to find enemy positions", + "旋转速度(建议单次360°左右):": "Rotation speed (recommend ~360° per sweep):", + "(建议配合1秒左右“更快检测结束战斗”) 打开队伍界面检测战斗结束前,先检测敌人,判断是否需靠近敌或旋转寻找敌人。(Q前检查:释放Q技能前检测是否结束战斗。尝试面敌:开战寻敌,战斗尝试面向敌人)": "(Recommended with ~1s \"Faster battle-end detection\") Before opening the party screen to detect battle end, check for enemies first to decide whether to approach or rotate to find them. (Pre-Q check: check if battle has ended before casting Q. Face enemy: find enemies when combat starts; try to face enemies during combat.)", + "Q前检测": "Pre-Q check", + "尝试面敌": "Try to face enemies", + "检查战斗结束的延时": "Combat-end check delay", + "检查战斗结束的延时,不同角色招式结束后的延时不一定相同,默认为1.5秒。也可以指定特定角色之后延时多少秒检查,未指定角色名,则默认为该值。格式如:2.5;白术,1.5;钟离,1.0;": "The post-move delay before checking whether combat has ended can vary by character. Default is 1.5s. You can also specify a per-character delay; if no character name is specified, this value is used. Format: 2.5;白术,1.5;钟离,1.0;", + "按键触发后检查延时": "Post-keypress check delay", + "按下切换队伍后去检查屏幕色块的延时,默认为0.45秒。若频繁误判可以适当提高这个值,比如到0.75。确保这个延时不会真的把队伍配置界面切出来。": "Delay after switching teams before checking screen color blocks (default 0.45s). If false positives occur frequently, increase it (e.g. 0.75). Make sure this delay doesn't actually bring up the team setup UI.", + "自动拾取掉落物时长": "Auto pickup duration", + "单位为秒。0表示不自动拾取掉落物。": "Unit: seconds. 0 means do not auto-pick up drops.", + "更快检查结束战斗": "Check combat end faster", + "快速检查战斗结束,在一轮脚本中,可以每隔一定秒数(默认为5)或指定角色操作后,去检查(在每个角色完成该轮脚本时)。": "Fast battle-end check. In a script round, it can check every N seconds (default 5) or after a specified character's action (when each character finishes that round).", + "更快检查结束战斗参数": "Faster combat-end check parameters", + "快速检查战斗结束的参数,可填入数字和人名,多种用分号分隔,例如:5;白术;钟离;,如果是数字(小于等于0则不会根据时间去检查,单位为秒),则指定检查间隔,如果是人名,则该角色执行一轮操作后进行检查。同时每轮结束后检查不变。": "Fast battle-end check parameters. Enter numbers and character names; separate multiple with semicolons, e.g. 5;Baizhu;Zhongli;. If it's a number (<= 0 disables time-based checks; unit: seconds), it specifies the check interval. If it's a name, check after that character completes one round of actions. The end-of-round check remains unchanged.", + "新增配置": "Add configuration", + "删除配置": "Delete config", + "任务列表": "Task list", + " ± 鼠标右击添加或删除任务 ": "± Right mouse click to add or remove tasks", + "配置": "Configuration", + "合成树脂": "Craft resin", + "领取奖励": "Claim rewards", + "尘歌壶配置": "Serenitea Pot settings", + "完成后操作": "After-completion action", + "(此处未覆盖的配置可在 独立任务-自动秘境 中配置)": "(Settings not covered here can be configured in Standalone tasks - Auto domain)", + "填写好感队名称": "Enter friendship team name", + "每日秘境刷取配置": "Daily domain farming config", + "前往指定秘境消耗树脂,并自动领取奖励。": "Go to the specified domain to spend resin and automatically claim rewards", + "每周秘境刷取配置": "Weekly domain farming config", + "启用后,每日刷取配置将会失效。": "When enabled, daily farming config will be disabled.", + "周期始于凌晨 4:00 ,如周一 4:00 至周二 3:59 刷取周一秘境": "The cycle starts at 4:00 AM. For example, Monday 4:00 to Tuesday 3:59 counts as Monday's domain", + "购买选择": "Purchase selection", + "进入秘境切换的队伍名称": "Team name to switch to when entering the domain", + "注意是游戏内你设置的名称": "Note: this is the name you set in-game", + "填写队伍名称": "Enter team name", + "选择秘境": "Select domain", + "秘境名称": "Domain name", + "周日或限时": "Sunday or limited-time", + "选择序号": "Select index", + "周一": "Monday", + "填写队伍名称 ": "Enter team name", + "周二": "Tuesday", + "周三": "Wednesday", + "周四": "Thursday", + "周五": "Friday", + "周六": "Saturday", + "周日": "Sunday", + "周日或限时三种奖励,从上往下(1/2/3)序号:": "For Sunday/limited-time, three reward types. Order from top to bottom (1/2/3):", + "合成树脂合成台": "Resin crafting station", + "指定地区合成树脂": "Craft resin in the specified region", + "合成后保留": "Keep after crafting", + "原粹树脂数量": "Original Resin amount", + "领奖树脂设定": "Reward resin settings", + "分解圣遗物": "Salvage artifacts", + "最高的星级": "Highest star rating", + "领取奖励的冒险者协会": "Adventurers' Guild for claiming rewards", + "前往指定地区冒险者协会领取": "Go to the Adventurers' Guild in the specified region to claim", + "领取前切换队伍(好感队)": "Switch team before claiming rewards (friendship team)", + "用于给指定队伍加好感度": "For increasing friendship for a specified team", + "进壶方式选择": "Serenitea Pot entry method", + "购买日期和商品": "Purchase date and items", + "任务完成后执行的操作": "Action after task completes", + "一条龙结束后操作": "After one-stop finishes", + "日期不影响领取好感和钱币": "Date does not affect claiming friendship and coins", + "布匹": "Cloth", + "须臾树脂": "Transient Resin", + "大英雄的经验": "Hero's Wit", + "流浪者的经验": "Wanderer's Advice", + "精锻用魔矿": "Mystic Enhancement Ore", + "摩拉": "Mora", + "祝圣精华": "Sanctifying Essence", + "祝圣油膏": "Sanctifying Unction", + "确定": "OK", + "取消": "Cancel", + "模式切换": "Mode switch", + "先用浓缩,后原粹,其余不用": "Condensed first, then Original; no others", + "按下方配置数量使用树脂": "Spend resin according to the amounts below", + "蒙德": "Johann Christoph Friedrich Mond (1747-1826), German astronomer", + "璃月": "colored glass moon", + "稻妻": "inarizuma (Japanese rice wife)", + "须弥": "Sumeru", + "枫丹": "Fontaine", + "纳塔": "Nata", + "挪德卡莱": "Nod-Krai", + "软件设置": "Software settings", + "通用功能设置": "General feature settings", + "帮助": "Help", + "查看": "View", + "启用遮罩窗口": "Enable overlay window", + "重启后生效": "Takes effect after restart", + "启用保存截图功能(开发者)": "Enable screenshot saving (developer)", + "可以通过快捷键保存截图,文件保存在": "Save screenshots via hotkey. Files are saved in", + "按键绑定设置": "Keybinding settings", + "如果你有游戏内改键需求,请在此配置对应的改键": "If you need in-game key remapping, configure it here", + "大地图地图传送设置": "World map teleport settings", + "用于地图追踪、自动秘境、一条龙等功能中传送功能的配置": "Teleport settings used by map tracking, auto domains, and one-stop routines", + "七天神像设置": "Statue of The Seven settings", + "用于指定回血的七天神像": "For selecting the Statue of The Seven used for healing", + "其他设置": "Other settings", + "设定一些其他功能的配置,失去焦点自动恢复等": "Settings for other features, such as auto-reactivate when losing focus", + "版本更新": "Version update", + "检查软件是否有新版本可用": "Check if a new version is available", + "检查更新": "Check for updates", + "显示日志窗口": "Show log window", + "在遮罩内显示日志窗口,": "Show log window in the overlay,", + "启用拖拽调整位置大小": "Enable drag to move/resize", + "开启后可以拖拽调整日志与状态栏位置,并调整大小": "When enabled, you can drag to reposition the log/status bar and resize it", + "重置位置": "Reset position", + "显示实时任务启用状态": "Show real-time task enabled status", + "在遮罩内显示实时任务启用状态": "Show real-time task status in the overlay", + "显示游戏FPS(实验功能)": "Show in-game FPS (experimental)", + "修改设置后重启生效,可能存在使游戏崩溃的BUG": "Restart to apply changes; may contain bugs that crash the game", + "显示图像识别结果": "Show image recognition results", + "实时显示各种图像识别的结果": "Show image recognition results in real time", + "启用UID遮盖": "Enable UID masking", + "遮盖右下角的UID区域": "Cover the UID area in the bottom-right", + "显示小地图方位": "Show minimap orientation", + "在小地图周围显示东南西北的文字": "Show cardinal directions around the minimap", + "遮罩文本透明度": "Overlay text transparency", + "调整遮罩窗口中文本的透明度(0.0-1.0)": "Adjust text transparency in the overlay window (0.0-1.0)", + "截图快捷键": "Screenshot hotkey", + "截图功能主要用于错误排查,训练素材快速获取等开发相关功能": "Screenshots are mainly for troubleshooting and dev tasks such as quickly collecting training data", + "绑定快捷键": "Bind hotkey", + "截图遮盖UID": "Mask UID in screenshots", + "生成的截图会遮盖右下角的UID区域": "Generated screenshots will cover the UID area in the bottom-right", + "启用全局按键映射": "Enable global key mapping", + "使按键绑定设置对外部脚本生效": "Apply keybinding settings to external scripts", + "地图移动过程中是否缩放地图": "Zoom map while moving", + "建议开启,关闭该设置可能在运行部分脚本时发生错误": "Recommended to enable; disabling may cause errors when running some scripts", + "单次鼠标移动的最大距离": "Max distance per mouse move", + "【100-2000】 过大可能会导致鼠标移动出窗口": "【100-2000】Too large may cause the mouse to move out of the window", + "地图缩小的距离": "Map zoom-out distance", + "【>600】大于这个距离会缩小地图以加快传送": "【>600】Above this distance, the map will zoom out to speed up teleporting", + "地图放大的距离": "Map zoom-in distance", + "【200-600】小于这个距离会放大地图以提高移动的精度": "【200-600】Below this distance, the map will zoom in to improve movement precision", + "鼠标移动的时间间隔(毫秒)": "Mouse move interval (ms)", + "【2-100】数字越小移动鼠标的速度越快,如果移动地图时产生卡顿,请提高这个数值": "【2-100】Smaller means faster mouse movement. If map movement stutters, increase this value", + "七天神像国家": "Statue of The Seven nation", + "选择七天神像所在国家": "Select the Statue of The Seven nation", + "七天神像区域": "Statue of The Seven area", + "选择七天神像所在区域": "Select the Statue of The Seven region", + "回血等待间隔(秒)": "HP recovery wait interval (s)", + "【1.0-30.0】传送到七天神像之后需要等待多久恢复血量": "【1.0-30.0】How long to wait for HP recovery after teleporting to a Statue of The Seven", + "游戏失去焦点时候,强制恢复激活游戏窗口": "When the game loses focus, force-reactivate the game window", + "适用于调度器任务和部分独立任务,失去焦点后,将自动激活游戏窗口,开启此项后,如果想切出游戏窗口,可以先暂停任务,之后再切出": "Applies to scheduler tasks and some standalone tasks. After losing focus, it will automatically activate the game window. If you want to switch out of the game after enabling this, pause the task first, then switch out.", + "调度器路径追踪任务大地图传送过程中自动领取委托": "Auto claim commissions during world map teleport in scheduler path tracking tasks", + "调度器路径追踪任务中,打开大地图准备传送时,会首先检测是否存在需要领取的派遣任务,如果存在,则自动领取委托(调用一条龙领取奖励逻辑),选择领取城市后生效。": "In scheduler path tracking tasks, when opening the world map to teleport, it will first check whether there are expedition rewards to claim. If yes, it will automatically claim commissions (using the one-stop reward-claim logic). Takes effect after selecting the claim city.", + "服务器时区设置": "Server time zone setting", + "设置游戏服务器的时区,用于计算基于服务器时间的每日重置时间等任务调度。JS脚本可手动使用此设置来处理不同区域服务器的时区。": "Set the game server time zone for scheduling tasks based on server time (daily reset, etc.). JS scripts can use this setting to handle different server regions' time zones.", + "检查是否存在最新测试版": "Check for latest beta", + "【测试版】非常不稳定,请谨慎选择更新!": "【Beta】Very unstable. Choose updates carefully!", + "点击打开日志文件夹": "Click to open the log folder", + "调度器异常重启配置": "Scheduler crash restart settings", + "当调度器任务异常抛出未预期错误时,累计一定次数后,自动重启bgi,以恢复功能。": "When a scheduler task throws an unexpected error, auto-restart BGI after a certain count to restore functionality", + "锄地规划配置": "Mob farming plan config", + "开启后,当每日(4点开始)统计锄地超过上限(需脚本支持,勾选允许锄地统计标志,或通过追踪文件目录的控制文件,来记录各追踪文件数据),会跳过接下来的锄地任务。": "When enabled, if daily (starting at 4:00) farming stats exceed the cap (requires script support; enable farming stats flag or use a control file in the tracking folder to record per-tracking-file data), upcoming farming tasks will be skipped.", + "米游社相关": "HoYoLAB", + "用于配置米游社相关的信息如cookie": "For configuring HoYoLAB-related info such as cookies", + "OCR 配置": "OCR settings", + "OCR 相关配置,面向进阶用户。": "OCR settings for advanced users.", + "退出时最小化到系统托盘": "Minimize to system tray on exit", + "启用后点击右上角退出按钮会最小化到系统托盘继续运行,右键托盘图标退出": "When enabled, clicking the top-right Exit button will minimize to tray and keep running; right-click the tray icon to exit", + "地图追踪优先使用的特征匹配方式": "Preferred feature matching method for map tracking", + "影响所有地图追踪功能,重启后生效": "Affects all map tracking features; takes effect after restart", + "异常次数": "Exception count", + "当运行调度器任务时,异常导致任务失败的计数,当达到计数时会重启bgi。": "Counts task failures caused by exceptions while running scheduler tasks; restart BGI when reaching the threshold", + "是否同时重启游戏": "Restart the game as well", + "重启bgi时,同时重启游戏,需开启首页启动配置:同时启动原神、自动进入游戏,此配置才会生效。": "When restarting BGI, also restart the game. Requires enabling the home startup settings: launch Genshin and auto enter the game. Only then will this take effect.", + "战斗失败算异常": "Treat combat failure as an exception", + "在锄地脚本中,实际战斗成功次数大于等于预期战斗次数,才能成功,否则抛出异常。可以解决锄地时,应打开地图失败、切换队伍、或卡在按Z的切换界面、卡月卡等,可能导致的无限卡死。 但需要确定自己的队伍练度,有无可能连续超过配置失败次数个脚本都复活超次,导致判断失败,从而触发无限重启。": "In farming scripts, the run is considered successful only if actual combat wins are >= the expected combat count; otherwise an exception is thrown. This helps avoid infinite soft-locks caused by failures such as not opening the map, switching teams, getting stuck on the Z switch UI, Welkin popups, etc. However, make sure your team is strong enough—if too many runs exceed the allowed failures, it may cause endless restarts.", + "路径未完全走完算异常": "Treat unfinished path as an exception", + "比战斗失败更严格的条件,勾选此项下,追踪文件,如果未执行完,算失败。此项勾选需要自身的追踪任务稳定性高。可以先关观察日志,打开异常情况统计下,有无连续标红超过失败次数的情况,有则不建议勾选。": "Stricter than combat failure. When enabled, checks tracking files; if not finished, it counts as a failure. This requires very stable tracking tasks. You can first turn off log viewing and check exception stats to see whether there are consecutive red entries exceeding the failure count; if so, it is not recommended.", + "日精英上限": "Daily elite enemies limit", + "当锄地精英数量到达上限时,跳过主目标为精英的锄地脚本。": "When elite count reaches the cap, skip farming scripts whose main target is elites", + "日小怪上限": "Daily normal enemies limit", + "当锄地小怪数量到达上限时,会跳过主目标为小怪的锄地脚本。": "When normal mob count reaches the cap, skip farming scripts whose main target is normal mobs", + "米游社cookie": "HoYoLAB cookie", + "cookie与调度器日志处同步": "Synced with cookie and scheduler logs", + "开启后,当调度器日志更改,或此处更改,都会同步其cookie。": "When enabled, cookie changes in scheduler logs or here will be synced", + "PaddleOCR模型": "PaddleOCR model", + "如果需要使用其他模型,请在此处选择。": "If you need to use other models, select them here.", + "关于 BetterGI": "About BetterGI", + "查看项目、文档等信息": "View project, docs, and more", + "结合米游社数据计算": "Calculate using HoYoLAB data", + "使用米游社数据(大概两三小时延时),对锄地数量进行修正,由于米游社cookie在一段时间后会失效,需要重新获取,所以如果当天存在米游社的数据,则上限需按照下面配置的来,否则按照上方配置的限制来(考虑到锄地之外的杀怪,如狗粮卡时间,所以估算值可能不一样)。": "Use HoYoLAB data (about 2-3 hours delayed) to correct the mob-farming count. Since the HoYoLAB cookie expires after a while and must be refreshed, if HoYoLAB data is available for today, the cap follows the config below; otherwise it follows the limit above. (Estimates may differ due to kills outside farming, e.g. fodder time.)", + "目录": "Directory", + "名称": "Name", + "版本": "Version", + "删除": "Delete", + "刷新": "Refresh", + "执行脚本": "Run script", + "打开脚本目录": "Open scripts folder", + "点击查看 Javascript 脚本使用与编写教程": "Click to view the JavaScript script usage & authoring tutorial", + "可以通过 Javascript 调用 BetterGI 在原神中的各项能力。请在调度器中使用! ": "Call BetterGI's in-game capabilities via JavaScript. Use it in the scheduler!", + "自定义 Javascript 脚本(实验功能)": "Custom JavaScript scripts (experimental)", + "领取每日奖励": "Claim daily rewards", + "领取尘歌壶奖励": "Claim Serenitea Pot rewards", + "领取邮件": "Claim mail", + "紫": "紫", + "彩橘": "colored orange", + "勾勾": "with hooks", + "圣遗物": "Holy relic", + "晶蝶示例组": "Crystal Butterfly Example Group", + "根据文件夹移除": "Remove by folder", + "移除": "Remove", + "打开所在目录": "Open containing folder", + "修改JS脚本自定义配置": "Edit JS script custom config", + "修改通用配置": "Edit general config", + "下一次任务从此处执行": "Start next task from here", + "添加Shell": "Add shell", + "添加键鼠脚本": "Add keyboard/mouse script", + "添加地图追踪任务": "Add map tracking task", + "添加JS脚本": "Add JS script", + "点击查看调度器使用教程": "Click to view the scheduler tutorial", + "连续任务从此开始执行": "Sequential tasks start executing from here", + "复制组": "Duplicate group", + "重命名": "Rename", + "删除组": "Delete group", + "新增组": "Add group", + "更多功能": "More features", + "添加": "Add", + "运行": "Run", + "(实验功能)配置组 - ": "(Experimental) Configuration group - ", + "新增配置组": "Add configuration group", + "在左侧栏目右键可以新增配置组,或者直接点击下面按钮新增配置组": "Right-click the left sidebar to add a config group, or click the button below", + "在左侧栏目右键可以新增/修改配置组,拖拽进行配置组排序。配置组内可以添加并配置软件内的 Javascript 脚本、键鼠脚本等,并能够控制执行次数、顺序等, ": "Right-click the left sidebar to add/edit config groups; drag to reorder groups. Inside a group, you can add and configure BetterGI JavaScript scripts, keyboard/mouse scripts, etc., and control run count and order.", + "(实验功能)请在左侧栏选择或新增配置组": "(Experimental) Select or add a configuration group in the left sidebar", + "继续执行": "Continue", + "连续执行": "Execute sequentially", + "配置组": "Configuration group", + "在下方列表中右键可以添加配置,拖拽可以调整执行顺序。支持 BetterGI 内的 Javascript 脚本、键鼠录制脚本等,通过调度器可以设置脚本执行次数、顺序等。": "Right-click in the list below to add configs; drag to change execution order. Supports BetterGI JavaScript scripts, keyboard/mouse recording scripts, etc. The scheduler can set script runs, order, and more.", + "录制回放": "Recording playback", + "地图追踪": "Map tracking", + "JS 脚本": "JS scripts", + "调度器": "Scheduler", + "功能": "Features", + "快捷键类型": "Hotkey type", + "配置快捷键": "Configure hotkeys", + "全局热键:只支持组合键和功能键,软件启动直接生效。键鼠监听:支持任意键盘单键、鼠标侧键,功能启动后才生效(推荐)。点击类型按钮可以切换快捷键类型。其中存在长按需求的功能不能使用全局热键。": "Global hotkeys: only supports combos and function keys; takes effect immediately after the app starts. Keyboard/mouse listening: supports any single keyboard key and mouse side buttons; takes effect only after the feature is started (recommended). Click the type button to switch hotkey type. Features that require press-and-hold cannot use global hotkeys.", + "快捷键设置": "Hotkey settings", + "快速拾取大量掉落物,有自动拾取的功能下,就显得比较鸡肋了": "Quickly pick up lots of drops; less useful if auto-pickup is enabled", + "长按 F 等于连续按下 F ": "Hold F equals pressing F repeatedly", + "轻松解除冻结,由于在水下存在长按的空格的场景,所以不推荐启用": "Easily break out of Frozen. Not recommended since underwater requires holding Space in some cases.", + "长按空格等于连续按下空格": "Hold Space equals pressing Space repeatedly", + "绑定快捷键到原神的确认/取消按钮": "Bind hotkey to Genshin's confirm/cancel button", + "一键确认/取消": "One-click Confirm/Cancel", + "一键自动打开背包,放置尘歌壶并进入": "One-click: open inventory, place the Serenitea Pot, and enter", + "一键进出尘歌壶": "One-click enter/exit Serenitea Pot", + "在物品购买/兑换页使用,从选中物品处开始,长按持续购买": "Use on purchase/exchange pages; long-press to keep buying starting from the selected item", + "一键购买": "One-click purchase", + "点击查看说明": "Click to view instructions", + "高延迟下无法跳过强化结果显示时,需要延长这个时间配置": "If you can't skip the enhancement result display under high latency, increase this value", + "强化的额外等待时间(毫秒)": "Extra wait time for enhancement (ms)", + "尽量设置大于0的数字,配置完毕后请切换页签生效(其他配置也一样)": "Try to set a number > 0. After configuring, switch tabs to apply (same for other settings)", + "移动鼠标间隔(毫秒)": "Mouse movement interval (ms)", + "可以为负数,绝对值越大移动越快,请不要配置太大": "Can be negative. The larger the absolute value, the faster the movement. Don't set it too high", + "移动鼠标距离": "Mouse movement distance", + "上方宏配置支持为每个角色单独设置宏编号。在角色宏配置中设置 macroPriority 字段(1-5),设置为0则使用上面的默认战斗宏编号。": "The macro settings above support assigning a macro ID per character. Set the macroPriority field (1-5) in character macro config; set to 0 to use the default combat macro ID above.", + "角色个性化宏编号设置": "Character personalized macro ID settings", + "当角色的 macroPriority 设置为0时,使用此默认宏编号(1~5)": "When a character's macroPriority is set to 0, use this default macro ID (1-5)", + "默认战斗宏编号": "Default combat macro ID", + "配置每个角色执行的宏,比如:胡桃A重跳,": "Configure each character's macro, e.g. Hu Tao A jump cancel,", + "宏配置": "Macro settings", + "按住时重复:按住时重复执行;触发:按下启动再按关闭": "Repeat while held: runs repeatedly while held; Trigger: press to start, press again to stop", + "快捷键触发方式": "Hotkey trigger mode", + "快速跳过强化结果展示,需要配置快捷键进行触发": "Quickly skip enhancement result screen; requires a hotkey", + "快速强化圣遗物": "Quick-enhance artifacts", + "快速水平平移鼠标,需要配置快捷键进行触发": "Quick horizontal mouse pan; requires a hotkey", + "那维莱特 - 转圈圈": "Neuvillette - Spin", + "触发后会识别当前出战角色,并根据配置执行对应的宏": "After triggering, recognize the current active character and run the corresponding macro based on the configuration", + "一键宏(按角色)": "One-click macro (by character)", + "辅助操控设置": "Assisted controls settings", + "创建时间": "Created time", + "操作": "Actions", + "修改名称": "Rename", + "停止录制": "Stop recording", + "开始录制": "Start recording", + "点击查看键鼠录制回放功能教程": "Click to view the keyboard/mouse recording & playback tutorial", + "建议在游戏内使用快捷键进行录制,录制完成后在调度器中使用。": "It's recommended to record with hotkeys in-game, then use it in the scheduler after recording.", + "键鼠录制回放功能(实验功能)": "Keyboard/mouse recording & playback (experimental)", + "执行任务": "Run task", + "开发者工具": "Developer tools", + "打开任务目录": "Open task folder", + "点击查看地图追踪与录制使用教程": "Click to view the map tracking & recording tutorial", + "可以实现自动采集、自动挖矿、自动锄地等功能。请在调度器中使用! ": "Enables auto gathering, auto mining, auto farming, etc. Use it in the scheduler!", + "地图追踪(实验功能)": "Map tracking (experimental)", + "测试 ServerChan 通知": "Test ServerChan notification", + "填写 ServerChan 的 SendKey": "Enter ServerChan SendKey", + "测试 Discord Webhook 通知": "Test Discord Webhook notification", + "PNG 无损,JPEG 快速压缩,WebP 档案小,按需选择": "PNG is lossless, JPEG is fast compression, WebP has smaller files—choose as needed", + "截图编码": "Screenshot encoding", + "讯息显示的头像,为空时使用 Discord 中预先设定的头像": "Message display avatar; if empty, use the preset avatar in Discord", + "头像网址": "Avatar URL", + "讯息显示的名称,为空时使用 Discord 中预先设定的名字": "Message display name; if empty, use the preset name in Discord", + "用户名": "Username", + "填写 Discord 提供的 Webhook 网址": "Enter the Webhook URL provided by Discord", + "Webhook 网址": "Webhook URL", + "测试钉钉机器人通知": "Test DingTalk bot notification", + "填写钉钉机器人密钥": "Enter DingTalk bot secret", + "钉钉机器人密钥": "DingTalk bot secret", + "填写钉钉机器人通知地址": "Enter DingTalk bot notification URL", + "钉钉机器人通知地址": "DingTalk bot notification URL", + "测试信息推送通知": "Test push notification", + "填写信息推送通知渠道": "Enter push notification channel", + "通知渠道": "Notification channel", + "Better原神": "BetterGI", + "填写信息推送通知来源": "Enter push notification source", + "通知来源": "Notification source", + "填写信息推送通知API密钥": "Enter push notification API key", + "API密钥": "API key", + "测试 Telegram 通知": "Test Telegram notification", + "格式:http://127.0.0.1:7890": "Format: http://127.0.0.1:7890", + "代理地址": "Proxy URL", + "是否启用代理连接": "Enable proxy connection", + "启用代理": "Enable proxy", + "留空使用官方 API,或填写第三方 API 地址": "Leave blank to use the official API, or enter a third-party API URL", + "API 基础 URL(可选)": "API base URL (optional)", + "填写接收消息的聊天 ID": "Enter chat ID for receiving messages", + "聊天 ID": "Chat ID", + "填写 Telegram 机器人的 Token": "Enter Telegram bot token", + "机器人 Token": "Bot token", + "测试 Bark 通知": "Test Bark notification", + "推送内容加密密钥": "Push content encryption key", + "加密密钥": "Encryption key", + "选择是否保存推送历史": "Choose whether to save push history", + "保存推送历史": "Save push history", + "按组显示在通知中心": "Group in Notification Center", + "通知分组": "Notification groups", + "自定义通知图标": "Custom notification icon", + "通知图标": "Notification icon", + "选择通知声音": "Select notification sound", + "通知声音": "Notification sound", + "修改推送级别": "Change notification level", + "推送级别": "Notification level", + "多个设备使用英文逗号、分号或空格分隔": "Separate multiple devices with commas, semicolons, or spaces", + "设备 Key": "Device key", + "填写 Bark API 端点,例如:api.day.app": "Enter Bark API endpoint, e.g. api.day.app", + "Bark API 端点": "Bark API endpoint", + "测试邮箱通知": "Test email notification", + "填写收件人邮箱": "Enter recipient email", + "收件人邮箱": "Recipient email", + "填写发件人姓名": "Enter sender name", + "发件人姓名": "Sender name", + "填写发件人邮箱": "Enter sender email", + "发件人邮箱": "Sender email", + "填写 SMTP 密码": "Enter SMTP password", + "SMTP 密码": "SMTP password", + "填写 SMTP 用户名": "Enter SMTP username", + "SMTP 用户名": "SMTP username", + "填写 SMTP 服务器端口,一般为:587": "Enter SMTP port (usually 587)", + "SMTP 服务器端口": "SMTP server port", + "填写 SMTP 服务器": "Enter SMTP server", + "SMTP 服务器": "SMTP server", + "测试企业微信通知": "Test WeCom notification", + "填写企业微信通知地址": "Enter WeCom notification URL", + "企业微信通知地址": "WeCom notification URL", + "测试 OneBot 通知": "Test OneBot notification", + "填写 OneBot Token(可选)": "Enter OneBot Token (optional)", + "填写接收消息的群号": "Enter group number for receiving messages", + "群号": "Group ID", + "填写接收消息的 QQ 号": "Enter QQ number for receiving messages", + "QQ 号": "QQ number", + "填写 OneBot 请求地址": "Enter OneBot request URL", + "OneBot 请求地址": "OneBot request URL", + "测试飞书通知": "Test Lark (Feishu) notification", + "飞书AppSecret": "Lark AppSecret", + "若填写AppId、AppSecret则发送图片": "Send images if AppId/AppSecret are provided", + "飞书AppId": "Lark AppId", + "填写飞书通知地址": "Enter Feishu notification URL", + "飞书通知地址": "Lark notification URL", + "测试 Windows 通知": "Test Windows notification", + "发送测试通知": "Send test notification", + "测试 WebSocket 通知": "Test WebSocket notification", + "填写 WebSocket 端点": "Enter WebSocket endpoint", + "WebSocket 端点": "WebSocket endpoint", + "发送": "Send", + "发送测试载荷": "Send test payload", + "测试": "Test", + "填写发送对象": "Enter recipients", + "发送对象": "Recipients", + "填写 Webhook 端点": "Enter Webhook endpoint", + "Webhook 端点": "Webhook endpoint", + "英文逗号分割,为空为全部通知": "Split with English commas; empty means all notifications", + "需要通知的事件": "Events to notify", + "开启时允许 JS 脚本发送通知": "Allow JS scripts to send notifications when enabled", + "是否允许 JS 通知": "Allow JS notifications", + "总是在通知中包含截图": "Always include screenshots in notifications", + "通知时包含截图": "Include screenshot in notifications", + "ServerChan 推送通知相关设置": "ServerChan push notification settings", + "启用 ServerChan 通知": "Enable ServerChan notifications", + "Discord Webhook 通知相关设置": "Discord Webhook notification settings", + "启用 Discord Webhook 通知": "Enable Discord Webhook notifications", + "钉钉机器人通知相关设置": "DingTalk bot notification settings", + "启用钉钉机器人通知": "Enable DingTalk bot notifications", + "xxtui 信息推送通知相关设置": "xxtui push notification settings", + "启用 xxtui 信息推送": "Enable xxtui push notifications", + "Telegram 机器人相关设置": "Telegram bot settings", + "启用 Telegram 通知": "Enable Telegram notifications", + "Bark ios 推送通知": "Bark iOS push notifications", + "启用 Bark 通知": "Enable Bark notifications", + "邮箱相关设置(账号密码完全保存于本地)": "Email settings (credentials are stored locally only)", + "启用邮箱通知": "Enable email notifications", + "企业微信通知相关设置": "WeCom notification settings", + "启用企业微信通知": "Enable WeCom notifications", + "OneBot 通知相关设置": "OneBot notification settings", + "启用 OneBot 通知": "Enable OneBot notifications", + "飞书通知相关设置": "Lark notification settings", + "启用飞书通知": "Enable Feishu notifications", + "Windows 通知别与游戏界面重叠,否则易误点通知": "Don't let Windows notifications overlap the game UI; otherwise it's easy to misclick them", + "启用 Windows 通知": "Enable Windows notifications", + "WebSocket 相关设置": "WebSocket settings", + "启用 WebSocket": "Enable WebSocket", + "Webhook 相关设置": "Webhook settings", + "启用 Webhook": "Enable Webhook", + "影响下方所有通知的设置": "Settings that affect all notifications below", + "全局通知设置": "Global notification settings", + "通知设置": "Notification settings", + "关于": "with respect to", + "BetterGI 更好的原神": "BetterGI Better Genshin Impact", + "开源地址: ": "Open source address.", + "协议: GPLv3": "Protocol: GPLv3", + "作者: ": "Author:.", + "文档: ": "Documentation.", + "B站账号(视频教程): ": "B-site account (video tutorial).", + "辉鸭蛋": "huiyadanli", + "证书号: 软著登字第15156950号": "Certificate No.: Soft Writings Registration No. 15156950", + "登记号: 2025SR0500752": "Registration number: 2025SR0500752", + "版权信息": "Copyright Information" +} \ No newline at end of file diff --git a/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs b/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs new file mode 100644 index 0000000000..ccf443d962 --- /dev/null +++ b/BetterGenshinImpact/View/Behavior/AutoTranslateInterceptor.cs @@ -0,0 +1,886 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Media; +using System.Windows.Threading; +using BetterGenshinImpact.Core.Config; +using BetterGenshinImpact.Service.Interface; +using CommunityToolkit.Mvvm.Messaging; +using CommunityToolkit.Mvvm.Messaging.Messages; + +namespace BetterGenshinImpact.View.Behavior +{ + public static class AutoTranslateInterceptor + { + static AutoTranslateInterceptor() + { + EventManager.RegisterClassHandler( + typeof(FrameworkElement), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnAnyElementLoaded), + true); + EventManager.RegisterClassHandler( + typeof(FrameworkContentElement), + FrameworkContentElement.LoadedEvent, + new RoutedEventHandler(OnAnyElementLoaded), + true); + } + + public static readonly DependencyProperty EnableAutoTranslateProperty = + DependencyProperty.RegisterAttached( + "EnableAutoTranslate", + typeof(bool), + typeof(AutoTranslateInterceptor), + new FrameworkPropertyMetadata( + false, + FrameworkPropertyMetadataOptions.Inherits, + OnEnableAutoTranslateChanged)); + + public static void SetEnableAutoTranslate(DependencyObject element, bool value) + => element.SetValue(EnableAutoTranslateProperty, value); + + public static bool GetEnableAutoTranslate(DependencyObject element) + => (bool)element.GetValue(EnableAutoTranslateProperty); + + private static readonly DependencyProperty ScopeProperty = + DependencyProperty.RegisterAttached( + "Scope", + typeof(Scope), + typeof(AutoTranslateInterceptor), + new PropertyMetadata(null)); + + private static readonly DependencyProperty OriginalValuesProperty = + DependencyProperty.RegisterAttached( + "OriginalValues", + typeof(Dictionary), + typeof(AutoTranslateInterceptor), + new PropertyMetadata(null)); + + private static Dictionary? GetOriginalValuesMap(DependencyObject obj) + => (Dictionary?)obj.GetValue(OriginalValuesProperty); + + private static Dictionary GetOrCreateOriginalValuesMap(DependencyObject obj) + { + var map = GetOriginalValuesMap(obj); + if (map != null) + { + return map; + } + + map = new Dictionary(); + obj.SetValue(OriginalValuesProperty, map); + return map; + } + + private static void OnAnyElementLoaded(object sender, RoutedEventArgs e) + { + if (sender is not DependencyObject obj) + { + return; + } + + FindNearestScope(obj)?.RequestApply(obj); + } + + private static Scope? FindNearestScope(DependencyObject obj) + { + DependencyObject? current = obj; + while (current != null) + { + if (current is FrameworkElement fe && fe.GetValue(ScopeProperty) is Scope scope) + { + return scope; + } + + current = GetParentObject(current); + } + + return null; + } + + private static DependencyObject? GetParentObject(DependencyObject obj) + { + if (obj is FrameworkElement fe) + { + if (fe.Parent != null) + { + return fe.Parent; + } + + if (fe.TemplatedParent is DependencyObject templatedParent) + { + return templatedParent; + } + } + + if (obj is FrameworkContentElement fce) + { + if (fce.Parent != null) + { + return fce.Parent; + } + + if (fce.TemplatedParent is DependencyObject templatedParent) + { + return templatedParent; + } + } + + if (obj is Visual || obj is System.Windows.Media.Media3D.Visual3D) + { + return VisualTreeHelper.GetParent(obj); + } + + return LogicalTreeHelper.GetParent(obj); + } + + private static void OnEnableAutoTranslateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not FrameworkElement fe) + { + return; + } + + if (e.NewValue is true) + { + if (fe.GetValue(ScopeProperty) is Scope oldScope) + { + fe.Loaded -= oldScope.OnLoaded; + fe.Unloaded -= oldScope.OnUnloaded; + oldScope.Dispose(); + fe.ClearValue(ScopeProperty); + } + + var scope = new Scope(fe); + fe.SetValue(ScopeProperty, scope); + fe.Loaded += scope.OnLoaded; + fe.Unloaded += scope.OnUnloaded; + if (fe.IsLoaded) + { + scope.ApplyNow(); + } + } + else + { + if (fe.GetValue(ScopeProperty) is Scope scope) + { + fe.Loaded -= scope.OnLoaded; + fe.Unloaded -= scope.OnUnloaded; + scope.Dispose(); + fe.ClearValue(ScopeProperty); + } + } + } + + private sealed class Scope : IDisposable + { + private readonly FrameworkElement _root; + private readonly List _unsubscribe = new(); + private bool _applied; + private readonly HashSet _trackedContextMenus = new(); + private readonly HashSet _trackedToolTips = new(); + private readonly HashSet _pendingApply = new(); + private bool _applyScheduled; + private bool _refreshScheduled; + + public Scope(FrameworkElement root) + { + _root = root; + WeakReferenceMessenger.Default.Register>(this, (_, msg) => + { + if (msg.PropertyName == nameof(OtherConfig.UiCultureInfoName)) + { + ScheduleRefresh(); + } + }); + _unsubscribe.Add(() => WeakReferenceMessenger.Default.UnregisterAll(this)); + } + + public void OnLoaded(object sender, RoutedEventArgs e) + { + if (_applied) + { + return; + } + + _applied = true; + Apply(_root); + } + + public void ApplyNow() + { + if (_applied) + { + return; + } + + _applied = true; + Apply(_root); + } + + public void OnUnloaded(object sender, RoutedEventArgs e) + { + Dispose(); + } + + public void Dispose() + { + foreach (var unsub in _unsubscribe) + { + try + { + unsub(); + } + catch + { + } + } + + _unsubscribe.Clear(); + } + + private void ScheduleRefresh() + { + if (!_applied) + { + return; + } + + if (_refreshScheduled) + { + return; + } + + _refreshScheduled = true; + _root.Dispatcher.BeginInvoke( + () => + { + _refreshScheduled = false; + if (!_applied) + { + return; + } + + RestoreOriginalValues(_root); + RefreshBoundValues(_root); + Apply(_root); + }, + DispatcherPriority.Loaded); + } + + public void RequestApply(DependencyObject obj) + { + if (!_applied) + { + return; + } + + if (IsInComboBoxContext(obj)) + { + return; + } + + if (!_pendingApply.Add(obj)) + { + return; + } + + if (_applyScheduled) + { + return; + } + + _applyScheduled = true; + _root.Dispatcher.BeginInvoke( + () => + { + _applyScheduled = false; + if (!_applied) + { + _pendingApply.Clear(); + return; + } + + var items = _pendingApply.ToArray(); + _pendingApply.Clear(); + foreach (var item in items) + { + Apply(item); + } + }, + DispatcherPriority.Loaded); + } + + private void Apply(DependencyObject root) + { + var translator = App.GetService(); + if (translator == null) + { + return; + } + + var culture = translator.GetCurrentCulture(); + if (culture.Name.StartsWith("zh", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var queue = new Queue(); + var visited = new HashSet(); + queue.Enqueue(root); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!visited.Add(current)) + { + continue; + } + + if (IsInGridViewRowPresenter(current)) + { + continue; + } + + if (IsInComboBoxContext(current)) + { + continue; + } + + TranslateKnown(current, translator); + + if (current is FrameworkElement feCurrent) + { + TrackContextMenu(feCurrent.ContextMenu, queue); + TrackToolTip(feCurrent.ToolTip, queue); + } + + if (current is Visual || current is System.Windows.Media.Media3D.Visual3D) + { + var count = VisualTreeHelper.GetChildrenCount(current); + for (var i = 0; i < count; i++) + { + queue.Enqueue(VisualTreeHelper.GetChild(current, i)); + } + } + + if (current is FrameworkElement || current is FrameworkContentElement) + { + foreach (var child in LogicalTreeHelper.GetChildren(current).OfType()) + { + queue.Enqueue(child); + } + } + + if (current is FrameworkElement fe) + { + foreach (var inline in EnumerateInlineObjects(fe)) + { + queue.Enqueue(inline); + } + } + } + } + + private void TrackContextMenu(ContextMenu? contextMenu, Queue queue) + { + if (contextMenu == null || !_trackedContextMenus.Add(contextMenu)) + { + return; + } + + queue.Enqueue(contextMenu); + + RoutedEventHandler? openedHandler = null; + openedHandler = (_, _) => + { + _root.Dispatcher.BeginInvoke( + () => Apply(contextMenu), + DispatcherPriority.Loaded); + }; + contextMenu.Opened += openedHandler; + _unsubscribe.Add(() => contextMenu.Opened -= openedHandler); + } + + private void TrackToolTip(object? toolTip, Queue queue) + { + if (toolTip is not ToolTip tt || !_trackedToolTips.Add(tt)) + { + return; + } + + queue.Enqueue(tt); + + RoutedEventHandler? openedHandler = null; + openedHandler = (_, _) => + { + _root.Dispatcher.BeginInvoke( + () => Apply(tt), + DispatcherPriority.Loaded); + }; + tt.Opened += openedHandler; + _unsubscribe.Add(() => tt.Opened -= openedHandler); + } + + private static IEnumerable EnumerateInlineObjects(FrameworkElement fe) + { + if (fe is not TextBlock tb) + { + yield break; + } + + foreach (var inline in EnumerateInlineObjects(tb.Inlines)) + { + yield return inline; + } + } + + private static IEnumerable EnumerateInlineObjects(InlineCollection inlines) + { + foreach (var inline in inlines) + { + yield return inline; + + if (inline is Span span) + { + foreach (var nested in EnumerateInlineObjects(span.Inlines)) + { + yield return nested; + } + } + + if (inline is InlineUIContainer { Child: DependencyObject child }) + { + yield return child; + } + } + } + + private void TranslateKnown(DependencyObject obj, ITranslationService translator) + { + switch (obj) + { + case TextBlock tb: + TranslateIfNotBound(tb, TextBlock.TextProperty, tb.Text, s => tb.Text = s, translator); + TranslateToolTip(tb, translator); + break; + case Run run: + TranslateIfNotBound(run, Run.TextProperty, run.Text, s => run.Text = s, translator); + break; + case HeaderedContentControl hcc: + if (hcc.Header is string header) + { + TranslateIfNotBound(hcc, HeaderedContentControl.HeaderProperty, header, s => hcc.Header = s, translator); + } + TranslateToolTip(hcc, translator); + break; + case ContentControl cc: + if (cc.Content is string content) + { + TranslateIfNotBound(cc, ContentControl.ContentProperty, content, s => cc.Content = s, translator); + } + TranslateToolTip(cc, translator); + break; + case FrameworkElement fe: + TranslateToolTip(fe, translator); + break; + } + + TranslateStringLocalValues(obj, translator); + } + + private void TranslateToolTip(FrameworkElement fe, ITranslationService translator) + { + if (fe.ToolTip is string tip) + { + TranslateIfNotBound(fe, FrameworkElement.ToolTipProperty, tip, s => fe.ToolTip = s, translator); + } + } + + private void TranslateStringLocalValues(DependencyObject obj, ITranslationService translator) + { + var enumerator = obj.GetLocalValueEnumerator(); + while (enumerator.MoveNext()) + { + var entry = enumerator.Current; + var property = entry.Property; + if (property.PropertyType != typeof(string) && property.PropertyType != typeof(object)) + { + continue; + } + + if (BindingOperations.IsDataBound(obj, property)) + { + continue; + } + + if (entry.Value is not string value || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + if (!ShouldTranslatePropertyName(property.Name)) + { + continue; + } + + var map = GetOriginalValuesMap(obj); + if (map == null || !map.TryGetValue(property, out var original)) + { + if (ContainsHan(value)) + { + map = GetOrCreateOriginalValuesMap(obj); + map[property] = value; + original = value; + } + else + { + continue; + } + } + + var translated = translator.Translate(original, BuildSourceInfo(obj, property, MissingTextSource.UiStaticLiteral)); + if (!ReferenceEquals(value, translated) && !string.Equals(value, translated, StringComparison.Ordinal)) + { + obj.SetValue(property, translated); + } + } + } + + private static bool ShouldTranslatePropertyName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name.EndsWith("Path", StringComparison.Ordinal) + || name.EndsWith("MemberPath", StringComparison.Ordinal) + || string.Equals(name, "Uid", StringComparison.Ordinal) + || string.Equals(name, "Name", StringComparison.Ordinal)) + { + return false; + } + + return name.Contains("Text", StringComparison.Ordinal) + || name.Contains("Content", StringComparison.Ordinal) + || name.Contains("Header", StringComparison.Ordinal) + || name.Contains("ToolTip", StringComparison.Ordinal) + || name.Contains("Title", StringComparison.Ordinal) + || name.Contains("Subtitle", StringComparison.Ordinal) + || name.Contains("Description", StringComparison.Ordinal) + || name.Contains("Placeholder", StringComparison.Ordinal) + || name.Contains("Label", StringComparison.Ordinal) + || name.Contains("Caption", StringComparison.Ordinal); + } + + private static bool ContainsHan(string text) + { + foreach (var ch in text) + { + if (ch is >= '\u4E00' and <= '\u9FFF') + { + return true; + } + } + + return false; + } + + private void TranslateIfNotBound( + DependencyObject obj, + DependencyProperty property, + string currentValue, + Action setter, + ITranslationService translator) + { + if (BindingOperations.IsDataBound(obj, property)) + { + return; + } + + var map = GetOriginalValuesMap(obj); + if (map == null || !map.TryGetValue(property, out var original)) + { + if (ContainsHan(currentValue)) + { + map = GetOrCreateOriginalValuesMap(obj); + map[property] = currentValue; + original = currentValue; + } + else + { + original = currentValue; + } + } + + var translated = translator.Translate(original, BuildSourceInfo(obj, property, MissingTextSource.UiStaticLiteral)); + if (!ReferenceEquals(currentValue, translated) && !string.Equals(currentValue, translated, StringComparison.Ordinal)) + { + setter(translated); + } + } + + private void RestoreOriginalValues(DependencyObject root) + { + var queue = new Queue(); + var visited = new HashSet(); + queue.Enqueue(root); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!visited.Add(current)) + { + continue; + } + + var map = GetOriginalValuesMap(current); + if (map != null) + { + foreach (var pair in map) + { + if (BindingOperations.IsDataBound(current, pair.Key)) + { + continue; + } + + current.SetValue(pair.Key, pair.Value); + } + } + + if (current is FrameworkElement feCurrent) + { + if (feCurrent.ContextMenu != null) + { + queue.Enqueue(feCurrent.ContextMenu); + } + + if (feCurrent.ToolTip is DependencyObject tt) + { + queue.Enqueue(tt); + } + } + + if (current is Visual || current is System.Windows.Media.Media3D.Visual3D) + { + var count = VisualTreeHelper.GetChildrenCount(current); + for (var i = 0; i < count; i++) + { + queue.Enqueue(VisualTreeHelper.GetChild(current, i)); + } + } + + if (current is FrameworkElement || current is FrameworkContentElement) + { + foreach (var child in LogicalTreeHelper.GetChildren(current).OfType()) + { + queue.Enqueue(child); + } + } + + if (current is TextBlock tb) + { + foreach (var inline in EnumerateInlineObjects(tb.Inlines)) + { + queue.Enqueue(inline); + } + } + } + } + + private static void RefreshBoundValues(DependencyObject root) + { + var queue = new Queue(); + var visited = new HashSet(); + queue.Enqueue(root); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!visited.Add(current)) + { + continue; + } + + RefreshBindings(current); + + if (current is FrameworkElement feCurrent) + { + if (feCurrent.ContextMenu != null) + { + queue.Enqueue(feCurrent.ContextMenu); + } + + if (feCurrent.ToolTip is DependencyObject tt) + { + queue.Enqueue(tt); + } + } + + if (current is Visual || current is System.Windows.Media.Media3D.Visual3D) + { + var count = VisualTreeHelper.GetChildrenCount(current); + for (var i = 0; i < count; i++) + { + queue.Enqueue(VisualTreeHelper.GetChild(current, i)); + } + } + + if (current is FrameworkElement || current is FrameworkContentElement) + { + foreach (var child in LogicalTreeHelper.GetChildren(current).OfType()) + { + queue.Enqueue(child); + } + } + + if (current is TextBlock tb) + { + foreach (var inline in EnumerateInlineObjects(tb.Inlines)) + { + queue.Enqueue(inline); + } + } + } + } + + private static void RefreshBindings(DependencyObject obj) + { + var enumerator = obj.GetLocalValueEnumerator(); + while (enumerator.MoveNext()) + { + var entry = enumerator.Current; + var property = entry.Property; + if (!BindingOperations.IsDataBound(obj, property)) + { + continue; + } + + 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 + }; + } + + private static bool IsInGridViewRowPresenter(DependencyObject obj) + { + DependencyObject? current = obj; + while (current != null) + { + if (current is GridViewRowPresenter) + { + return true; + } + + current = GetParentObject(current); + } + + return false; + } + + private static bool IsInComboBoxContext(DependencyObject obj) + { + DependencyObject? current = obj; + while (current != null) + { + if (current is ComboBox or ComboBoxItem) + { + return true; + } + + if (current is Popup { PlacementTarget: ComboBox }) + { + return true; + } + + current = GetParentObject(current); + } + + return false; + } + } + } +} diff --git a/BetterGenshinImpact/View/Converters/TrConverter.cs b/BetterGenshinImpact/View/Converters/TrConverter.cs new file mode 100644 index 0000000000..b6c3166be7 --- /dev/null +++ b/BetterGenshinImpact/View/Converters/TrConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using BetterGenshinImpact.Service.Interface; + +namespace BetterGenshinImpact.View.Converters; + +[ValueConversion(typeof(string), typeof(string))] +public sealed class TrConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string s || string.IsNullOrEmpty(s)) + { + return value ?? string.Empty; + } + + var translator = App.GetService(); + 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) + { + return value ?? string.Empty; + } + +} diff --git a/BetterGenshinImpact/View/MainWindow.xaml b/BetterGenshinImpact/View/MainWindow.xaml index d246a5c050..2f616f8193 100644 --- a/BetterGenshinImpact/View/MainWindow.xaml +++ b/BetterGenshinImpact/View/MainWindow.xaml @@ -1,7 +1,8 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -36,7 +84,7 @@ @@ -302,4 +304,4 @@ - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/PickerWindow.xaml b/BetterGenshinImpact/View/PickerWindow.xaml index 9505302b68..6685fff946 100644 --- a/BetterGenshinImpact/View/PickerWindow.xaml +++ b/BetterGenshinImpact/View/PickerWindow.xaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:view="clr-namespace:BetterGenshinImpact.View" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}" Title="选择捕获窗口" @@ -11,6 +12,7 @@ ExtendsContentIntoTitleBar="True" WindowBackdropType="Auto" WindowStartupLocation="CenterScreen" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" Loaded="Window_Loaded" PreviewKeyDown="FluentWindow_PreviewKeyDown"> diff --git a/BetterGenshinImpact/View/Windows/AboutWindow.xaml b/BetterGenshinImpact/View/Windows/AboutWindow.xaml index e20cfaa231..b702372a6e 100644 --- a/BetterGenshinImpact/View/Windows/AboutWindow.xaml +++ b/BetterGenshinImpact/View/Windows/AboutWindow.xaml @@ -4,11 +4,13 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" Title="关于" Width="400" Height="300" Background="#202020" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" FontFamily="{DynamicResource TextThemeFontFamily}" WindowBackdropType="Auto" WindowStartupLocation="CenterOwner" @@ -62,4 +64,4 @@ - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/ArtifactOcrDialog.xaml b/BetterGenshinImpact/View/Windows/ArtifactOcrDialog.xaml index c36f9019f6..68b7904ed7 100644 --- a/BetterGenshinImpact/View/Windows/ArtifactOcrDialog.xaml +++ b/BetterGenshinImpact/View/Windows/ArtifactOcrDialog.xaml @@ -4,10 +4,12 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" SizeToContent="WidthAndHeight" ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" FontFamily="{DynamicResource TextThemeFontFamily}" ResizeMode="CanMinimize" WindowBackdropType="Mica" diff --git a/BetterGenshinImpact/View/Windows/CheckUpdateWindow.xaml b/BetterGenshinImpact/View/Windows/CheckUpdateWindow.xaml index f18f190e52..6e712fd68e 100644 --- a/BetterGenshinImpact/View/Windows/CheckUpdateWindow.xaml +++ b/BetterGenshinImpact/View/Windows/CheckUpdateWindow.xaml @@ -1,4 +1,4 @@ - - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/FeedWindow.xaml b/BetterGenshinImpact/View/Windows/FeedWindow.xaml index c7d0029fcc..775f2ba9bd 100644 --- a/BetterGenshinImpact/View/Windows/FeedWindow.xaml +++ b/BetterGenshinImpact/View/Windows/FeedWindow.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" Width="400" Height="700" MinWidth="400" @@ -11,6 +12,7 @@ ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" FontFamily="{DynamicResource TextThemeFontFamily}" WindowBackdropType="Mica" WindowStartupLocation="CenterScreen" @@ -263,4 +265,4 @@ - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/JsonMonoDialog.xaml b/BetterGenshinImpact/View/Windows/JsonMonoDialog.xaml index 025cf1636f..9019e05fdf 100644 --- a/BetterGenshinImpact/View/Windows/JsonMonoDialog.xaml +++ b/BetterGenshinImpact/View/Windows/JsonMonoDialog.xaml @@ -1,15 +1,17 @@ - - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/KeyBindingsWindow.xaml b/BetterGenshinImpact/View/Windows/KeyBindingsWindow.xaml index 7722a39c4f..aaeac6ffed 100644 --- a/BetterGenshinImpact/View/Windows/KeyBindingsWindow.xaml +++ b/BetterGenshinImpact/View/Windows/KeyBindingsWindow.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" xmlns:pages="clr-namespace:BetterGenshinImpact.View.Pages" Title="按键绑定设置" Width="800" @@ -11,6 +12,7 @@ d:DesignHeight="600" d:DesignWidth="800" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" WindowBackdropType="Mica" WindowCornerPreference="Round" WindowStartupLocation="CenterOwner" @@ -24,4 +26,4 @@ Title="按键绑定设置" Icon="pack://application:,,,/Assets/logo.ico" /> - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/MapLabelSearchWindow.xaml b/BetterGenshinImpact/View/Windows/MapLabelSearchWindow.xaml index a9d43cb18c..b3ee5307fd 100644 --- a/BetterGenshinImpact/View/Windows/MapLabelSearchWindow.xaml +++ b/BetterGenshinImpact/View/Windows/MapLabelSearchWindow.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" Width="280" Height="64" MinWidth="120" @@ -13,6 +14,7 @@ Topmost="True" WindowStyle="None" Background="Transparent" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" mc:Ignorable="d"> @@ -45,4 +47,4 @@ Content="录制编辑器" /> - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/MapViewer.xaml b/BetterGenshinImpact/View/Windows/MapViewer.xaml index 06f7123d5e..c2bc48d2e9 100644 --- a/BetterGenshinImpact/View/Windows/MapViewer.xaml +++ b/BetterGenshinImpact/View/Windows/MapViewer.xaml @@ -1,9 +1,10 @@ - - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/PromptDialog.xaml b/BetterGenshinImpact/View/Windows/PromptDialog.xaml index ba7d09cb76..4f755bc7f0 100644 --- a/BetterGenshinImpact/View/Windows/PromptDialog.xaml +++ b/BetterGenshinImpact/View/Windows/PromptDialog.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" Width="500" Height="225" MinWidth="400" @@ -11,6 +12,7 @@ ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" FontFamily="{DynamicResource TextThemeFontFamily}" SizeToContent="Width" WindowBackdropType="Auto" diff --git a/BetterGenshinImpact/View/Windows/RepoUpdateDialog.xaml b/BetterGenshinImpact/View/Windows/RepoUpdateDialog.xaml index 0838027b3d..2f42e9bf1d 100644 --- a/BetterGenshinImpact/View/Windows/RepoUpdateDialog.xaml +++ b/BetterGenshinImpact/View/Windows/RepoUpdateDialog.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" Width="520" Height="280" MinWidth="400" @@ -11,6 +12,7 @@ ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" FontFamily="{DynamicResource TextThemeFontFamily}" ResizeMode="NoResize" WindowBackdropType="Auto" diff --git a/BetterGenshinImpact/View/Windows/ScriptRepoWindow.xaml b/BetterGenshinImpact/View/Windows/ScriptRepoWindow.xaml index bf47daa2b9..09bfb7710c 100644 --- a/BetterGenshinImpact/View/Windows/ScriptRepoWindow.xaml +++ b/BetterGenshinImpact/View/Windows/ScriptRepoWindow.xaml @@ -6,6 +6,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:vio="http://schemas.lepo.co/wpfui/2022/xaml/violeta" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" Title="脚本仓库" Width="410" MinWidth="360" @@ -13,6 +14,7 @@ ResizeMode="NoResize" Background="#202020" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" FontFamily="{DynamicResource TextThemeFontFamily}" SizeToContent="Height" WindowBackdropType="Auto" @@ -320,4 +322,4 @@ - \ No newline at end of file + diff --git a/BetterGenshinImpact/View/Windows/ThemedMessageBox.xaml b/BetterGenshinImpact/View/Windows/ThemedMessageBox.xaml index 011f034844..5a5e733b61 100644 --- a/BetterGenshinImpact/View/Windows/ThemedMessageBox.xaml +++ b/BetterGenshinImpact/View/Windows/ThemedMessageBox.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:behavior="clr-namespace:BetterGenshinImpact.View.Behavior" Width="480" Height="240" MinWidth="400" @@ -11,6 +12,7 @@ ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}" ExtendsContentIntoTitleBar="True" + behavior:AutoTranslateInterceptor.EnableAutoTranslate="True" FontFamily="{DynamicResource TextThemeFontFamily}" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" diff --git a/BetterGenshinImpact/View/Windows/WelcomeDialog.xaml b/BetterGenshinImpact/View/Windows/WelcomeDialog.xaml index d4d320e8a5..8944a8a7af 100644 --- a/BetterGenshinImpact/View/Windows/WelcomeDialog.xaml +++ b/BetterGenshinImpact/View/Windows/WelcomeDialog.xaml @@ -1,9 +1,10 @@ - - \ No newline at end of file + diff --git a/BetterGenshinImpact/ViewModel/Pages/CommonSettingsPageViewModel.cs b/BetterGenshinImpact/ViewModel/Pages/CommonSettingsPageViewModel.cs index 9ee97ca0a7..23f88d6476 100644 --- a/BetterGenshinImpact/ViewModel/Pages/CommonSettingsPageViewModel.cs +++ b/BetterGenshinImpact/ViewModel/Pages/CommonSettingsPageViewModel.cs @@ -7,6 +7,9 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; using System.Threading.Tasks; using System.Windows; using Windows.System; @@ -20,6 +23,7 @@ using BetterGenshinImpact.GameTask.Common.Element.Assets; using BetterGenshinImpact.GameTask.LogParse; using BetterGenshinImpact.Helpers; +using BetterGenshinImpact.Helpers.Http; using BetterGenshinImpact.Model; using BetterGenshinImpact.Service.Interface; using BetterGenshinImpact.Service.Notification; @@ -34,6 +38,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Win32; +using Newtonsoft.Json; using Wpf.Ui; namespace BetterGenshinImpact.ViewModel.Pages; @@ -77,7 +82,7 @@ public CommonSettingsPageViewModel(IConfigService configService, INavigationServ public ObservableCollection MapPathingTypes { get; } = ["SIFT", "TemplateMatch"]; [ObservableProperty] private FrozenDictionary _languageDict = - new string[] { "zh-Hans", "zh-Hant", "en", "fr" } + new string[] { "zh-Hans", "zh-Hant", "en"} .ToFrozenDictionary( c => c, c => @@ -89,6 +94,94 @@ public CommonSettingsPageViewModel(IConfigService configService, INavigationServ } ); + [RelayCommand] + private async Task OnUpdateUiLanguageAsync() + { + var cultureName = Config.OtherConfig.UiCultureInfoName ?? string.Empty; + if (string.IsNullOrWhiteSpace(cultureName)) + { + throw new InvalidOperationException("当前UI语言为空,无法更新语言文件。"); + } + + if (cultureName == "zh-Hans") + { + await ThemedMessageBox.InformationAsync("zh-Hans 无语言文件,无需更新。"); + return; + } + + var urls = new[] + { + $"https://raw.githubusercontent.com/babalae/bettergi-i18n/refs/heads/main/i18n/{cultureName}.json", + $"https://cnb.cool/bettergi/bettergi-i18n/-/git/raw/main/i18n/{cultureName}.json" + }; + + using var httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + + byte[]? bytes = null; + Exception? lastError = null; + var allNotFound = true; + foreach (var url in urls) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd("BetterGenshinImpact"); + using var response = await httpClient.SendAsync(request); + if (response.StatusCode == HttpStatusCode.NotFound) + { + lastError = new HttpRequestException("Language file not found.", null, response.StatusCode); + continue; + } + + allNotFound = false; + response.EnsureSuccessStatusCode(); + bytes = await response.Content.ReadAsByteArrayAsync(); + + var json = Encoding.UTF8.GetString(bytes); + _ = JsonConvert.DeserializeObject>(json) + ?? throw new JsonException("翻译文件不是有效的 JSON 字典。"); + break; + } + catch (Exception e) + { + lastError = e; + allNotFound = false; + } + } + + if (bytes == null) + { + if (allNotFound) + { + await ThemedMessageBox.WarningAsync($"语言文件不存在:{cultureName}.json"); + return; + } + + throw new Exception($"下载语言文件失败:{cultureName}.json", lastError); + } + + var dir = Global.Absolute(@"User\I18n"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"{cultureName}.json"); + var tmp = $"{path}.{Guid.NewGuid():N}.tmp"; + await File.WriteAllBytesAsync(tmp, bytes); + + if (File.Exists(path)) + { + File.Replace(tmp, path, null); + } + else + { + File.Move(tmp, path); + } + + var translator = App.GetService() ?? throw new NullReferenceException(); + translator.Reload(); + } + public string SelectedCountry { get => _selectedCountry;