实现多语言切换

This commit is contained in:
2025-12-09 03:31:07 +08:00
parent 9631e00a12
commit 429fb39140
18 changed files with 1173 additions and 41 deletions

View File

@@ -0,0 +1,517 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.JSInterop;
using Microsoft.Extensions.Localization;
namespace Atomx.Admin.Client.Services
{
/// <summary>
/// <20><EFBFBD><E1B9A9><EFBFBD><EFBFBD><EFBFBD><EFBFBD> JSON <20>ļ<EFBFBD><C4BC><EFBFBD><EFBFBD>ء<EFBFBD><D8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʱ<EFBFBD>л<EFBFBD><D0BB><EFBFBD>ʵ<EFBFBD>֡<EFBFBD>
/// - <20><> Server <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͨ<EFBFBD><CDA8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> IWebHostEnvironment <20><> webroot<6F><74><EFBFBD><EFBFBD><EFBFBD>ļ<EFBFBD>ϵͳ<CFB5><CDB3>ȡ {culture}.json <20>ļ<EFBFBD><C4BC><EFBFBD>
/// - <20><> WASM <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʹ<EFBFBD><CAB9>ע<EFBFBD><D7A2><EFBFBD><EFBFBD> HttpClient <20><> /localization/{culture}.json <20><><EFBFBD>ز<EFBFBD><D8B2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
/// ͬʱ<CDAC><CAB1><EFBFBD>л<EFBFBD><D0BB><EFBFBD><EFBFBD><EFBFBD>ʱд<CAB1><D0B4> Cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ҳ<EFBFBD><D2B3> HTML lang <20><><EFBFBD>ԡ<EFBFBD>
/// </summary>
public interface ILocalizationProvider
{
string CurrentCulture { get; }
string? GetString(string key);
Task SetCultureAsync(string cultureShortOrFull);
Task InitializeAsync();
/// <summary>
/// <20><>ָ֤<D6A4><D6B8><EFBFBD>Ļ<EFBFBD><C4BB><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD>ѱ<EFBFBD><D1B1><EFBFBD><EFBFBD>أ<EFBFBD><D8A3><EFBFBD><ECB2BD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ⲿ<EFBFBD><E2B2BF><EFBFBD><EFBFBD>Ҫʱ<D2AA><CAB1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ء<EFBFBD>
/// </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;
// <20><><EFBFBD>棺culture -> translations
private readonly Dictionary<string, Dictionary<string, string>> _cache = new();
// <20><><EFBFBD><EFBFBD><EBB5BD><EFBFBD><EFBFBD> culture <20><>ӳ<EFBFBD><D3B3>
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;
// <20><><EFBFBD>ڹ<EFBFBD><DAB9><EFBFBD><ECBAAF><EFBFBD>н<EFBFBD><D0BD><EFBFBD> JS <20><><EFBFBD><EFBFBD>ͬ<EFBFBD><CDAC> IO<49><4F><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ը<EFBFBD><D4B8><EFBFBD><EFBFBD>߳<EFBFBD> culture <20><><EFBFBD>ó<EFBFBD>ʼֵ<CABC><D6B5><EFBFBD><EFBFBD>һ<EFBFBD><D2BB>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD> 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");
}
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Server<65><72>IWebHostEnvironment <20><><EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD> JSRuntime <20><><EFBFBD><EFBFBD><EFBFBD>ã<EFBFBD>˵<EFBFBD><CBB5><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͬ<EFBFBD><CDAC><EFBFBD><EFBFBD><EFBFBD>ļ<EFBFBD><C4BC><EFBFBD><EFBFBD>ر<EFBFBD><D8B1>ػ<EFBFBD><D8BB>ļ<EFBFBD>
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, "<22><>ȡ location.pathname ʧ<><CAA7>");
}
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>("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
{
// <20><><EFBFBD><EFBFBD> cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> culture<72><65><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>һ<EFBFBD><D2BB><EFBFBD><EFBFBD>ʹ<EFBFBD><CAB9>
await SetCultureInternalAsync(MapToFullCulture(cookieVal), persistCookie: false);
return;
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "<22><>ȡ Cookie ʧ<><CAA7>");
}
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, "<22><>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʧ<EFBFBD><CAA7>");
}
// <20><><EFBFBD><EFBFBD>ȷ<EFBFBD><C8B7><EFBFBD><EFBFBD><EFBFBD>ص<EFBFBD>ǰ 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));
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, "<22><><EFBFBD><EFBFBD> Culture ʧ<><CAA7>: {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 ʧ<><CAA7>");
}
}
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, <><CDA8> HttpClient <20><><EFBFBD>ر<EFBFBD><D8B1>ػ<EFBFBD><D8BB>ļ<EFBFBD>ʧ<EFBFBD><CAA7>: {Culture}", cultureFull);
}
}
_logger?.LogDebug("EnsureCultureLoadedAsync trying filesystem for {Culture}", cultureFull);
// <20><><EFBFBD><EFBFBD>ͨ<EFBFBD><CDA8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȡ IWebHostEnvironment<6E><74>Server ʱ<><CAB1><EFBFBD>ã<EFBFBD>
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, "<22><><EFBFBD>ļ<EFBFBD>ϵͳ<CFB5><CDB3><EFBFBD>ر<EFBFBD><D8B1>ػ<EFBFBD><D8BB>ļ<EFBFBD>ʧ<EFBFBD><CAA7>: {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>
/// <20><><EFBFBD><EFBFBD> ILocalizationProvider <20><> IStringLocalizer ʵ<>֣<EFBFBD>
/// ʹ<><CAB9> JSON <20>ļ<EFBFBD><C4BC>еļ<D0B5>ֵ<EFBFBD><D6B5>δ<EFBFBD>ҵ<EFBFBD><D2B5><EFBFBD><EFBFBD><EFBFBD> key <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
/// <20><><EFBFBD>Ƹ<EFBFBD>Ϊ JsonStringLocalizer <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD> StringLocalizer <20><>ͻ<EFBFBD><CDBB>
/// </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;
}
}
}