From 0741368b440bcd5aa2d61d85dbeafadbff7531d1 Mon Sep 17 00:00:00 2001 From: Seany <17074267@qq.com> Date: Sat, 13 Dec 2025 13:11:03 +0800 Subject: [PATCH] chore --- Atomx.Admin/Atomx.Admin.Client/Program.cs | 2 +- .../Services/LocalizationProvider.cs | 2 +- .../Services/WasmLocalizationProvider.cs | 178 ++++++++++++++++++ Atomx.Admin/Atomx.Admin/Program.cs | 6 +- .../Services/ServerLocalizationProvider.cs | 86 +++++++++ Atomx.Core/Jos/LocalizationJob.cs | 60 +++++- Atomx.Core/Services/BackgroundJobsService.cs | 21 +++ .../CacheServices/LocalizationCacheService.cs | 2 +- 8 files changed, 342 insertions(+), 15 deletions(-) create mode 100644 Atomx.Admin/Atomx.Admin.Client/Services/WasmLocalizationProvider.cs create mode 100644 Atomx.Admin/Atomx.Admin/Services/ServerLocalizationProvider.cs diff --git a/Atomx.Admin/Atomx.Admin.Client/Program.cs b/Atomx.Admin/Atomx.Admin.Client/Program.cs index bd2d48d..a805b28 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Program.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Program.cs @@ -29,7 +29,7 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder. // 注册 LocalizationProvider (用于 WASM) // Use Scoped lifetime because LocalizationProvider depends on IJSRuntime (scoped) and IHttpClient etc. -builder.Services.AddScoped(); +builder.Services.AddScoped(); // 注册 ILocalizationService 用于同步 Culture 在组件间传播 builder.Services.AddScoped(); diff --git a/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs b/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs index 8a8b9f3..060daa4 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs +++ b/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs @@ -49,7 +49,7 @@ namespace Atomx.Admin.Client.Services private const string CookieName = "atomx.culture"; - public LocalizationProvider(IServiceProvider sp, IHttpClientFactory? httpClientFactory, IJSRuntime? jsRuntime, ILogger logger, ILocalizationService localizationService) + public LocalizationProvider(IServiceProvider sp, ILogger logger, IHttpClientFactory? httpClientFactory, IJSRuntime? jsRuntime, ILocalizationService localizationService) { _sp = sp; _httpClientFactory = httpClientFactory; diff --git a/Atomx.Admin/Atomx.Admin.Client/Services/WasmLocalizationProvider.cs b/Atomx.Admin/Atomx.Admin.Client/Services/WasmLocalizationProvider.cs new file mode 100644 index 0000000..ea4e113 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin.Client/Services/WasmLocalizationProvider.cs @@ -0,0 +1,178 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; +using Blazored.LocalStorage; + +namespace Atomx.Admin.Client.Services +{ + public class WasmLocalizationProvider : ILocalizationProvider + { + private readonly IJSRuntime _jsRuntime; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ILocalStorageService _localStorage; + private static readonly ConcurrentDictionary> _cache = new(); + private readonly HashSet _loadingCultures = new(); + private string _currentCulture = "zh-Hans"; + private bool _isInitialized = false; + + private const string LocalizationStorageKey = "Localization_{0}"; + private const string LocalizationVersionKey = "LocalizationVersion_{0}"; + + public WasmLocalizationProvider(IJSRuntime jsRuntime, HttpClient httpClient, ILogger logger, ILocalStorageService localStorage) + { + _jsRuntime = jsRuntime; + _httpClient = httpClient; + _logger = logger; + _localStorage = localStorage; + } + + public string CurrentCulture => _currentCulture; + public bool IsInitialized => _isInitialized; + + public event EventHandler? LanguageChanged; + + public string? GetString(string key) + { + if (string.IsNullOrEmpty(key)) return null; + + if (_cache.TryGetValue(_currentCulture, out var dict) && dict.TryGetValue(key, out var val)) + { + return val; + } + + return null; + } + + public async Task InitializeAsync() + { + if (_isInitialized) return; + + await LoadCultureAsync(_currentCulture); + _isInitialized = true; + LanguageChanged?.Invoke(this, _currentCulture); + } + + public async Task SetCultureAsync(string cultureShortOrFull) + { + _currentCulture = MapToFullCulture(cultureShortOrFull); + await LoadCultureAsync(_currentCulture); + LanguageChanged?.Invoke(this, _currentCulture); + } + + public async Task LoadCultureAsync(string culture) + { + var cultureFull = MapToFullCulture(culture); + + // Step 1: Check in-memory cache + if (_cache.ContainsKey(cultureFull)) return; + + lock (_loadingCultures) + { + if (_loadingCultures.Contains(cultureFull)) + { + _logger.LogDebug("Culture {Culture} is already being loaded.", cultureFull); + return; + } + _loadingCultures.Add(cultureFull); + } + + try + { + // Step 2: Check local storage for cached data + var localDataKey = string.Format(LocalizationStorageKey, cultureFull); + var localVersionKey = string.Format(LocalizationVersionKey, cultureFull); + + var cachedVersion = await _localStorage.GetItemAsync(localVersionKey); + var cachedData = await _localStorage.GetItemAsync>(localDataKey); + + if (cachedData != null) + { + _cache[cultureFull] = cachedData; + _logger.LogInformation("Loaded localization for {Culture} from local storage.", cultureFull); + } + + // Step 3: Validate version with server + var versionUrl = $"api/localeresource/version/{cultureFull}"; + var serverVersion = await _httpClient.GetStringAsync(versionUrl); + + if (cachedVersion == serverVersion && cachedData != null) + { + _logger.LogInformation("Localization data for {Culture} is up-to-date.", cultureFull); + return; + } + + // Step 4: Fetch from server if version mismatch or no cached data + var url = $"/localization/{cultureFull}.json"; + var json = await _httpClient.GetStringAsync(url); + var dict = JsonSerializer.Deserialize>(json) ?? new Dictionary(); + _cache[cultureFull] = dict; + + // Step 5: Update local storage + await _localStorage.SetItemAsync(localVersionKey, serverVersion); + await _localStorage.SetItemAsync(localDataKey, dict); + + _logger.LogInformation("Loaded localization file for {Culture} from server and updated local storage.", cultureFull); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load localization file for {Culture}", cultureFull); + } + finally + { + lock (_loadingCultures) + { + _loadingCultures.Remove(cultureFull); + } + } + } + + private string MapToFullCulture(string culture) + { + return culture switch + { + "zh" => "zh-Hans", + "en" => "en-US", + _ => culture + }; + } + + /// + /// 从服务器获取本地化数据 + /// + /// + /// + private Dictionary FetchFromServer(string culture) + { + var url = $"/localization/{culture}.json"; + var json = _httpClient.GetStringAsync(url).Result; + var dict = JsonSerializer.Deserialize>(json) ?? new Dictionary(); + return dict; + } + + /// + /// 检查本地化版本是否最新 + /// + /// + /// + /// + private async Task CheckVersionAsync(string versionKey, string culture) + { + var cachedVersion = await _localStorage.GetItemAsync(versionKey); + if(string.IsNullOrEmpty(cachedVersion)) + { + return false; + } + var versionUrl = $"api/localeresource/version/{culture}"; + var serverVersion = await _httpClient.GetStringAsync(versionUrl); + + if (cachedVersion != serverVersion) + { + return false; + } + return true; + } + } +} \ No newline at end of file diff --git a/Atomx.Admin/Atomx.Admin/Program.cs b/Atomx.Admin/Atomx.Admin/Program.cs index c3df378..def4087 100644 --- a/Atomx.Admin/Atomx.Admin/Program.cs +++ b/Atomx.Admin/Atomx.Admin/Program.cs @@ -66,8 +66,8 @@ builder.Services.AddHttpContextAccessor(); // HttpClient 与数据服务 builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost"); -// 注入 LocalizationProvider 供 Server 模式下使用 -builder.Services.AddScoped(); +// 注入本地化提供程序与服务 +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(JsonStringLocalizer<>)); @@ -109,7 +109,7 @@ builder.Services.AddStackExchangeRedisCache(options => }); // Hangfire 配置 -var hangfireConnection = builder.Configuration.GetConnectionString("HangfireConnection") +var hangfireConnection = builder.Configuration.GetConnectionString("HangfireConnection") ?? throw new InvalidOperationException("Connection string 'HangfireConnection' not found."); // 添加hangfire服务并配置PostgreSQL存储 diff --git a/Atomx.Admin/Atomx.Admin/Services/ServerLocalizationProvider.cs b/Atomx.Admin/Atomx.Admin/Services/ServerLocalizationProvider.cs new file mode 100644 index 0000000..b35f3e6 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin/Services/ServerLocalizationProvider.cs @@ -0,0 +1,86 @@ +using Atomx.Admin.Client.Services; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace Atomx.Admin.Services +{ + public class ServerLocalizationProvider : ILocalizationProvider + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private static readonly ConcurrentDictionary> _cache = new(); + private string _currentCulture = "zh-Hans"; + + public ServerLocalizationProvider(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public string CurrentCulture => _currentCulture; + + public event EventHandler? LanguageChanged; + + public string? GetString(string key) + { + if (string.IsNullOrEmpty(key)) return null; + + if (_cache.TryGetValue(_currentCulture, out var dict) && dict.TryGetValue(key, out var val)) + { + return val; + } + + return null; + } + + public async Task InitializeAsync() + { + await LoadCultureAsync(_currentCulture); + } + + public async Task SetCultureAsync(string cultureShortOrFull) + { + _currentCulture = MapToFullCulture(cultureShortOrFull); + await LoadCultureAsync(_currentCulture); + LanguageChanged?.Invoke(this, _currentCulture); + } + + public Task LoadCultureAsync(string culture) + { + var cultureFull = MapToFullCulture(culture); + if (_cache.ContainsKey(cultureFull)) return Task.CompletedTask; + + try + { + var env = _serviceProvider.GetService(typeof(Microsoft.AspNetCore.Hosting.IWebHostEnvironment)) as Microsoft.AspNetCore.Hosting.IWebHostEnvironment; + if (env != null) + { + var path = Path.Combine(env.WebRootPath, "localization", cultureFull + ".json"); + if (File.Exists(path)) + { + var json = File.ReadAllText(path); + var dict = JsonSerializer.Deserialize>(json) ?? new Dictionary(); + _cache[cultureFull] = dict; + _logger.LogInformation("Loaded localization file for {Culture} from {Path}", cultureFull, path); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load localization file for {Culture}", cultureFull); + } + + return Task.CompletedTask; + } + + private string MapToFullCulture(string culture) + { + return culture switch + { + "zh" => "zh-Hans", + "en" => "en-US", + _ => culture + }; + } + } +} \ No newline at end of file diff --git a/Atomx.Core/Jos/LocalizationJob.cs b/Atomx.Core/Jos/LocalizationJob.cs index 06dea1a..44faa4e 100644 --- a/Atomx.Core/Jos/LocalizationJob.cs +++ b/Atomx.Core/Jos/LocalizationJob.cs @@ -1,5 +1,7 @@ -锘縰sing Hangfire; +锘縰sing Atomx.Utils.Json; +using Hangfire; using Microsoft.Extensions.Logging; +using System.Text.Json; namespace Atomx.Core.Jos { @@ -20,16 +22,56 @@ namespace Atomx.Core.Jos /// [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] [DisableConcurrentExecution(60)] - public void Execute(string path) + public async Task ExecuteAsync(string path, string culture, string data) { - _logger.LogInformation("LocalizationJob executed at: {time}", DateTimeOffset.Now); - // 鍦ㄨ繖閲屾坊鍔犲璇█鏈湴鍖栫殑鍏蜂綋浠诲姟閫昏緫 - } + try + { + var translations = data.FromJson>(); - public void ResetCache() - { - _logger.LogInformation("LocalizationJob ResetCache executed at: {time}", DateTimeOffset.Now); - // 鍦ㄨ繖閲屾坊鍔犻噸缃紦瀛樼殑鍏蜂綋閫昏緫 + var fileName = $"{culture}.json"; + var filePath = Path.Combine(path, fileName); + if (!Directory.Exists(filePath)) + { + Directory.CreateDirectory(filePath); + _logger.LogInformation("Created Resources directory: {Path}", filePath); + } + + var json = await File.ReadAllTextAsync(filePath); + var fileData = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + if (fileData == null) + { + fileData = new Dictionary(); + } + foreach (var item in translations) + { + if (fileData.ContainsKey(item.Key)) + { + fileData[item.Key] = item.Value; + } + else + { + fileData.Add(item.Key, item.Value); + } + } + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + json = JsonSerializer.Serialize(fileData, options); + await File.WriteAllTextAsync(filePath, json); + + + + _logger.LogInformation("Saved localization file for culture: {Culture} with {Count} translations", + culture, translations.Count); + } + catch(Exception ex) { + _logger.LogError(ex, "Error saving localization file for culture: {Culture}", culture); + } } } } diff --git a/Atomx.Core/Services/BackgroundJobsService.cs b/Atomx.Core/Services/BackgroundJobsService.cs index 8d1d331..c3d317d 100644 --- a/Atomx.Core/Services/BackgroundJobsService.cs +++ b/Atomx.Core/Services/BackgroundJobsService.cs @@ -8,6 +8,14 @@ namespace Atomx.Core.Jos { public partial interface IBackgroundJobsService { + /// + /// 鏇存柊鏈湴鍖栨枃浠 + /// + /// + /// + /// + /// + string UpdateLocalizationFile(string path, string culture, string data); string SendSMSVerificationCode(string phoneNumber, string code, TimeSpan validDuration); } @@ -23,6 +31,19 @@ namespace Atomx.Core.Jos _logger = logger; } + /// + /// 鏇存柊鏈湴鍖栨枃浠 + /// + /// + /// + /// + /// + public string UpdateLocalizationFile(string path, string culture, string data) + { + var jobId = _backgroundJobClient.Enqueue(job => job.ExecuteAsync(path, culture, data)); + return jobId; + } + public string SendSMSVerificationCode(string phoneNumber, string code, TimeSpan validDuration) { return string.Empty; diff --git a/Atomx.Data/CacheServices/LocalizationCacheService.cs b/Atomx.Data/CacheServices/LocalizationCacheService.cs index cb0daa0..3c6b114 100644 --- a/Atomx.Data/CacheServices/LocalizationCacheService.cs +++ b/Atomx.Data/CacheServices/LocalizationCacheService.cs @@ -2,7 +2,7 @@ { public partial interface ICacheService { - Task LoadLocale(string Culture); + //Task LoadLocale(string Culture); }