Files
better-genshin-impact/BetterGenshinImpact/ViewModel/Pages/CommonSettingsPageViewModel.cs
辉鸭蛋 9f67726781 I18n v2 (#2709)
* feat(i18n): 添加界面与日志的国际化支持

- 新增 ITranslationService 接口及 JsonTranslationService 实现,提供基于 JSON 的翻译服务
- 添加 TrConverter 转换器,支持通过绑定动态翻译界面文本
- 引入 AutoTranslateInterceptor 行为,自动扫描并翻译界面中的静态文本
- 集成 TranslatingSerilogLoggerProvider,实现日志输出的实时翻译
- 在 App.xaml 中注册全局样式,为 Window、UserControl 和 Page 启用自动翻译

* refactor(AutoTranslateInterceptor): 优化自动翻译拦截器的加载与应用机制

- 移除 HomePage 中冗余的 EnableAutoTranslate 属性设置,改为继承属性
- 通过类构造函数注册全局 Loaded 事件处理器,替代在每个元素上单独添加
- 引入请求队列机制,批量处理待应用翻译的元素,避免重复调度
- 扩展属性类型检查,支持 object 类型以处理更多动态内容场景

* fix: 移除全局自动翻译拦截器以避免冲突

移除在 App.xaml 中为 Window、UserControl 和 Page 全局设置的 AutoTranslateInterceptor,
改为仅在 PickerWindow 中显式启用。这解决了全局样式可能导致的意外行为或冲突。

* feat(ui): 为多个窗口启用自动翻译拦截器

为 MapLabelSearchWindow、ArtifactOcrDialog、PromptDialog 等 14 个窗口添加了 AutoTranslateInterceptor.EnableAutoTranslate 属性,以启用自动翻译拦截功能。

* feat(i18n): 添加国际化目录支持并优化异常处理

* feat(ui): 添加软件UI语言设置并改进翻译服务

- 在通用设置页面新增UI语言选择控件,支持动态切换界面语言
- 修改游戏语言标签为“原神游戏语言”以明确区分
- 改进JsonTranslationService,支持UI语言切换时的实时翻译更新
- 优化AutoTranslateInterceptor,缓存原始文本值并在语言切换时恢复
- 添加属性变更监听机制,确保UI元素在语言切换后正确刷新

* feat(自动翻译): 添加排除自动翻译的依赖属性

在 AutoTranslateInterceptor 中新增 ExcludeAutoTranslate 附加属性,允许对特定依赖对象禁用自动翻译功能。当遍历元素进行翻译时,会检查此属性并跳过已标记排除的元素。

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

扩展翻译服务以收集缺失文本的详细上下文,包括视图路径、元素类型、属性名等。
重构 `ITranslationService` 接口,引入 `TranslationSourceInfo` 类封装上下文信息。
修改 `AutoTranslateInterceptor` 自动收集 UI 元素信息,`JsonTranslationService` 合并多来源上下文。

* Revert "feat(自动翻译): 添加排除自动翻译的依赖属性"

This reverts commit a1c2334951.

* fix: 跳过 GridViewRowPresenter 中的文本翻译

添加 IsInGridViewRowPresenter 检查,避免在 GridViewRowPresenter 控件内进行自动翻译,防止潜在的界面显示问题。

* fix: 修复自动翻译拦截器在组合框上下文中的误触发

在自动翻译拦截器中添加了 IsInComboBoxContext 方法,用于检测依赖对象是否处于 ComboBox 或其相关弹出菜单的上下文中。当检测到对象位于组合框上下文时,跳过自动翻译逻辑,避免对下拉选项等界面元素进行不必要的翻译操作,从而解决潜在的界面干扰问题。

* feat(translation): 添加缺失翻译上报至 Supabase 的功能

- 新增 IMissingTranslationReporter 接口及 SupabaseMissingTranslationReporter 实现
- 在 JsonTranslationService 中集成缺失翻译上报逻辑
- 添加缺失翻译收集的配置设置(MissingTranslationCollectionSettings)
- 优化缺失翻译文件的序列化格式,将 Source 字段改为紧凑的数字表示
- 移除 ScriptRepoUpdater 中未使用的 using 语句
- 在 App.xaml.cs 中注册 SupabaseMissingTranslationReporter 服务

* fix: 修复自动翻译功能中原始值恢复和重复报告问题

- 移除未使用的法语翻译支持以简化语言选项
- 修复 Supabase 报告序列化时移除冗余字段
- 添加已缺失翻译键的缓存以避免重复报告
- 重构自动翻译拦截器,将原始值存储移至依赖属性
- 修复原始值恢复逻辑,确保正确遍历所有子元素

* feat(ui): 添加更新UI语言文件功能

- 在 ITranslationService 接口中添加 Reload 方法
- 在 JsonTranslationService 中实现 Reload 方法,支持重新加载语言文件并发送变更通知
- 在通用设置页面添加“更新”按钮,点击后从远程仓库下载最新语言文件
- 实现 OnUpdateUiLanguageAsync 命令,支持从 GitHub 和镜像源下载语言文件
- 下载后自动替换本地文件并重新加载翻译服务
2026-02-15 19:03:33 +08:00

468 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
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;
using BetterGenshinImpact.Core.Config;
using BetterGenshinImpact.Core.Recognition;
using BetterGenshinImpact.Core.Recognition.OCR;
using BetterGenshinImpact.Core.Recognition.OCR.Paddle;
using BetterGenshinImpact.Core.Script;
using BetterGenshinImpact.GameTask;
using BetterGenshinImpact.GameTask.AutoTrackPath;
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;
using BetterGenshinImpact.View.Controls.Webview;
using BetterGenshinImpact.View.Converters;
using BetterGenshinImpact.View.Pages;
using BetterGenshinImpact.View.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Win32;
using Newtonsoft.Json;
using Wpf.Ui;
namespace BetterGenshinImpact.ViewModel.Pages;
public partial class CommonSettingsPageViewModel : ViewModel
{
private readonly INavigationService _navigationService;
private readonly NotificationService _notificationService;
private readonly TpConfig _tpConfig = TaskContext.Instance().Config.TpConfig;
private string _selectedArea = string.Empty;
private string _selectedCountry = string.Empty;
[ObservableProperty] private List<string> _adventurersGuildCountry = ["无", "枫丹", "稻妻", "璃月", "蒙德"];
[ObservableProperty] private List<Tuple<TimeSpan, string>> _serverTimeZones =
[
Tuple.Create(TimeSpan.FromHours(8), "其他 UTC+08"),
Tuple.Create(TimeSpan.FromHours(1), "欧服 UTC+01"),
Tuple.Create(TimeSpan.FromHours(-5), "美服 UTC-05")
];
public CommonSettingsPageViewModel(IConfigService configService, INavigationService navigationService,
NotificationService notificationService)
{
Config = configService.Get();
_navigationService = navigationService;
_notificationService = notificationService;
InitializeCountries();
InitializeMiyousheCookie();
// 初始化OCR模型选择
SelectedPaddleOcrModelConfig = Config.OtherConfig.OcrConfig.PaddleOcrModelConfig;
}
public AllConfig Config { get; set; }
public ObservableCollection<string> CountryList { get; } = new();
public ObservableCollection<string> Areas { get; } = new();
public ObservableCollection<string> MapPathingTypes { get; } = ["SIFT", "TemplateMatch"];
[ObservableProperty] private FrozenDictionary<string, string> _languageDict =
new string[] { "zh-Hans", "zh-Hant", "en"}
.ToFrozenDictionary(
c => c,
c =>
{
CultureInfo.CurrentUICulture = new CultureInfo(c);
var stringLocalizer = App.GetService<IStringLocalizer<CultureInfoNameToKVPConverter>>() ??
throw new NullReferenceException();
return stringLocalizer["简体中文"].ToString();
}
);
[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<Dictionary<string, string>>(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<ITranslationService>() ?? throw new NullReferenceException();
translator.Reload();
}
public string SelectedCountry
{
get => _selectedCountry;
set
{
if (SetProperty(ref _selectedCountry, value))
{
UpdateAreas(value);
SelectedArea = Areas.FirstOrDefault() ?? string.Empty;
}
}
}
public string SelectedArea
{
get => _selectedArea;
set
{
if (SetProperty(ref _selectedArea, value))
{
UpdateRevivePoint(SelectedCountry, SelectedArea);
}
}
}
public ObservableCollection<PaddleOcrModelConfig> PaddleOcrModelConfigs { get; } =
new(Enum.GetValues(typeof(PaddleOcrModelConfig)).Cast<PaddleOcrModelConfig>());
[ObservableProperty] private PaddleOcrModelConfig _selectedPaddleOcrModelConfig;
[RelayCommand]
public void OnQuestionButtonOnClick()
{
// Owner = this,
WebpageWindow cookieWin = new()
{
Title = "日志分析",
Width = 800,
Height = 600,
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
cookieWin.NavigateToHtml(TravelsDiaryDetailManager.generHtmlMessage());
cookieWin.Show();
}
private void InitializeMiyousheCookie()
{
OtherConfig.Miyoushe mcfg = TaskContext.Instance().Config.OtherConfig.MiyousheConfig;
if (mcfg.Cookie == string.Empty &&
mcfg.LogSyncCookie)
{
var config = LogParse.LoadConfig();
mcfg.Cookie = config.Cookie;
}
}
private void InitializeCountries()
{
var countries = MapLazyAssets.Instance.GoddessPositions.Values
.OrderBy(g => int.TryParse(g.Id, out var id) ? id : int.MaxValue)
.GroupBy(g => g.Country)
.Select(grp => grp.Key);
CountryList.Clear();
foreach (var country in countries)
{
if (!string.IsNullOrEmpty(country))
{
CountryList.Add(country);
}
}
_selectedCountry = _tpConfig.ReviveStatueOfTheSevenCountry;
UpdateAreas(SelectedCountry);
_selectedArea = _tpConfig.ReviveStatueOfTheSevenArea;
UpdateRevivePoint(SelectedCountry, SelectedArea);
}
private void UpdateAreas(string country)
{
Areas.Clear();
SelectedArea = string.Empty;
if (string.IsNullOrEmpty(country)) return;
var areas = MapLazyAssets.Instance.GoddessPositions.Values
.Where(g => g.Country == country)
.OrderBy(g => int.TryParse(g.Id, out var id) ? id : int.MaxValue)
.GroupBy(g => g.Level1Area)
.Select(grp => grp.Key);
foreach (var area in areas)
{
if (!string.IsNullOrEmpty(area))
{
Areas.Add(area);
}
}
}
// 当国家或区域改变时更新坐标
private void UpdateRevivePoint(string country, string area)
{
if (string.IsNullOrEmpty(country) || string.IsNullOrEmpty(area)) return;
var goddess = MapLazyAssets.Instance.GoddessPositions.Values
.FirstOrDefault(g => g.Country == country && g.Level1Area == area);
if (goddess == null) return;
_tpConfig.ReviveStatueOfTheSevenCountry = country;
_tpConfig.ReviveStatueOfTheSevenArea = area;
_tpConfig.ReviveStatueOfTheSevenPointX = goddess.X;
_tpConfig.ReviveStatueOfTheSevenPointY = goddess.Y;
_tpConfig.ReviveStatueOfTheSeven = goddess;
}
[RelayCommand]
public void OnRefreshMaskSettings()
{
WeakReferenceMessenger.Default.Send(
new PropertyChangedMessage<object>(this, "RefreshSettings", new object(), "重新计算控件位置"));
}
[RelayCommand]
private void OnResetMaskOverlayLayout()
{
var c = Config.MaskWindowConfig;
c.StatusListLeftRatio = 20.0 / 1920;
c.StatusListTopRatio = 807.0 / 1080;
c.StatusListWidthRatio = 477.0 / 1920;
c.StatusListHeightRatio = 24.0 / 1080;
c.LogTextBoxLeftRatio = 20.0 / 1920;
c.LogTextBoxTopRatio = 832.0 / 1080;
c.LogTextBoxWidthRatio = 477.0 / 1920;
c.LogTextBoxHeightRatio = 188.0 / 1080;
OnRefreshMaskSettings();
}
[RelayCommand]
private void OnSwitchMaskEnabled()
{
// if (Config.MaskWindowConfig.MaskEnabled)
// {
// MaskWindow.Instance().Show();
// }
// else
// {
// MaskWindow.Instance().Hide();
// }
}
[RelayCommand]
public void OnGoToHotKeyPage()
{
_navigationService.Navigate(typeof(HotKeyPage));
}
[RelayCommand]
public void OnSwitchTakenScreenshotEnabled()
{
}
[RelayCommand]
public void OnGoToFolder()
{
var path = Global.Absolute(@"log\screenshot\");
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
Process.Start("explorer.exe", path);
}
[RelayCommand]
public void OnGoToLogFolder()
{
var path = Global.Absolute(@"log");
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
Process.Start("explorer.exe", path);
}
[RelayCommand]
private void ImportLocalScriptsRepoZip()
{
Directory.CreateDirectory(ScriptRepoUpdater.ReposPath);
var dialog = new OpenFileDialog
{
Filter = "Zip Files (*.zip)|*.zip",
Multiselect = false
};
if (dialog.ShowDialog() == true)
{
var zipPath = dialog.FileName;
// 删除旧文件夹
if (Directory.Exists(ScriptRepoUpdater.CenterRepoPathOld))
{
DirectoryHelper.DeleteReadOnlyDirectory(ScriptRepoUpdater.CenterRepoPathOld);
}
ZipFile.ExtractToDirectory(zipPath, ScriptRepoUpdater.ReposPath, true);
if (Directory.Exists(ScriptRepoUpdater.CenterRepoPathOld))
{
DirectoryHelper.CopyDirectory(ScriptRepoUpdater.CenterRepoPathOld, ScriptRepoUpdater.CenterRepoPath);
ThemedMessageBox.Information("脚本仓库离线包导入成功!");
}
else
{
ThemedMessageBox.Error("脚本仓库离线包导入失败,不正确的脚本仓库离线包内容!");
DirectoryHelper.DeleteReadOnlyDirectory(ScriptRepoUpdater.ReposPath);
}
}
}
[RelayCommand]
private void OpenAboutWindow()
{
var aboutWindow = new AboutWindow();
aboutWindow.Owner = Application.Current.MainWindow;
aboutWindow.ShowDialog();
}
[RelayCommand]
private void OpenKeyBindingsWindow()
{
var keyBindingsWindow = KeyBindingsWindow.Instance;
keyBindingsWindow.Owner = Application.Current.MainWindow;
keyBindingsWindow.ShowDialog();
}
[RelayCommand]
private async Task CheckUpdateAsync()
{
await App.GetService<IUpdateService>()!.CheckUpdateAsync(new UpdateOption
{
Trigger = UpdateTrigger.Manual,
Channel = UpdateChannel.Stable
});
}
[RelayCommand]
private async Task CheckUpdateAlphaAsync()
{
var result = await ThemedMessageBox.ShowAsync("测试版本非常不稳定!\n测试版本非常不稳定\n测试版本非常不稳定\n\n是否继续检查更新", "警告", MessageBoxButton.YesNo, ThemedMessageBox.MessageBoxIcon.Warning);
if (result != MessageBoxResult.Yes)
{
return;
}
await App.GetService<IUpdateService>()!.CheckUpdateAsync(new UpdateOption
{
Trigger = UpdateTrigger.Manual,
Channel = UpdateChannel.Alpha,
});
}
// [RelayCommand]
// private async Task GotoGithubActionAsync()
// {
// await Launcher.LaunchUriAsync(
// new Uri("https://github.com/babalae/better-genshin-impact/actions/workflows/publish.yml"));
// }
[RelayCommand]
private async Task OnGameLangSelectionChanged(KeyValuePair<string, string> type)
{
await App.ServiceProvider.GetRequiredService<OcrFactory>().Unload();
}
[RelayCommand]
private async Task OnPaddleOcrModelConfigChanged(PaddleOcrModelConfig value)
{
Config.OtherConfig.OcrConfig.PaddleOcrModelConfig = value;
await App.ServiceProvider.GetRequiredService<OcrFactory>().Unload();
}
}