using System.Collections.Concurrent; using System.Globalization; using System.Text.Json; using Microsoft.JSInterop; using Microsoft.Extensions.Localization; namespace Atomx.Admin.Client.Services { /// /// 提供多语言 JSON 文件加载、缓存与运行时切换的实现。 /// - 在 Server 环境:尝试通过反射访问 IWebHostEnvironment 的 webroot,从文件系统读取 {culture}.json 文件。 /// - 在 WASM 环境:使用注入的 HttpClient 从 /localization/{culture}.json 下载并解析。 /// 同时在切换语言时写入 Cookie 并设置页面 HTML lang 属性。 /// public interface ILocalizationProvider { string CurrentCulture { get; } string? GetString(string key); Task SetCultureAsync(string cultureShortOrFull); Task InitializeAsync(); /// /// 保证指定文化的资源已被加载(异步)。供外部在需要时触发加载。 /// Task LoadCultureAsync(string culture); event EventHandler? LanguageChanged; } public class LocalizationProvider : ILocalizationProvider { private readonly IServiceProvider _sp; private readonly IHttpClientFactory? _httpClientFactory; private readonly IJSRuntime? _jsRuntime; private readonly ILogger _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> _cache = new(); // 短码到完整 culture 的映射 private static readonly Dictionary 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 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"); } // 如果运行在 Server(IWebHostEnvironment 可用),且 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>(json) ?? new Dictionary(); _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? 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("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("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("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)); /// /// 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. /// 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>(json) ?? new Dictionary(); _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("SetCultureInternalAsync start: {Culture}, persist={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("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>(txt) ?? new Dictionary(); _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); // 尝试通过反射获取 IWebHostEnvironment(Server 时可用) 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>(json) ?? new Dictionary(); _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>(json2) ?? new Dictionary(); _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(); } 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; } } /// /// 基于 ILocalizationProvider 的 IStringLocalizer 实现: /// 使用 JSON 文件中的键值,未找到返回 key 本身。 /// 名称改为 JsonStringLocalizer 避免与框架的 StringLocalizer 冲突。 /// public class JsonStringLocalizer : IStringLocalizer { 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 GetAllStrings(bool includeParentCultures) { var list = new List(); 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> 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; } } }