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("CookieReader.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;
}
}
}