Skip to content

Commit 7da80cd

Browse files
JusterZhuCopilot
andauthored
feat: multi-language support (zh-CN/en-US) and dark/light theme (#23)
* feat: multi-language (zh-CN/en-US) and dark/light theme toggle - Add LocalizationService with dictionary-based i18n for 70+ strings - Add theme toggle button (🌙/☀️) in sidebar - Add locale toggle button (EN/中) in sidebar - All labels in PatchView/ExtensionView/OSSView use localized strings - ViewModels use LocalizationService for status/error messages - Navigation items re-sync when locale changes * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent b78dba8 commit 7da80cd

9 files changed

Lines changed: 476 additions & 88 deletions
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using System.Collections.Generic;
2+
using System.ComponentModel;
3+
using System.Runtime.CompilerServices;
4+
5+
namespace GeneralUpdate.Tools.Services;
6+
7+
/// <summary>
8+
/// Simple dictionary-based localization. Supports zh-CN and en-US.
9+
/// Usage: LocalizationService.Instance["Patch.Title"]
10+
/// </summary>
11+
public class LocalizationService : INotifyPropertyChanged
12+
{
13+
public static LocalizationService Instance { get; } = new();
14+
15+
private string _locale = "zh-CN";
16+
17+
public string Locale
18+
{
19+
get => _locale;
20+
set { if (_locale != value) { _locale = value; OnPropertyChanged(); OnPropertyChanged("Item"); } }
21+
}
22+
23+
public string this[string key]
24+
{
25+
get
26+
{
27+
if (_strings.TryGetValue(_locale, out var langDict) && langDict.TryGetValue(key, out var val))
28+
return val;
29+
// Fallback to zh-CN
30+
if (_locale != "zh-CN" && _strings.TryGetValue("zh-CN", out var fallback) && fallback.TryGetValue(key, out var fb))
31+
return fb;
32+
return key;
33+
}
34+
}
35+
36+
private readonly Dictionary<string, Dictionary<string, string>> _strings = new()
37+
{
38+
["zh-CN"] = new()
39+
{
40+
["App.Title"] = "GeneralUpdate Tools",
41+
["Nav.Patch"] = "补丁包",
42+
["Nav.Extension"] = "扩展包",
43+
["Nav.OSS"] = "OSS配置",
44+
["Patch.Title"] = "补丁包生成",
45+
["Patch.CorePaths"] = "核心路径",
46+
["Patch.OldDir"] = "旧版本目录",
47+
["Patch.NewDir"] = "新版本目录",
48+
["Patch.Select"] = "选择",
49+
["Patch.OldPlaceholder"] = "选择旧版本应用目录...",
50+
["Patch.NewPlaceholder"] = "选择新版本发布目录...",
51+
["Patch.PackageInfo"] = "包信息",
52+
["Patch.PackageName"] = "包名",
53+
["Patch.Version"] = "版本",
54+
["Patch.OutputDir"] = "输出目录",
55+
["Patch.OutputPlaceholder"] = "桌面 (默认)",
56+
["Patch.Build"] = "开始构建",
57+
["Patch.Ready"] = "就绪",
58+
["Patch.ValidateDirs"] = "请选择新旧版本目录",
59+
["Patch.Building"] = "正在生成差分补丁...",
60+
["Patch.Comparing"] = "对比目录差异 + 生成 BSDiff40 补丁...",
61+
["Patch.PatchDone"] = "补丁生成完成",
62+
["Patch.Packing"] = "打包: {0}",
63+
["Patch.Success"] = "成功: {0} ({1:F1} KB)",
64+
["Patch.Failed"] = "失败: {0}",
65+
["Patch.Error"] = "错误: {0}",
66+
["Patch.OldSelected"] = "旧版本: {0}",
67+
["Patch.NewSelected"] = "新版本: {0}",
68+
["Patch.TempDir"] = "临时目录: {0}",
69+
["Ext.Title"] = "扩展包生成",
70+
["Ext.BasicInfo"] = "基本信息",
71+
["Ext.Name"] = "名称",
72+
["Ext.Version"] = "版本",
73+
["Ext.Description"] = "描述",
74+
["Ext.DescPlaceholder"] = "扩展功能描述...",
75+
["Ext.Publisher"] = "发布者",
76+
["Ext.License"] = "许可证",
77+
["Ext.LicensePlaceholder"] = "MIT",
78+
["Ext.Paths"] = "路径",
79+
["Ext.ExtDir"] = "扩展目录",
80+
["Ext.ExportDir"] = "导出目录",
81+
["Ext.CustomProps"] = "自定义属性",
82+
["Ext.Key"] = "Key",
83+
["Ext.Value"] = "Value",
84+
["Ext.AddProp"] = "+ 添加",
85+
["Ext.Generate"] = "生成扩展包",
86+
["Ext.ValidateNameVer"] = "请填写扩展名称和版本",
87+
["Ext.ValidateDir"] = "请选择有效的扩展目录",
88+
["Ext.Building"] = "正在生成扩展包...",
89+
["Ext.Success"] = "成功: {0}",
90+
["Ext.Failed"] = "失败: {0}",
91+
["OSS.Title"] = "OSS 配置生成",
92+
["OSS.NewEntry"] = "新建条目",
93+
["OSS.PacketName"] = "包名",
94+
["OSS.Version"] = "版本",
95+
["OSS.Url"] = "URL",
96+
["OSS.SHA256"] = "SHA256",
97+
["OSS.ComputeHash"] = "计算",
98+
["OSS.AddToList"] = "添加到列表",
99+
["OSS.ConfigList"] = "配置列表",
100+
["OSS.Clear"] = "清空",
101+
["OSS.Export"] = "导出 JSON",
102+
["OSS.Added"] = "已添加",
103+
["OSS.Cleared"] = "已清空",
104+
["OSS.Exported"] = "导出: {0} 条",
105+
["OSS.HashResult"] = "SHA256: {0}",
106+
["Theme.Light"] = "浅色",
107+
["Theme.Dark"] = "深色",
108+
["Theme.Toggle"] = "切换主题",
109+
},
110+
["en-US"] = new()
111+
{
112+
["App.Title"] = "GeneralUpdate Tools",
113+
["Nav.Patch"] = "Patch",
114+
["Nav.Extension"] = "Extension",
115+
["Nav.OSS"] = "OSS Config",
116+
["Patch.Title"] = "Patch Package",
117+
["Patch.CorePaths"] = "Core Paths",
118+
["Patch.OldDir"] = "Old Directory",
119+
["Patch.NewDir"] = "New Directory",
120+
["Patch.Select"] = "Select",
121+
["Patch.OldPlaceholder"] = "Select old version directory...",
122+
["Patch.NewPlaceholder"] = "Select new version directory...",
123+
["Patch.PackageInfo"] = "Package Info",
124+
["Patch.PackageName"] = "Package Name",
125+
["Patch.Version"] = "Version",
126+
["Patch.OutputDir"] = "Output Directory",
127+
["Patch.OutputPlaceholder"] = "Desktop (default)",
128+
["Patch.Build"] = "Build",
129+
["Patch.Ready"] = "Ready",
130+
["Patch.ValidateDirs"] = "Please select both old and new directories",
131+
["Patch.Building"] = "Generating diff patch...",
132+
["Patch.Comparing"] = "Comparing directories + generating BSDiff40 patches...",
133+
["Patch.PatchDone"] = "Patch generation complete",
134+
["Patch.Packing"] = "Packing: {0}",
135+
["Patch.Success"] = "Success: {0} ({1:F1} KB)",
136+
["Patch.Failed"] = "Failed: {0}",
137+
["Patch.Error"] = "Error: {0}",
138+
["Patch.OldSelected"] = "Old version: {0}",
139+
["Patch.NewSelected"] = "New version: {0}",
140+
["Patch.TempDir"] = "Temp directory: {0}",
141+
["Ext.Title"] = "Extension Package",
142+
["Ext.BasicInfo"] = "Basic Info",
143+
["Ext.Name"] = "Name",
144+
["Ext.Version"] = "Version",
145+
["Ext.Description"] = "Description",
146+
["Ext.DescPlaceholder"] = "Extension description...",
147+
["Ext.Publisher"] = "Publisher",
148+
["Ext.License"] = "License",
149+
["Ext.LicensePlaceholder"] = "MIT",
150+
["Ext.Paths"] = "Paths",
151+
["Ext.ExtDir"] = "Extension Directory",
152+
["Ext.ExportDir"] = "Export Directory",
153+
["Ext.CustomProps"] = "Custom Properties",
154+
["Ext.Key"] = "Key",
155+
["Ext.Value"] = "Value",
156+
["Ext.AddProp"] = "+ Add",
157+
["Ext.Generate"] = "Generate Extension",
158+
["Ext.ValidateNameVer"] = "Please fill in extension name and version",
159+
["Ext.ValidateDir"] = "Please select a valid extension directory",
160+
["Ext.Building"] = "Generating extension package...",
161+
["Ext.Success"] = "Success: {0}",
162+
["Ext.Failed"] = "Failed: {0}",
163+
["OSS.Title"] = "OSS Config Generator",
164+
["OSS.NewEntry"] = "New Entry",
165+
["OSS.PacketName"] = "Package Name",
166+
["OSS.Version"] = "Version",
167+
["OSS.Url"] = "URL",
168+
["OSS.SHA256"] = "SHA256",
169+
["OSS.ComputeHash"] = "Compute",
170+
["OSS.AddToList"] = "Add to List",
171+
["OSS.ConfigList"] = "Config List",
172+
["OSS.Clear"] = "Clear",
173+
["OSS.Export"] = "Export JSON",
174+
["OSS.Added"] = "Added",
175+
["OSS.Cleared"] = "Cleared",
176+
["OSS.Exported"] = "Exported: {0} entries",
177+
["OSS.HashResult"] = "SHA256: {0}",
178+
["Theme.Light"] = "Light",
179+
["Theme.Dark"] = "Dark",
180+
["Theme.Toggle"] = "Toggle Theme",
181+
}
182+
};
183+
184+
public event PropertyChangedEventHandler? PropertyChanged;
185+
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
186+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
187+
188+
public string T(string key) => this[key];
189+
public string T(string key, params object[] args) => string.Format(this[key], args);
190+
}

src/ViewModels/ExtensionViewModel.cs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.ObjectModel;
33
using System.IO;
44
using System.Linq;
@@ -13,14 +13,33 @@ namespace GeneralUpdate.Tools.ViewModels;
1313
public partial class ExtensionViewModel : ViewModelBase
1414
{
1515
private readonly PackageService _pkg = new();
16+
private readonly LocalizationService _loc = LocalizationService.Instance;
17+
1618
public ExtensionConfigModel Config { get; } = new();
1719
[ObservableProperty] private bool _isBuilding;
18-
[ObservableProperty] private string _status = "就绪";
20+
[ObservableProperty] private string _status;
1921
[ObservableProperty] private string _newPropKey = "";
2022
[ObservableProperty] private string _newPropValue = "";
2123
public ObservableCollection<CustomPropModel> CustomProps { get; } = new();
2224

23-
async Task<string?> Pick() { var tl = Avalonia.Controls.TopLevel.GetTopLevel((Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); if (tl == null) return null; var r = await tl.StorageProvider.OpenFolderPickerAsync(new Avalonia.Platform.Storage.FolderPickerOpenOptions { Title = "选择目录", AllowMultiple = false }); return r.Count > 0 ? r[0].Path.LocalPath : null; }
25+
public ExtensionViewModel() { _status = _loc["Patch.Ready"]; }
26+
27+
string GetFolderPickerTitle()
28+
{
29+
var title = _loc["Ext.SelectDirectoryTitle"];
30+
if (!string.IsNullOrWhiteSpace(title) && title != "Ext.SelectDirectoryTitle") return title;
31+
return string.Equals(System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, "zh", StringComparison.OrdinalIgnoreCase)
32+
? "选择目录"
33+
: "Select folder";
34+
}
35+
36+
async Task<string?> Pick()
37+
{
38+
var tl = Avalonia.Controls.TopLevel.GetTopLevel((Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow);
39+
if (tl == null) return null;
40+
var r = await tl.StorageProvider.OpenFolderPickerAsync(new Avalonia.Platform.Storage.FolderPickerOpenOptions { Title = GetFolderPickerTitle(), AllowMultiple = false });
41+
return r.Count > 0 ? r[0].Path.LocalPath : null;
42+
}
2443

2544
[RelayCommand] async Task SelectExt() { var p = await Pick(); if (p != null) Config.ExtensionDirectory = p; }
2645
[RelayCommand] async Task SelectExport() { var p = await Pick(); if (p != null) Config.ExportPath = p; }
@@ -29,9 +48,9 @@ public partial class ExtensionViewModel : ViewModelBase
2948

3049
[RelayCommand] async Task Generate()
3150
{
32-
if (string.IsNullOrWhiteSpace(Config.Name) || string.IsNullOrWhiteSpace(Config.Version)) { Status = "请填写扩展名称和版本"; return; }
33-
if (string.IsNullOrWhiteSpace(Config.ExtensionDirectory) || !Directory.Exists(Config.ExtensionDirectory)) { Status = "请选择有效的扩展目录"; return; }
34-
IsBuilding = true; Status = "正在生成扩展包...";
51+
if (string.IsNullOrWhiteSpace(Config.Name) || string.IsNullOrWhiteSpace(Config.Version)) { Status = _loc["Ext.ValidateNameVer"]; return; }
52+
if (string.IsNullOrWhiteSpace(Config.ExtensionDirectory) || !Directory.Exists(Config.ExtensionDirectory)) { Status = _loc["Ext.ValidateDir"]; return; }
53+
IsBuilding = true; Status = _loc["Ext.Building"];
3554
try
3655
{
3756
var dir = string.IsNullOrWhiteSpace(Config.ExportPath) ? Environment.GetFolderPath(Environment.SpecialFolder.Desktop) : Config.ExportPath;
@@ -45,9 +64,9 @@ [RelayCommand] async Task Generate()
4564
customProperties = CustomProps.ToDictionary(p => p.Key, p => p.Value)
4665
});
4766
Config.OutputPath = zip;
48-
Status = $"成功: {Path.GetFileName(zip)}";
67+
Status = _loc.T("Ext.Success", Path.GetFileName(zip));
4968
}
50-
catch (Exception ex) { Status = $"失败: {ex.Message}"; }
69+
catch (Exception ex) { Status = _loc.T("Ext.Failed", ex.Message); }
5170
finally { IsBuilding = false; }
5271
}
5372
static string Sanitize(string n) => string.Join("_", n.Split(Path.GetInvalidFileNameChars()));

src/ViewModels/MainWindowViewModel.cs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,72 @@
1-
using System.Collections.ObjectModel;
2-
using System.Linq;
1+
using System.Collections.ObjectModel;
32
using CommunityToolkit.Mvvm.ComponentModel;
43
using CommunityToolkit.Mvvm.Input;
54
using GeneralUpdate.Tools.Models;
5+
using GeneralUpdate.Tools.Services;
66

77
namespace GeneralUpdate.Tools.ViewModels;
88

99
public partial class MainWindowViewModel : ViewModelBase
1010
{
11+
private readonly LocalizationService _loc = LocalizationService.Instance;
12+
1113
[ObservableProperty] private ViewModelBase _currentPage = new PatchViewModel();
14+
[ObservableProperty] private bool _isDarkTheme;
15+
[ObservableProperty] private string _themeButtonText = "🌙";
16+
[ObservableProperty] private string _localeText = "EN";
17+
1218
public ObservableCollection<NavItem> NavItems { get; } = new();
1319

1420
public MainWindowViewModel()
1521
{
16-
NavItems.Add(new("Patch", "补丁包", typeof(PatchViewModel), true));
17-
NavItems.Add(new("Extension", "扩展包", typeof(ExtensionViewModel), false));
18-
NavItems.Add(new("OSS", "OSS配置", typeof(OSSViewModel), false));
22+
SyncNavItems();
23+
_loc.PropertyChanged += (_, e) =>
24+
{
25+
if (e.PropertyName != nameof(LocalizationService.Locale))
26+
return;
27+
28+
SyncNavItems();
29+
LocaleText = _loc.Locale == "zh-CN" ? "EN" : "中";
30+
};
31+
}
32+
33+
private void SyncNavItems()
34+
{
35+
NavItems.Clear();
36+
NavItems.Add(new("Patch", _loc["Nav.Patch"], typeof(PatchViewModel), true));
37+
NavItems.Add(new("Extension", _loc["Nav.Extension"], typeof(ExtensionViewModel), false));
38+
NavItems.Add(new("OSS", _loc["Nav.OSS"], typeof(OSSViewModel), false));
39+
}
40+
41+
[RelayCommand] private void Navigate(NavItem item)
42+
{
43+
foreach (var n in NavItems) n.IsSelected = false;
44+
item.IsSelected = true;
45+
CurrentPage = item.Key switch
46+
{
47+
"Patch" => new PatchViewModel(),
48+
"Extension" => new ExtensionViewModel(),
49+
_ => new OSSViewModel()
50+
};
1951
}
2052

21-
[RelayCommand] private void Navigate(NavItem item) { foreach (var n in NavItems) n.IsSelected = false; item.IsSelected = true; CurrentPage = item.Key switch { "Patch" => new PatchViewModel(), "Extension" => new ExtensionViewModel(), _ => new OSSViewModel() }; }
53+
[RelayCommand]
54+
private void ToggleTheme()
55+
{
56+
IsDarkTheme = !IsDarkTheme;
57+
ThemeButtonText = IsDarkTheme ? "☀️" : "🌙";
58+
var app = Avalonia.Application.Current;
59+
if (app != null)
60+
app.RequestedThemeVariant = IsDarkTheme
61+
? Avalonia.Styling.ThemeVariant.Dark
62+
: Avalonia.Styling.ThemeVariant.Light;
63+
}
64+
65+
[RelayCommand]
66+
private void ToggleLocale()
67+
{
68+
_loc.Locale = _loc.Locale == "zh-CN" ? "en-US" : "zh-CN";
69+
}
2270
}
2371

2472
public partial class NavItem : ObservableObject

src/ViewModels/OSSViewModel.cs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.ObjectModel;
33
using System.IO;
44
using System.Threading.Tasks;
@@ -13,13 +13,40 @@ namespace GeneralUpdate.Tools.ViewModels;
1313
public partial class OSSViewModel : ViewModelBase
1414
{
1515
private readonly HashService _hash = new();
16+
private readonly LocalizationService _loc = LocalizationService.Instance;
17+
1618
public ObservableCollection<OSSConfigModel> Configs { get; } = new();
1719
[ObservableProperty] private OSSConfigModel _current = new();
18-
[ObservableProperty] private string _status = "就绪";
20+
[ObservableProperty] private string _status;
21+
22+
public OSSViewModel() { _status = _loc["Patch.Ready"]; }
23+
24+
private string GetOpenFilePickerTitle()
25+
{
26+
var title = _loc["Patch.SelectFile"];
27+
if (string.IsNullOrWhiteSpace(title) || title == "Patch.SelectFile" || title == _loc["Patch.Select"])
28+
{
29+
return "选择文件";
30+
}
31+
32+
return title;
33+
}
1934

20-
[RelayCommand] async Task ComputeHash() { var tl = Avalonia.Controls.TopLevel.GetTopLevel((Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); if (tl == null) return; var files = await tl.StorageProvider.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions { Title = "选择文件", AllowMultiple = false }); if (files.Count > 0) { Current.Hash = await _hash.ComputeHashAsync(files[0].Path.LocalPath); Status = $"SHA256: {Current.Hash}"; } }
21-
[RelayCommand] void Append() { Configs.Add(new() { PacketName = Current.PacketName, Hash = Current.Hash, Version = Current.Version, Url = Current.Url, ReleaseDate = Current.ReleaseDate }); Status = "已添加"; }
35+
[RelayCommand] async Task ComputeHash()
36+
{
37+
var tl = Avalonia.Controls.TopLevel.GetTopLevel((Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow);
38+
if (tl == null) return;
39+
var files = await tl.StorageProvider.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions { Title = GetOpenFilePickerTitle(), AllowMultiple = false });
40+
if (files.Count > 0) { Current.Hash = await _hash.ComputeHashAsync(files[0].Path.LocalPath); Status = _loc.T("OSS.HashResult", Current.Hash); }
41+
}
42+
[RelayCommand] void Append() { Configs.Add(new() { PacketName = Current.PacketName, Hash = Current.Hash, Version = Current.Version, Url = Current.Url, ReleaseDate = Current.ReleaseDate }); Status = _loc["OSS.Added"]; }
2243
[RelayCommand] void Remove(OSSConfigModel? item) { if (item != null) Configs.Remove(item); }
23-
[RelayCommand] void Clear() { Configs.Clear(); Status = "已清空"; }
24-
[RelayCommand] async Task Export() { var tl = Avalonia.Controls.TopLevel.GetTopLevel((Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow); if (tl == null) return; var file = await tl.StorageProvider.SaveFilePickerAsync(new Avalonia.Platform.Storage.FilePickerSaveOptions { Title = "导出JSON", DefaultExtension = ".json", SuggestedFileName = "oss_config.json" }); if (file != null) { await File.WriteAllTextAsync(file.Path.LocalPath, JsonConvert.SerializeObject(Configs, Formatting.Indented), System.Text.Encoding.UTF8); Status = $"导出: {Configs.Count} 条"; } }
44+
[RelayCommand] void Clear() { Configs.Clear(); Status = _loc["OSS.Cleared"]; }
45+
[RelayCommand] async Task Export()
46+
{
47+
var tl = Avalonia.Controls.TopLevel.GetTopLevel((Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.MainWindow);
48+
if (tl == null) return;
49+
var file = await tl.StorageProvider.SaveFilePickerAsync(new Avalonia.Platform.Storage.FilePickerSaveOptions { Title = _loc["OSS.Export"], DefaultExtension = ".json", SuggestedFileName = "oss_config.json" });
50+
if (file != null) { await File.WriteAllTextAsync(file.Path.LocalPath, JsonConvert.SerializeObject(Configs, Formatting.Indented), System.Text.Encoding.UTF8); Status = _loc.T("OSS.Exported", Configs.Count); }
51+
}
2552
}

0 commit comments

Comments
 (0)