using Microsoft.JSInterop; using System.Collections.Concurrent; using System.Globalization; using System.Text.Json; namespace Atomx.Admin.Client.Services { /// /// 本类提供多语言 JSON 文件的加载、缓存与运行时切换功能。 /// 主要职责: /// - 在 Server 环境(例如 prerender 或 Blazor Server)时尝试从 webroot 同步读取本地化 JSON 文件并缓存, /// 以便在服务端渲染阶段立即可用。 /// - 在 WASM 环境时使用注入的 HttpClient 从 /localization/{culture}.json 下载并解析。 /// - 在切换语言时写入名为 `atomx.culture` 的 cookie(供后端读取)并尝试设置页面的 HTML lang 属性。 /// - 提供事件通知 LanguageChanged,供 UI 或其他服务订阅以响应语言变更。 /// /// 说明:为了兼容 Server 与 WASM,本 Provider 会尽量根据运行时环境选择最合适的资源加载方式。 /// public interface ILocalizationProvider { string CurrentCulture { get; } string? GetString(string key); Task SetCultureAsync(string cultureShortOrFull); Task InitializeAsync(); /// /// 保证指定文化的资源已被加载(异步)。供外部在需要时触发加载。 /// Task LoadCultureAsync(string culture); event EventHandler? LanguageChanged; } /// /// LocalizationProvider 的实现: /// - 维护一个静态缓存,避免重复下载/读取资源。 /// - 支持短码(如 zh / en)与完整 culture(如 zh-Hans / en-US)之间的映射。 /// - 在 Server 端支持同步加载以满足 prerender 场景。 /// 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。使用 ConcurrentDictionary 以线程安全地共享。 // 使用静态字段是为了使中间件/服务在同一进程内能够复用已加载的翻译,避免重复 I/O。 private static readonly ConcurrentDictionary> _cache = new(); // 支持的短码映射,后续如需扩展可在此添加。 private static readonly Dictionary ShortToCulture = new(StringComparer.OrdinalIgnoreCase) { { "zh", "zh-Hans" }, { "en", "en-US" } }; // 默认文化(当未能从浏览器/URL/Cookie 中解析到文化时使用) private string _currentCulture = "zh-Hans"; private const string CookieName = "atomx.culture"; /// /// 构造函数:通过依赖注入获取必要服务。 /// 注意构造函数中不应执行耗时或 JS 相关的同步操作。 /// public LocalizationProvider(IServiceProvider sp, ILogger logger, IHttpClientFactory? httpClientFactory, IJSRuntime? jsRuntime, ILocalizationService localizationService) { _sp = sp; _httpClientFactory = httpClientFactory; _jsRuntime = jsRuntime; _logger = logger; _localizationService = localizationService; // 尝试根据当前线程 culture 初始化 _currentCulture(不抛出异常) try { var threadUi = CultureInfo.DefaultThreadCurrentUICulture ?? CultureInfo.CurrentUICulture; if (!string.IsNullOrEmpty(threadUi?.Name)) { _currentCulture = MapToFullCulture(threadUi!.Name); _logger.LogDebug("LocalizationProvider 构造检测到线程 UI 文化: {Culture}", _currentCulture); } } catch (Exception ex) { _logger.LogDebug(ex, "LocalizationProvider 构造读取线程文化失败"); } // 如果在 Server 环境且未能使用 JSRuntime(意味着非浏览器),则尝试同步从文件系统读取本地化文件以支持 prerender 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("(Server 同步) 从路径加载本地化文件 {Path},Culture:{Culture},条目数:{Count}", path, _currentCulture, dict.Count); } catch (Exception ex) { _logger.LogWarning(ex, "(Server 同步) 读取本地化文件失败: {Path}", path); } } else { _logger.LogDebug("本地化文件未在路径找到: {Path}", path); } } } } catch (Exception ex) { _logger.LogDebug(ex, "LocalizationProvider 构造中同步文件加载尝试失败"); } } public string CurrentCulture => _currentCulture; public event EventHandler? LanguageChanged; /// /// 从缓存中读取指定键的本地化字符串,未找到返回 null。 /// 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; } /// /// 初始化流程(浏览器端) /// 优先级:URL 语言前缀 -> Cookie(atomx.culture) -> 浏览器语言 -> 默认 /// 若解析到短码(如 zh/en)则映射为完整文化(如 zh-Hans/en-US)。 /// public async Task InitializeAsync() { _logger.LogDebug("LocalizationProvider.InitializeAsync 开始. 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("检测到 URL 首段: {Segment}", urlFirstSegment); } } } } catch (Exception ex) { _logger.LogDebug(ex, "读取 location.pathname 失败"); } if (!string.IsNullOrEmpty(urlFirstSegment) && ShortToCulture.TryGetValue(urlFirstSegment, out var mapped)) { _logger.LogDebug("URL 短码 '{Seg}' 映射为文化 '{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 短码 '{Cookie}' 映射为文化 {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("浏览器语言: {BrowserLang}", browserLang); if (!string.IsNullOrEmpty(browserLang)) { var mappedFromBrowser = MapToFullCulture(browserLang); _logger.LogDebug("浏览器语言映射为 {Culture}", mappedFromBrowser); await SetCultureInternalAsync(mappedFromBrowser, persistCookie: false); return; } } } catch (Exception ex) { _logger.LogDebug(ex, "读取浏览器语言失败"); } // 回退到当前默认文化并确保加载资源 _logger.LogDebug("InitializeAsync 回退使用当前文化 {Culture}", _currentCulture); await EnsureCultureLoadedAsync(_currentCulture); await SetCultureInternalAsync(_currentCulture, persistCookie: false); } /// /// 对外异步设置文化(可传短码或完整 culture),并可选择是否持久化到 Cookie /// 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 端用于 prerender 时的同步设置:立即设置线程 Culture 并尝试从 webroot 同步加载本地化文件。 /// 该方法不会触发 JS 操作,适合在中间件或请求处理早期使用。 /// public void SetCultureForServer(string cultureShortOrFull) { try { var cultureFull = MapToFullCulture(cultureShortOrFull); if (string.IsNullOrEmpty(cultureFull)) return; // 设置线程 culture,影响后续服务端处理(例如 IStringLocalizer) try { var ci = new CultureInfo(cultureFull); CultureInfo.DefaultThreadCurrentCulture = ci; CultureInfo.DefaultThreadCurrentUICulture = ci; _currentCulture = cultureFull; } catch { } // 同步从 webroot 加载 JSON 本地化文件(若存在) 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 同步) 已为 {Culture} 从路径加载本地化文件,条目数: {Count}", cultureFull, dict.Count); } catch (Exception ex) { _logger.LogWarning(ex, "(Server 同步) 读取本地化文件失败: {Path}", path); } } } } } catch (Exception ex) { _logger.LogDebug(ex, "SetCultureForServer 加载文件时发生错误: {Culture}", cultureFull); } } catch (Exception ex) { _logger.LogDebug(ex, "SetCultureForServer 执行过程中发生异常"); } } /// /// 内部设置文化并在需要时持久化 Cookie、更新 localization service 并触发事件。 /// 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}", 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; // 将 shortKey 写入 cookie,供后端请求读取(例如 Server 模式下的中间件) await _jsRuntime.InvokeVoidAsync("cookies.Write", CookieName, shortKey, DateTime.UtcNow.AddYears(1).ToString("o")); } catch (Exception ex) { _logger.LogDebug(ex, "写入 Cookie 失败"); } } try { if (_jsRuntime != null) { // 尝试设置 HTML 的 lang 属性,改善无障碍/SEO await _jsRuntime.InvokeVoidAsync("setHtmlLang", cultureFull); } } catch { } // 通知订阅者 LanguageChanged?.Invoke(this, cultureFull); } /// /// 确保指定文化的 JSON 文件已被加载到缓存。加载顺序:WASM HttpClient -> 文件系统 -> 空字典占位。 /// private async Task EnsureCultureLoadedAsync(string cultureFull) { // 归一化短码(例如 zh -> zh-Hans) cultureFull = MapToFullCulture(cultureFull); if (string.IsNullOrEmpty(cultureFull)) return; if (_cache.ContainsKey(cultureFull)) { _logger.LogDebug("EnsureCultureLoadedAsync: 文化 {Culture} 已缓存", cultureFull); return; } // 如果在浏览器(WASM),优先使用 HttpClient 下载本地化 JSON if (_jsRuntime != null && OperatingSystem.IsBrowser()) { _logger.LogInformation("EnsureCultureLoadedAsync: 在浏览器环境,将尝试通过 HttpClient 下载 {Culture}", cultureFull); try { var http = _sp.GetService(typeof(HttpClient)) as HttpClient; if (http == null && _httpClientFactory != null) { _logger.LogDebug("未从 ServiceProvider 解析到 HttpClient,使用 IHttpClientFactory 创建"); http = _httpClientFactory.CreateClient(); } else { _logger.LogDebug("从 ServiceProvider 解析 HttpClient: {HasClient}", http != null); } if (http != null) { var url = $"/localization/{cultureFull}.json"; Uri? requestUri = null; // 当 HttpClient 配置了 BaseAddress 时使用;否则通过 JS 获取 location.origin 构建绝对 URI 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)) { requestUri = new Uri(new Uri(origin), url); } } catch (Exception jsEx) { _logger.LogDebug(jsEx, "从 JS 获取 location.origin 失败"); } } if (requestUri != null) { _logger.LogInformation("从 {Url} 下载本地化资源", requestUri); var txt = await http.GetStringAsync(requestUri); var dict = JsonSerializer.Deserialize>(txt) ?? new Dictionary(); _cache[cultureFull] = dict; _logger.LogInformation("通过 HttpClient 为 {Culture} 加载到本地化数据,条目数: {Count}", cultureFull, dict.Count); return; } else { _logger.LogWarning("HttpClient 无法构建请求 URL,跳过通过 HttpClient 加载 {Culture}", cultureFull); } } else { _logger.LogWarning("未找到可用的 HttpClient 以加载 {Culture}", cultureFull); } } catch (Exception ex) { _logger.LogDebug(ex, "通过 HttpClient 加载本地化文件失败: {Culture}", cultureFull); } } _logger.LogDebug("EnsureCultureLoadedAsync: 尝试文件系统加载 {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("查找本地化文件路径: {Path}", path); if (File.Exists(path)) { var json = await File.ReadAllTextAsync(path); var dict = JsonSerializer.Deserialize>(json) ?? new Dictionary(); _cache[cultureFull] = dict; _logger.LogInformation("从文件为 {Culture} 加载本地化,条目数: {Count}", cultureFull, dict.Count); return; } else { _logger.LogDebug("未在路径找到本地化文件: {Path}", path); // 备用路径:构建于运行时输出目录的 wwwroot try { var alt = Path.Combine(AppContext.BaseDirectory ?? ".", "wwwroot", "localization", cultureFull + ".json"); _logger.LogDebug("查找备用路径: {AltPath}", alt); if (File.Exists(alt)) { var json2 = await File.ReadAllTextAsync(alt); var dict2 = JsonSerializer.Deserialize>(json2) ?? new Dictionary(); _cache[cultureFull] = dict2; _logger.LogInformation("从备用路径为 {Culture} 加载到本地化文件,条目数: {Count}", cultureFull, dict2.Count); return; } else { _logger.LogDebug("备用路径未找到本地化文件: {AltPath}", alt); } } catch (Exception exAlt) { _logger.LogDebug(exAlt, "检查备用本地化路径时出错"); } } } else { _logger.LogDebug("无法从 ServiceProvider 获取 IWebHostEnvironment 实例"); } } else { _logger.LogDebug("通过反射未能找到 IWebHostEnvironment 类型"); } } catch (Exception ex) { _logger.LogDebug(ex, "通过文件系统加载本地化文件失败: {Culture}", cultureFull); } _logger.LogDebug("EnsureCultureLoadedAsync: 回退为 {Culture} 的空字典占位", cultureFull); _cache[cultureFull] = new Dictionary(); } /// /// 将短码或带区域的字符串映射为内部使用的完整 culture(例如 zh -> zh-Hans) /// private string MapToFullCulture(string culture) { if (string.IsNullOrEmpty(culture)) return culture; // 直接映射 if (ShortToCulture.TryGetValue(culture, out var mapped)) return mapped; // 考虑前缀,例如 zh-CN -> zh var prefix = culture.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); if (!string.IsNullOrEmpty(prefix) && ShortToCulture.TryGetValue(prefix, out var mapped2)) return mapped2; return culture; } } }