510 lines
24 KiB
C#
510 lines
24 KiB
C#
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, ILogger<LocalizationProvider> logger, IHttpClientFactory? httpClientFactory, IJSRuntime? jsRuntime, 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<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);
|
||
// 尝试通过反射获取 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<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;
|
||
}
|
||
}
|
||
|
||
|
||
}
|