This commit is contained in:
2025-12-14 02:43:40 +08:00
parent 0741368b44
commit 54e9c7962d
10 changed files with 220 additions and 278 deletions

View File

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Localization;
using System.Globalization;
namespace Atomx.Admin.Client.Services
{
/// <summary>
/// 基于 ILocalizationProvider 的 IStringLocalizer 实现:
/// 使用 JSON 文件中的键值,未找到返回 key 本身。
/// 名称改为 JsonStringLocalizer 避免与框架的 StringLocalizer 冲突。
/// </summary>
public class JsonStringLocalizer<T> : IStringLocalizer<T>
{
private readonly ILocalizationProvider _provider;
public JsonStringLocalizer(ILocalizationProvider provider)
{
_provider = provider;
}
public LocalizedString this[string name]
{
get
{
var value = _provider.GetString(name);
if (value == null)
{
// Avoid synchronous blocking during server prerender. Start background load and return key.
try
{
_ = _provider.LoadCultureAsync(_provider.CurrentCulture);
}
catch { }
}
var result = value ?? name;
return new LocalizedString(name, result, resourceNotFound: result == name);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
var fmt = _provider.GetString(name);
if (fmt == null)
{
try
{
_ = _provider.LoadCultureAsync(_provider.CurrentCulture);
}
catch { }
}
var format = fmt ?? name;
var value = string.Format(format, arguments);
return new LocalizedString(name, value, resourceNotFound: format == name);
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
var list = new List<LocalizedString>();
var providerType = _provider.GetType();
var currentProp = providerType.GetProperty("CurrentCulture");
var culture = currentProp?.GetValue(_provider) as string ?? string.Empty;
var cacheField = providerType.GetField("_cache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (!string.IsNullOrEmpty(culture) && cacheField?.GetValue(_provider) is Dictionary<string, Dictionary<string, string>> cache && cache.TryGetValue(culture, out var dict))
{
foreach (var kv in dict)
{
list.Add(new LocalizedString(kv.Key, kv.Value, resourceNotFound: false));
}
}
return list;
}
public IStringLocalizer WithCulture(CultureInfo culture)
{
return this;
}
}
}

View File

@@ -505,80 +505,5 @@ namespace Atomx.Admin.Client.Services
}
}
/// <summary>
/// <20><><EFBFBD><EFBFBD> ILocalizationProvider <20><> IStringLocalizer ʵ<>֣<EFBFBD>
/// ʹ<><CAB9> JSON <20>ļ<EFBFBD><C4BC>еļ<D0B5>ֵ<EFBFBD><D6B5>δ<EFBFBD>ҵ<EFBFBD><D2B5><EFBFBD><EFBFBD><EFBFBD> key <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
/// <20><><EFBFBD>Ƹ<EFBFBD>Ϊ JsonStringLocalizer <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD> StringLocalizer <20><>ͻ<EFBFBD><CDBB>
/// </summary>
public class JsonStringLocalizer<T> : IStringLocalizer<T>
{
private readonly ILocalizationProvider _provider;
public JsonStringLocalizer(ILocalizationProvider provider)
{
_provider = provider;
}
public LocalizedString this[string name]
{
get
{
var value = _provider.GetString(name);
if (value == null)
{
// Avoid synchronous blocking during server prerender. Start background load and return key.
try
{
_ = _provider.LoadCultureAsync(_provider.CurrentCulture);
}
catch { }
}
var result = value ?? name;
return new LocalizedString(name, result, resourceNotFound: result == name);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
var fmt = _provider.GetString(name);
if (fmt == null)
{
try
{
_ = _provider.LoadCultureAsync(_provider.CurrentCulture);
}
catch { }
}
var format = fmt ?? name;
var value = string.Format(format, arguments);
return new LocalizedString(name, value, resourceNotFound: format == name);
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
var list = new List<LocalizedString>();
var providerType = _provider.GetType();
var currentProp = providerType.GetProperty("CurrentCulture");
var culture = currentProp?.GetValue(_provider) as string ?? string.Empty;
var cacheField = providerType.GetField("_cache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (!string.IsNullOrEmpty(culture) && cacheField?.GetValue(_provider) is Dictionary<string, Dictionary<string, string>> cache && cache.TryGetValue(culture, out var dict))
{
foreach (var kv in dict)
{
list.Add(new LocalizedString(kv.Key, kv.Value, resourceNotFound: false));
}
}
return list;
}
public IStringLocalizer WithCulture(CultureInfo culture)
{
return this;
}
}
}

View File

@@ -13,6 +13,7 @@ namespace Atomx.Admin.Client.Services
private readonly HttpClient _httpClient;
private readonly ILogger<WasmLocalizationProvider> _logger;
private readonly ILocalStorageService _localStorage;
private readonly ILocalizationService _localizationService;
private static readonly ConcurrentDictionary<string, Dictionary<string, string>> _cache = new();
private readonly HashSet<string> _loadingCultures = new();
private string _currentCulture = "zh-Hans";
@@ -21,12 +22,13 @@ namespace Atomx.Admin.Client.Services
private const string LocalizationStorageKey = "Localization_{0}";
private const string LocalizationVersionKey = "LocalizationVersion_{0}";
public WasmLocalizationProvider(IJSRuntime jsRuntime, HttpClient httpClient, ILogger<WasmLocalizationProvider> logger, ILocalStorageService localStorage)
public WasmLocalizationProvider(IJSRuntime jsRuntime, HttpClient httpClient, ILogger<WasmLocalizationProvider> logger, ILocalStorageService localStorage, ILocalizationService localizationService)
{
_jsRuntime = jsRuntime;
_httpClient = httpClient;
_logger = logger;
_localStorage = localStorage;
_localizationService = localizationService;
}
public string CurrentCulture => _currentCulture;
@@ -51,14 +53,43 @@ namespace Atomx.Admin.Client.Services
if (_isInitialized) return;
await LoadCultureAsync(_currentCulture);
// ensure thread cultures and notify localization service
try
{
var ci = new CultureInfo(_currentCulture);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
_localizationService.SetLanguage(ci);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to set culture after initialize: {Culture}", _currentCulture);
}
_isInitialized = true;
LanguageChanged?.Invoke(this, _currentCulture);
}
public async Task SetCultureAsync(string cultureShortOrFull)
{
_currentCulture = MapToFullCulture(cultureShortOrFull);
var full = MapToFullCulture(cultureShortOrFull);
_currentCulture = full;
await LoadCultureAsync(_currentCulture);
try
{
var ci = new CultureInfo(_currentCulture);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
_localizationService.SetLanguage(ci);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to set culture in SetCultureAsync: {Culture}", _currentCulture);
}
LanguageChanged?.Invoke(this, _currentCulture);
}
@@ -101,6 +132,20 @@ namespace Atomx.Admin.Client.Services
if (cachedVersion == serverVersion && cachedData != null)
{
_logger.LogInformation("Localization data for {Culture} is up-to-date.", cultureFull);
// ensure thread cultures and notify localization service when using cached data
try
{
var ci = new CultureInfo(cultureFull);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
_localizationService.SetLanguage(ci);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to set culture after loading from cache: {Culture}", cultureFull);
}
return;
}
@@ -115,6 +160,19 @@ namespace Atomx.Admin.Client.Services
await _localStorage.SetItemAsync(localDataKey, dict);
_logger.LogInformation("Loaded localization file for {Culture} from server and updated local storage.", cultureFull);
// ensure thread cultures and notify localization service after fetching
try
{
var ci = new CultureInfo(cultureFull);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
_localizationService.SetLanguage(ci);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to set culture after fetching: {Culture}", cultureFull);
}
}
catch (Exception ex)
{
@@ -127,6 +185,9 @@ namespace Atomx.Admin.Client.Services
_loadingCultures.Remove(cultureFull);
}
}
// Notify listeners that the culture has been loaded
LanguageChanged?.Invoke(this, cultureFull);
}
private string MapToFullCulture(string culture)

