Files
Atomx/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs
2025-12-09 19:10:10 +08:00

585 lines
26 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.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using Microsoft.JSInterop;
using Microsoft.Extensions.Localization;
namespace Atomx.Admin.Client.Services
{
/// <summary>
/// 提供多语言 JSON 文件加载、缓存与运行时切换的实现。
/// - 在 Server 环境:尝试通过反射访问 IWebHostEnvironment 的 webroot从文件系统读取 {culture}.json 文件。
/// - 在 WASM 环境:使用注入的 HttpClient 从 /localization/{culture}.json 下载并解析。
/// 同时在切换语言时写入 Cookie 并设置页面 HTML lang 属性。
/// </summary>
public interface ILocalizationProvider
{
string CurrentCulture { get; }
string? GetString(string key);
Task SetCultureAsync(string cultureShortOrFull);
Task InitializeAsync();
/// <summary>
/// 保证指定文化的资源已被加载(异步)。供外部在需要时触发加载。
/// </summary>
Task LoadCultureAsync(string culture);
event EventHandler<string>? LanguageChanged;
}
public class LocalizationProvider : ILocalizationProvider
{
private readonly IServiceProvider _sp;
private readonly IHttpClientFactory? _httpClientFactory;
private readonly IJSRuntime? _jsRuntime;
private readonly ILogger<LocalizationProvider> _logger;
private readonly ILocalizationService _localizationService;
// 缓存culture -> translations
// Use a static concurrent dictionary so files loaded during middleware/server prerender
// are visible to provider instances created later in the same request pipeline.
private static readonly ConcurrentDictionary<string, Dictionary<string, string>> _cache = new();
// 短码到完整 culture 的映射
private static readonly Dictionary<string, string> ShortToCulture = new(StringComparer.OrdinalIgnoreCase)
{
{ "zh", "zh-Hans" },
{ "en", "en-US" }
};
private string _currentCulture = "zh-Hans";
private const string CookieName = "atomx.culture";
public LocalizationProvider(IServiceProvider sp, IHttpClientFactory? httpClientFactory, IJSRuntime? jsRuntime, ILogger<LocalizationProvider> logger, ILocalizationService localizationService)
{
_sp = sp;
_httpClientFactory = httpClientFactory;
_jsRuntime = jsRuntime;
_logger = logger;
_localizationService = localizationService;
// 不在构造函数中进行 JS 相关同步 IO但尝试根据线程 culture 设置初始值并归一化为完整 culture
try
{
var threadUi = CultureInfo.DefaultThreadCurrentUICulture ?? CultureInfo.CurrentUICulture;
if (!string.IsNullOrEmpty(threadUi?.Name))
{
_currentCulture = MapToFullCulture(threadUi!.Name);
_logger?.LogDebug("LocalizationProvider ctor detected thread UI culture: {Culture}", _currentCulture);
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "LocalizationProvider ctor failed to read thread culture");
}
// 如果运行在 ServerIWebHostEnvironment 可用),且 JSRuntime 不可用(说明非浏览器),则同步从文件加载本地化文件
try
{
var envType = Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment, Microsoft.AspNetCore.Hosting.Abstractions")
?? Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment");
if (envType != null)
{
var env = _sp.GetService(envType);
if (env != null && _jsRuntime == null)
{
var webRootProp = envType.GetProperty("WebRootPath");
var contentRootProp = envType.GetProperty("ContentRootPath");
var webRoot = webRootProp?.GetValue(env) as string ?? Path.Combine(contentRootProp?.GetValue(env) as string ?? ".", "wwwroot");
var path = Path.Combine(webRoot ?? ".", "localization", _currentCulture + ".json");
if (File.Exists(path))
{
try
{
var json = File.ReadAllText(path);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
_cache[_currentCulture] = dict;
_logger?.LogInformation("Loaded localization file for {Culture} from path {Path}, entries: {Count}", _currentCulture, path, dict.Count);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to read localization file synchronously: {Path}", path);
}
}
else
{
_logger?.LogDebug("Localization file not found at {Path}", path);
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "Synchronous file load attempt failed in ctor");
}
}
public string CurrentCulture => _currentCulture;
public event EventHandler<string>? 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()
{
_logger?.LogDebug("LocalizationProvider.InitializeAsync start. CurrentCulture={Culture}", _currentCulture);
string? urlFirstSegment = null;
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var path = await _jsRuntime.InvokeAsync<string>("eval", "location.pathname");
_logger?.LogDebug("JS location.pathname='{Path}'", path);
if (!string.IsNullOrEmpty(path))
{
var trimmed = path.Trim('/');
if (!string.IsNullOrEmpty(trimmed))
{
var seg = trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
urlFirstSegment = seg;
_logger?.LogDebug("Detected url first segment: {Segment}", urlFirstSegment);
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "读取 location.pathname 失败");
}
if (!string.IsNullOrEmpty(urlFirstSegment) && ShortToCulture.TryGetValue(urlFirstSegment, out var mapped))
{
_logger?.LogDebug("URL short segment '{Seg}' mapped to culture '{Culture}'", urlFirstSegment, mapped);
await SetCultureInternalAsync(mapped, persistCookie: false);
return;
}
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var cookieVal = await _jsRuntime.InvokeAsync<string>("cookies.Read", CookieName);
_logger?.LogDebug("Cookie '{CookieName}'='{CookieVal}'", CookieName, cookieVal);
if (!string.IsNullOrEmpty(cookieVal))
{
if (ShortToCulture.TryGetValue(cookieVal, out var mappedFromCookie))
{
_logger?.LogDebug("Cookie short '{Cookie}' mapped to {Culture}", cookieVal, mappedFromCookie);
await SetCultureInternalAsync(mappedFromCookie, persistCookie: false);
return;
}
else
{
// 如果 cookie 中是完整 culture或其他形式归一化后使用
await SetCultureInternalAsync(MapToFullCulture(cookieVal), persistCookie: false);
return;
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "读取 Cookie 失败");
}
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var browserLang = await _jsRuntime.InvokeAsync<string>("getBrowserLanguage");
_logger?.LogDebug("Browser language: {BrowserLang}", browserLang);
if (!string.IsNullOrEmpty(browserLang))
{
var mappedFromBrowser = MapToFullCulture(browserLang);
_logger?.LogDebug("Browser mapped to {Culture}", mappedFromBrowser);
await SetCultureInternalAsync(mappedFromBrowser, persistCookie: false);
return;
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "读取浏览器语言失败");
}
// 最后确保加载当前 culture
_logger?.LogDebug("InitializeAsync falling back to current culture {Culture}", _currentCulture);
await EnsureCultureLoadedAsync(_currentCulture);
await SetCultureInternalAsync(_currentCulture, persistCookie: false);
}
public async Task SetCultureAsync(string cultureShortOrFull)
{
if (string.IsNullOrEmpty(cultureShortOrFull)) return;
if (ShortToCulture.TryGetValue(cultureShortOrFull, out var mapped))
{
await SetCultureInternalAsync(mapped, persistCookie: true);
}
else
{
await SetCultureInternalAsync(MapToFullCulture(cultureShortOrFull), persistCookie: true);
}
}
public Task LoadCultureAsync(string culture) => EnsureCultureLoadedAsync(MapToFullCulture(culture));
/// <summary>
/// Server-side synchronous culture set used during prerender to ensure translations
/// are available immediately. This method will attempt to load localization
/// JSON from the server's webroot synchronously and set thread cultures.
/// </summary>
public void SetCultureForServer(string cultureShortOrFull)
{
try
{
var cultureFull = MapToFullCulture(cultureShortOrFull);
if (string.IsNullOrEmpty(cultureFull)) return;
// set thread culture
try
{
var ci = new CultureInfo(cultureFull);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
_currentCulture = cultureFull;
}
catch { }
// try load from webroot synchronously via IWebHostEnvironment if available
try
{
var envType = Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment, Microsoft.AspNetCore.Hosting.Abstractions")
?? Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment");
if (envType != null)
{
var env = _sp.GetService(envType);
if (env != null)
{
var webRootProp = envType.GetProperty("WebRootPath");
var contentRootProp = envType.GetProperty("ContentRootPath");
var webRoot = webRootProp?.GetValue(env) as string ?? Path.Combine(contentRootProp?.GetValue(env) as string ?? ".", "wwwroot");
var path = Path.Combine(webRoot ?? ".", "localization", cultureFull + ".json");
if (File.Exists(path))
{
try
{
var json = File.ReadAllText(path);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
_cache[cultureFull] = dict;
_logger?.LogInformation("(Server sync) Loaded localization file for {Culture} from path {Path}, entries: {Count}", cultureFull, path, dict.Count);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "(Server sync) Failed to read localization file synchronously: {Path}", path);
}
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "SetCultureForServer failed to load file for {Culture}", cultureFull);
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "SetCultureForServer encountered error");
}
}
private async Task SetCultureInternalAsync(string cultureFull, bool persistCookie)
{
//_logger?.LogDebug("设置内部文化异步开始: {Culture}, 持久化={Persist}", cultureFull, persistCookie);
await EnsureCultureLoadedAsync(cultureFull);
try
{
var ci = new CultureInfo(cultureFull);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
_currentCulture = cultureFull;
_localizationService.SetLanguage(ci);
_logger?.LogDebug("Culture set to {Culture}", cultureFull);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "设置 Culture 失败: {Culture}", cultureFull);
}
if (persistCookie && _jsRuntime != null)
{
try
{
var shortKey = ShortToCulture.FirstOrDefault(kv => string.Equals(kv.Value, cultureFull, StringComparison.OrdinalIgnoreCase)).Key ?? cultureFull;
await _jsRuntime.InvokeVoidAsync("cookies.Write", CookieName, shortKey, DateTime.UtcNow.AddYears(1).ToString("o"));
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "写 Cookie 失败");
}
}
try
{
if (_jsRuntime != null)
{
await _jsRuntime.InvokeVoidAsync("setHtmlLang", cultureFull);
}
}
catch { }
LanguageChanged?.Invoke(this, cultureFull);
}
private async Task EnsureCultureLoadedAsync(string cultureFull)
{
// Normalize possible short codes (e.g. zh -> zh-Hans, en -> en-US) and variants (zh-CN -> zh-Hans)
cultureFull = MapToFullCulture(cultureFull);
if (string.IsNullOrEmpty(cultureFull)) return;
if (_cache.ContainsKey(cultureFull))
{
_logger?.LogDebug("EnsureCultureLoadedAsync: culture {Culture} already cached", cultureFull);
return;
}
// Prefer HttpClient when running in browser (WASM)
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
_logger?.LogInformation("EnsureCultureLoadedAsync: running in browser, will attempt HttpClient for {Culture}", cultureFull);
try
{
var http = _sp.GetService(typeof(HttpClient)) as HttpClient;
if (http == null && _httpClientFactory != null)
{
_logger?.LogDebug("HttpClient not found from service provider, using factory");
http = _httpClientFactory.CreateClient();
}
else
{
_logger?.LogDebug("HttpClient resolved from service provider: {HasClient}", http != null);
}
if (http != null)
{
var url = $"/localization/{cultureFull}.json";
Uri? requestUri = null;
// If HttpClient has a BaseAddress, use it. Otherwise, if running in browser, build absolute URI from location.origin
if (http.BaseAddress != null)
{
requestUri = new Uri(http.BaseAddress, url);
}
else if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
try
{
var origin = await _jsRuntime.InvokeAsync<string>("eval", "location.origin");
if (!string.IsNullOrEmpty(origin))
{
// ensure no double slashes
requestUri = new Uri(new Uri(origin), url);
}
}
catch (Exception jsEx)
{
_logger?.LogDebug(jsEx, "Failed to get location.origin from JS");
}
}
if (requestUri != null)
{
_logger?.LogInformation("Downloading localization from {Url}", requestUri);
var txt = await http.GetStringAsync(requestUri);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(txt) ?? new Dictionary<string, string>();
_cache[cultureFull] = dict;
_logger?.LogInformation("Loaded localization via HttpClient for {Culture}, entries: {Count}", cultureFull, dict.Count);
return;
}
else
{
_logger?.LogWarning("HttpClient has no BaseAddress and JSRuntime unavailable to construct absolute URL; skipping HttpClient load for {Culture}", cultureFull);
}
}
else
{
_logger?.LogWarning("No HttpClient available to load localization for {Culture}", cultureFull);
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "通过 HttpClient 加载本地化文件失败: {Culture}", cultureFull);
}
}
_logger?.LogDebug("EnsureCultureLoadedAsync trying filesystem for {Culture}", cultureFull);
// 尝试通过反射获取 IWebHostEnvironmentServer 时可用)
try
{
var envType = Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment, Microsoft.AspNetCore.Hosting.Abstractions")
?? Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment");
if (envType != null)
{
var env = _sp.GetService(envType);
if (env != null)
{
var webRootProp = envType.GetProperty("WebRootPath");
var contentRootProp = envType.GetProperty("ContentRootPath");
var webRoot = webRootProp?.GetValue(env) as string ?? Path.Combine(contentRootProp?.GetValue(env) as string ?? ".", "wwwroot");
var path = Path.Combine(webRoot ?? ".", "localization", cultureFull + ".json");
_logger?.LogDebug("Looking for localization file at {Path}", path);
if (File.Exists(path))
{
var json = await File.ReadAllTextAsync(path);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
_cache[cultureFull] = dict;
_logger?.LogInformation("Loaded localization from file for {Culture}, entries: {Count}", cultureFull, dict.Count);
return;
}
else
{
_logger?.LogDebug("Localization file not found at {Path}", path);
// Fallback: check build output wwwroot under AppContext.BaseDirectory
try
{
var alt = Path.Combine(AppContext.BaseDirectory ?? ".", "wwwroot", "localization", cultureFull + ".json");
_logger?.LogDebug("Looking for localization file at alternative path {AltPath}", alt);
if (File.Exists(alt))
{
var json2 = await File.ReadAllTextAsync(alt);
var dict2 = JsonSerializer.Deserialize<Dictionary<string, string>>(json2) ?? new Dictionary<string, string>();
_cache[cultureFull] = dict2;
_logger?.LogInformation("Loaded localization from alternative file path for {Culture}, entries: {Count}", cultureFull, dict2.Count);
return;
}
else
{
_logger?.LogDebug("Localization file not found at alternative path {AltPath}", alt);
}
}
catch (Exception exAlt)
{
_logger?.LogDebug(exAlt, "Error while checking alternative localization path");
}
}
}
else
{
_logger?.LogDebug("IWebHostEnvironment not resolved from service provider");
}
}
else
{
_logger?.LogDebug("IWebHostEnvironment type not found via reflection");
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "从文件系统加载本地化文件失败: {Culture}", cultureFull);
}
_logger?.LogDebug("EnsureCultureLoadedAsync fallback to empty dict for {Culture}", cultureFull);
_cache[cultureFull] = new Dictionary<string, string>();
}
private string MapToFullCulture(string culture)
{
if (string.IsNullOrEmpty(culture)) return culture;
// direct mapping
if (ShortToCulture.TryGetValue(culture, out var mapped)) return mapped;
// consider prefix, e.g. zh-CN -> zh
var prefix = culture.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrEmpty(prefix) && ShortToCulture.TryGetValue(prefix, out var mapped2)) return mapped2;
return culture;
}
}
/// <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;
}
}
}