View File

@@ -18,6 +18,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Atomx.Core\Atomx.Core.csproj" />
<ProjectReference Include="..\..\Atomx.Data\Atomx.Data.csproj" />
<ProjectReference Include="..\..\Atomx.Utils\Atomx.Utils.csproj" />
<ProjectReference Include="..\Atomx.Admin.Client\Atomx.Admin.Client.csproj" />

View File

@@ -3,12 +3,16 @@ using Atomx.Admin.Client.Validators;
using Atomx.Admin.Services;
using Atomx.Common.Entities;
using Atomx.Common.Models;
using Atomx.Core.Jos;
using Atomx.Data;
using Atomx.Data.CacheServices;
using Atomx.Data.Services;
using Atomx.Utils.Json;
using Atomx.Utils.Models;
using MapsterMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
namespace Atomx.Admin.Controllers
{
@@ -23,6 +27,8 @@ namespace Atomx.Admin.Controllers
private readonly IMapper _mapper;
private readonly JwtSetting _jwtSettings;
private readonly ICacheService _cacheService;
private readonly IBackgroundJobService _backgroundService;
private readonly IWebHostEnvironment _environment;
/// <summary>
@@ -35,7 +41,7 @@ namespace Atomx.Admin.Controllers
/// <param name="mapper"></param>
/// <param name="jwtSettings"></param>
/// <param name="cacheService"></param>
public LocaleResourceController(ILogger<LocaleResourceController> logger, IIdCreatorService idCreator, IIdentityService identityService, DataContext dbContext, IMapper mapper, JwtSetting jwtSettings, ICacheService cacheService)
public LocaleResourceController(ILogger<LocaleResourceController> logger, IIdCreatorService idCreator, IIdentityService identityService, DataContext dbContext, IMapper mapper, JwtSetting jwtSettings, ICacheService cacheService, IBackgroundJobService backgroundJobService, IWebHostEnvironment environment)
{
_logger = logger;
_idCreator = idCreator;
@@ -44,6 +50,8 @@ namespace Atomx.Admin.Controllers
_mapper = mapper;
_jwtSettings = jwtSettings;
_cacheService = cacheService;
_backgroundService = backgroundJobService;
_environment = environment;
}
/// <summary>
@@ -198,6 +206,11 @@ namespace Atomx.Admin.Controllers
}
//异步更新对应的json文件
var wwwroot = _environment.WebRootPath;
var dic = new Dictionary<string, string>();
dic.Add(data.Name,data.Value);
_backgroundService.UpdateLocalizationFile(wwwroot, model.Culture, dic.ToJson());
result = result.IsSuccess(true);
@@ -210,29 +223,16 @@ namespace Atomx.Admin.Controllers
/// <param name="culture"></param>
/// <returns></returns>
[HttpGet("version/{culture}")]
[AllowAnonymous]
public async Task<IActionResult> GetVersion(string culture)
{
var result = new ApiResult<string>();
var result = string.Empty;
var data = await _cacheService.GetLanguageByCulture(culture);
if (data != null)
{
result = result.IsSuccess(data.ResourceVersion);
result = data.ResourceVersion;
}
return new JsonResult(result);
}
/// <summary>
/// 获取文化语言数据
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
[HttpGet("resources/{culture}")]
public IActionResult GetLocaleResources(string culture)
{
var result = new ApiResult<LocalizationFile>();
return new JsonResult(result);
}
}
}

View File

@@ -53,7 +53,7 @@ namespace Atomx.Admin.Middlewares
logger?.LogWarning(ex, "Failed to set thread culture to {Culture}", cultureName);
}
// Attempt to synchronously load localization for server-side rendering
// 尝试同步加载服务器端渲染的本地化内容
try
{
var providerObj = context.RequestServices.GetService(typeof(Atomx.Admin.Client.Services.ILocalizationProvider));
@@ -61,10 +61,10 @@ namespace Atomx.Admin.Middlewares
{
logger?.LogDebug("ILocalizationProvider not registered in RequestServices");
}
else if (providerObj is Atomx.Admin.Client.Services.LocalizationProvider provider)
else if (providerObj is Atomx.Admin.Services.ServerLocalizationProvider provider)
{
logger?.LogDebug("Calling SetCultureForServer on LocalizationProvider with {Culture}", cultureName);
provider.SetCultureForServer(cultureName);
_= provider.SetCultureAsync(cultureName);
logger?.LogInformation("LocalizationProvider.CurrentCulture after SetCultureForServer: {Culture}", provider.CurrentCulture);
}
else

View File

@@ -7,6 +7,7 @@ using Atomx.Admin.Models;
using Atomx.Admin.Services;
using Atomx.Admin.Utils;
using Atomx.Common.Models;
using Atomx.Core.Jos;
using Atomx.Data;
using Atomx.Data.Services;
using Atomx.Utils.Json.Converts;
@@ -180,6 +181,9 @@ builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("
// ע<><D7A2> FluentValidation <20><>֤<EFBFBD><D6A4>
builder.Services.AddValidatorsFromAssembly(typeof(Atomx.Admin.Client.Validators.LoginModelValidator).Assembly);
// Register IBackgroundJobService and its implementation
builder.Services.AddScoped<IBackgroundJobService, BackgroundJobService>();
var app = builder.Build();
app.AddDataMigrate();

View File

@@ -1,147 +0,0 @@
using Atomx.Common.Models;
using System.Text.Json;
namespace Atomx.Admin.Services
{
public interface ILocalizationFileService
{
Task<Dictionary<string, string>> GetTranslationsAsync(string culture);
Task<bool> SaveTranslationsAsync(string version, string culture, Dictionary<string, string> translations);
Task<bool> FileExistsAsync(string culture);
}
public class LocalizationFileService : ILocalizationFileService
{
private readonly IWebHostEnvironment _environment;
private readonly ILogger<LocalizationFileService> _logger;
private readonly string _resourcesPath;
public LocalizationFileService(
IWebHostEnvironment environment,
ILogger<LocalizationFileService> logger)
{
_environment = environment;
_logger = logger;
_resourcesPath = Path.Combine(_environment.ContentRootPath, "Resources");
EnsureResourcesDirectoryExists();
}
/// <summary>
/// 读取指定文化语言的译文
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
public async Task<Dictionary<string, string>> GetTranslationsAsync(string culture)
{
var filePath = GetFilePath(culture);
if (!File.Exists(filePath))
{
_logger.LogWarning("Localization file not found for culture: {Culture}", culture);
return new Dictionary<string, string>();
}
try
{
var json = await File.ReadAllTextAsync(filePath);
var fileData = JsonSerializer.Deserialize<LocalizationFile>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
return fileData?.Translations ?? new Dictionary<string, string>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading localization file for culture: {Culture}", culture);
return new Dictionary<string, string>();
}
}
/// <summary>
/// 保存本地化译文
/// </summary>
/// <param name="version"></param>
/// <param name="culture"></param>
/// <param name="translations"></param>
/// <returns></returns>
public async Task<bool> SaveTranslationsAsync(string version, string culture, Dictionary<string, string> translations)
{
try
{
var fileData = new LocalizationFile
{
ResourceVersion = culture,
Translations = translations
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var json = JsonSerializer.Serialize(fileData, options);
var filePath = GetFilePath(culture);
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Saved localization file for culture: {Culture} with {Count} translations",
culture, translations.Count);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving localization file for culture: {Culture}", culture);
return false;
}
}
/// <summary>
/// 判断文化语言文件是否存在
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
public Task<bool> FileExistsAsync(string culture)
{
var filePath = GetFilePath(culture);
return Task.FromResult(File.Exists(filePath));
}
/// <summary>
/// 获取文件最后修改时间
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
public DateTime GetFileLastModified(string culture)
{
var filePath = GetFilePath(culture);
return File.Exists(filePath) ? File.GetLastWriteTimeUtc(filePath) : DateTime.MinValue;
}
/// <summary>
/// 获取文件路径
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private string GetFilePath(string culture)
{
var fileName = $"{culture}.json";
return Path.Combine(_resourcesPath, fileName);
}
/// <summary>
/// 判断存放资源文件的文件夹是否存在,不存在则创建
/// </summary>
private void EnsureResourcesDirectoryExists()
{
if (!Directory.Exists(_resourcesPath))
{
Directory.CreateDirectory(_resourcesPath);
_logger.LogInformation("Created Resources directory: {Path}", _resourcesPath);
}
}
}
}

View File

@@ -1,6 +1,9 @@
using Atomx.Utils.Json;
using Atomx.Data;
using Atomx.Data.CacheServices;
using Atomx.Utils.Json;
using Hangfire;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using System.Text.Json;
namespace Atomx.Core.Jos
@@ -12,9 +15,13 @@ namespace Atomx.Core.Jos
public class LocalizationJob
{
readonly ILogger<LocalizationJob> _logger;
public LocalizationJob(ILogger<LocalizationJob> logger)
readonly DataContext _dbContext;
readonly ICacheService _cacheService;
public LocalizationJob(ILogger<LocalizationJob> logger, DataContext dataContext, ICacheService cacheService)
{
_logger = logger;
_dbContext = dataContext;
_cacheService = cacheService;
}
/// <summary>
@@ -30,46 +37,58 @@ namespace Atomx.Core.Jos
var fileName = $"{culture}.json";
var filePath = Path.Combine(path, fileName);
if (!Directory.Exists(filePath))
if (!Directory.Exists(path))
{
Directory.CreateDirectory(filePath);
_logger.LogInformation("Created Resources directory: {Path}", filePath);
Directory.CreateDirectory(path);
_logger.LogInformation("Created Resources directory: {Path}", path);
}
var fileData = new Dictionary<string, string>();
if (File.Exists(filePath))
{
var json = await File.ReadAllTextAsync(filePath);
var fileData = JsonSerializer.Deserialize<Dictionary<string, string>>(json, new JsonSerializerOptions
fileData = JsonSerializer.Deserialize<Dictionary<string, string>>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
if (fileData == null)
{
fileData = new Dictionary<string, string>();
}) ?? new Dictionary<string, string>();
}
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);
var updatedJson = JsonSerializer.Serialize(fileData, options);
await File.WriteAllTextAsync(filePath, updatedJson);
_logger.LogInformation("Saved localization file for culture: {Culture} with {Count} translations",
culture, translations.Count);
// 更新文件后,更新数据库中的资源版本
string fileHash;
using (var sha256 = SHA256.Create())
using (var stream = File.OpenRead(filePath))
{
var hashBytes = sha256.ComputeHash(stream);
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
catch(Exception ex) {
var language = _dbContext.Languages.FirstOrDefault(l => l.Culture == culture);
if (language != null)
{
language.UpdateTime = DateTime.UtcNow;
language.ResourceVersion = fileHash;
await _dbContext.SaveChangesAsync();
await _cacheService.GetLanguageById(language.Id, language);
}
_logger.LogInformation("Saved localization file for culture: {Culture} with {Count} translations. File hash: {Hash}",
culture, translations.Count, fileHash);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving localization file for culture: {Culture}", culture);
}
}

View File

@@ -1,12 +1,9 @@
using Hangfire;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Text;
namespace Atomx.Core.Jos
{
public partial interface IBackgroundJobsService
public partial interface IBackgroundJobService
{
/// <summary>
/// 更新本地化文件
@@ -19,12 +16,12 @@ namespace Atomx.Core.Jos
string SendSMSVerificationCode(string phoneNumber, string code, TimeSpan validDuration);
}
public partial class BackgroundJobsService : IBackgroundJobsService
public partial class BackgroundJobService : IBackgroundJobService
{
readonly IBackgroundJobClient _backgroundJobClient;
readonly IRecurringJobManager _recurringJobManager;
readonly ILogger<BackgroundJobsService> _logger;
public BackgroundJobsService(IBackgroundJobClient backgroundJobClient, IRecurringJobManager recurringJobManager, ILogger<BackgroundJobsService> logger)
readonly ILogger<BackgroundJobService> _logger;
public BackgroundJobService(IBackgroundJobClient backgroundJobClient, IRecurringJobManager recurringJobManager, ILogger<BackgroundJobService> logger)
{
_backgroundJobClient = backgroundJobClient;
_recurringJobManager = recurringJobManager;