L
+@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
-Counter
+@L["site.name"]
-Current count: @currentCount
+@L["site.name"]
-
+@(L["current.count"] ?? "Current count"): @currentCount
+
+
+
+
+
+ Quick links:
+ Login
+ Weather
+
+
+ zh Quick links:
+ Login
+ Weather
+
+
+ en Quick links:
+ Login
+ Weather
+
@code {
+
+ [Parameter]
+ public string Locale { get; set; } = string.Empty;
+
private int currentCount = 0;
+ protected override Task OnInitializedAsync()
+ {
+ // localization handled globally in Routes. No per-page initialization needed.
+ return Task.CompletedTask;
+ }
+
+ private void OnLanguageChanged(object? sender, string culture)
+ {
+ // no-op; global router remount will update page translations
+ }
+
+ public void Dispose()
+ {
+ // no per-page unsubscribe required
+ }
+
private void IncrementCount()
{
currentCount++;
}
+
+ private string GetShortCulture(string current)
+ {
+ if (string.IsNullOrEmpty(current)) return current;
+ if (current.StartsWith("zh", StringComparison.OrdinalIgnoreCase)) return "zh";
+ if (current.StartsWith("en", StringComparison.OrdinalIgnoreCase)) return "en";
+ var prefix = current.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ return prefix ?? current;
+ }
}
diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/DebugLocalization.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/DebugLocalization.razor
new file mode 100644
index 0000000..58f1813
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin.Client/Pages/DebugLocalization.razor
@@ -0,0 +1,123 @@
+@page "/debug/localization"
+@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
+@inject IHttpClientFactory HttpClientFactory
+@inject IJSRuntime JS
+@inject ILogger Logger
+@inject HttpClient Http
+@inject NavigationManager Nav
+
+Localization Debug
+
+CurrentCulture: @LocalizationProvider.CurrentCulture
+
+
+
+
+
+
+Provider Results
+
+ @foreach (var kv in providerResults)
+ {
+ - @kv
+ }
+
+
+HTTP Fetch Results
+
+ @foreach (var kv in httpResults)
+ {
+ - @kv
+ }
+
+
+JS Read
+@jsResult
+
+@code {
+ private List providerResults = new();
+ private List httpResults = new();
+ private string jsResult = string.Empty;
+
+ private async Task SetProviderZh()
+ {
+ providerResults.Clear();
+ try
+ {
+ // Use short code; provider will map to full culture and set active culture
+ await LocalizationProvider.SetCultureAsync("zh");
+ providerResults.Add($"Set culture to {LocalizationProvider.CurrentCulture}");
+ providerResults.Add($"site.name={LocalizationProvider.GetString("site.name")}");
+ providerResults.Add($"login.title={LocalizationProvider.GetString("login.title")}");
+ }
+ catch (Exception ex)
+ {
+ providerResults.Add("Set provider zh failed: " + ex.Message);
+ Logger.LogError(ex, "Set provider zh failed");
+ }
+ }
+
+ private async Task SetProviderEn()
+ {
+ providerResults.Clear();
+ try
+ {
+ await LocalizationProvider.SetCultureAsync("en");
+ providerResults.Add($"Set culture to {LocalizationProvider.CurrentCulture}");
+ providerResults.Add($"site.name={LocalizationProvider.GetString("site.name")}");
+ providerResults.Add($"login.title={LocalizationProvider.GetString("login.title")}");
+ }
+ catch (Exception ex)
+ {
+ providerResults.Add("Set provider en failed: " + ex.Message);
+ Logger.LogError(ex, "Set provider en failed");
+ }
+ }
+
+ private async Task FetchFiles()
+ {
+ httpResults.Clear();
+ try
+ {
+ // Prefer injected HttpClient that has BaseAddress set in Program.cs for WASM
+ var client = Http ?? HttpClientFactory.CreateClient();
+
+ var urlZ = new Uri(new Uri(Nav.BaseUri), $"localization/zh-Hans.json");
+ httpResults.Add($"GET {urlZ}");
+ var resZ = await client.GetAsync(urlZ);
+ httpResults.Add($"=> {resZ.StatusCode}");
+ if (resZ.IsSuccessStatusCode)
+ {
+ var txt = await resZ.Content.ReadAsStringAsync();
+ httpResults.Add("zh content len=" + txt.Length);
+ }
+
+ var urlE = new Uri(new Uri(Nav.BaseUri), $"localization/en-US.json");
+ httpResults.Add($"GET {urlE}");
+ var resE = await client.GetAsync(urlE);
+ httpResults.Add($"=> {resE.StatusCode}");
+ if (resE.IsSuccessStatusCode)
+ {
+ var txt = await resE.Content.ReadAsStringAsync();
+ httpResults.Add("en content len=" + txt.Length);
+ }
+ }
+ catch (Exception ex)
+ {
+ httpResults.Add("Fetch error: " + ex.Message);
+ Logger.LogError(ex, "FetchFiles error");
+ }
+ }
+
+ private async Task ReadCookie()
+ {
+ try
+ {
+ jsResult = await JS.InvokeAsync("CookieReader.Read", "atomx.culture");
+ }
+ catch (Exception ex)
+ {
+ jsResult = "JS error: " + ex.Message;
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor
index 03d8af1..f45f7dc 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor
+++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor
@@ -4,8 +4,11 @@
@layout EmptyLayout
@inject ILogger Logger
@inject IJSRuntime JS
+@using Microsoft.Extensions.Localization
+@inject IStringLocalizer L
+@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
-登录
+@L["login.title"]
@if (!dataLoaded)
{
@@ -20,29 +23,29 @@ else
@@ -50,9 +53,31 @@ else
- Copyright © 2025-@DateTime.Now.Year Atomlust.com All rights reserved.
+ @L["copyright"]
runing as @handler
+
+
+
+
+
+
+
+ Quick links:
+ Counter
+ Weather
+
+
+ zh Quick links:
+ Counter
+ Weather
+
+
+ en Quick links:
+ Counter
+ Weather
+
+
}
@@ -200,6 +225,15 @@ else
login.Account = "admin";
login.Password = "admin888";
}
+
+ private string GetShortCulture(string current)
+ {
+ if (string.IsNullOrEmpty(current)) return current;
+ if (current.StartsWith("zh", StringComparison.OrdinalIgnoreCase)) return "zh";
+ if (current.StartsWith("en", StringComparison.OrdinalIgnoreCase)) return "en";
+ var prefix = current.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ return prefix ?? current;
+ }
}
@* 页面内 JS 辅助:用于在 Server 模式下从浏览器发起 POST 并携带凭证,使浏览器接收 Set-Cookie *@
diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor
index dd36b18..f7ded12 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor
+++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor
@@ -1,10 +1,33 @@
@page "/weather"
+@page "/{locale}/weather"
-Weather
+@using Microsoft.Extensions.Localization
+@inject IStringLocalizer L
+@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
-Weather
+@L["weather.title"]
-This component demonstrates showing data.
+@L["weather.title"]
+
+@L["weather.summary"]
+
+
+ Quick links:
+ Login
+ Counter
+
+
+
+ zh Quick links:
+ Login
+ Counter
+
+
+
+ en Quick links:
+ Login
+ Counter
+
@if (forecasts == null)
{
@@ -16,7 +39,7 @@ else
| Date |
- Temp. (C) |
+ @L["weather.temperature"] |
Temp. (F) |
Summary |
@@ -36,23 +59,18 @@ else
}
@code {
+ [Parameter]
+ public string Locale { get; set; } = string.Empty;
private WeatherForecast[]? forecasts;
- protected override async Task OnInitializedAsync()
+ protected override Task OnInitializedAsync()
{
- // Simulate asynchronous loading to demonstrate a loading indicator
- await Task.Delay(500);
-
- var startDate = DateOnly.FromDateTime(DateTime.Now);
- var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
- forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = startDate.AddDays(index),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = summaries[Random.Shared.Next(summaries.Length)]
- }).ToArray();
+ // localization handled globally in Routes. No per-page initialization needed.
+ return Task.CompletedTask;
}
+ public void Dispose() { }
+
private class WeatherForecast
{
public DateOnly Date { get; set; }
@@ -60,4 +78,13 @@ else
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
+
+ private string GetShortCulture(string current)
+ {
+ if (string.IsNullOrEmpty(current)) return current;
+ if (current.StartsWith("zh", StringComparison.OrdinalIgnoreCase)) return "zh";
+ if (current.StartsWith("en", StringComparison.OrdinalIgnoreCase)) return "en";
+ var prefix = current.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ return prefix ?? current;
+ }
}
diff --git a/Atomx.Admin/Atomx.Admin.Client/Program.cs b/Atomx.Admin/Atomx.Admin.Client/Program.cs
index 448ccd1..6ec603d 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Program.cs
+++ b/Atomx.Admin/Atomx.Admin.Client/Program.cs
@@ -26,8 +26,14 @@ builder.Services.AddScoped();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
-// עIStringLocalizer
-builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
+// ע LocalizationProvider ( WASM)
+// Use Scoped lifetime because LocalizationProvider depends on IJSRuntime (scoped) and IHttpClient etc.
+builder.Services.AddScoped();
+// ע ILocalizationService ͬ Culture 䴫
+builder.Services.AddScoped();
+
+// ע IStringLocalizer ʵ
+builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(JsonStringLocalizer<>));
// ӱػ
builder.Services.AddLocalization();
@@ -64,4 +70,6 @@ builder.Services.AddScoped(sp =>
builder.Services.AddAntDesign();
-await builder.Build().RunAsync();
+var host = builder.Build();
+
+await host.RunAsync();
diff --git a/Atomx.Admin/Atomx.Admin.Client/Routes.razor b/Atomx.Admin/Atomx.Admin.Client/Routes.razor
index 2771534..baf152d 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Routes.razor
+++ b/Atomx.Admin/Atomx.Admin.Client/Routes.razor
@@ -1,13 +1,129 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+@using Microsoft.AspNetCore.Components.Routing
+@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
+@inject NavigationManager Navigation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ private bool _initialized;
+
+ protected override async Task OnInitializedAsync()
+ {
+ // Subscribe to language changes to remount router when needed
+ if (LocalizationProvider != null)
+ {
+ LocalizationProvider.LanguageChanged += OnLanguageChanged;
+ }
+
+ // Subscribe to navigation events so client-side nav to /zh/... or /en/... triggers culture change
+ Navigation.LocationChanged += OnLocationChanged;
+
+ // Try detect first URL segment as short culture (e.g. /zh/ or /en/) and set culture before first render
+ try
+ {
+ var relative = Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/');
+ var segments = string.IsNullOrEmpty(relative) ? Array.Empty() : relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var first = segments.FirstOrDefault();
+ var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "zh", "zh-Hans" },
+ { "en", "en-US" }
+ };
+
+ if (!string.IsNullOrEmpty(first) && mapping.TryGetValue(first, out var mapped))
+ {
+ try
+ {
+ if (OperatingSystem.IsBrowser())
+ {
+ await LocalizationProvider.SetCultureAsync(mapped);
+ }
+ else
+ {
+ if (LocalizationProvider is Atomx.Admin.Client.Services.LocalizationProvider concrete)
+ {
+ concrete.SetCultureForServer(mapped);
+ }
+ }
+ }
+ catch { }
+ }
+ }
+ catch { }
+ }
+
+ private async void OnLocationChanged(object? sender, LocationChangedEventArgs args)
+ {
+ try
+ {
+ var relative = Navigation.ToBaseRelativePath(args.Location).Trim('/');
+ var segments = string.IsNullOrEmpty(relative) ? Array.Empty() : relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var first = segments.FirstOrDefault();
+ if (string.IsNullOrEmpty(first)) return;
+
+ var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "zh", "zh-Hans" },
+ { "en", "en-US" }
+ };
+
+ if (mapping.TryGetValue(first, out var mapped))
+ {
+ // if culture already set to same, skip
+ if (string.Equals(LocalizationProvider.CurrentCulture, mapped, StringComparison.OrdinalIgnoreCase)) return;
+
+ // Call async set; ignore errors
+ await LocalizationProvider.SetCultureAsync(mapped);
+
+ // Trigger router remount via StateHasChanged
+ _ = InvokeAsync(StateHasChanged);
+ }
+ }
+ catch { }
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender && !_initialized)
+ {
+ _initialized = true;
+ try
+ {
+ if (LocalizationProvider != null)
+ {
+ await LocalizationProvider.InitializeAsync();
+ }
+ }
+ catch { }
+ }
+ }
+
+ private void OnLanguageChanged(object? s, string culture)
+ {
+ // Remount router via @key by triggering StateHasChanged
+ _ = InvokeAsync(StateHasChanged);
+ }
+
+ public void Dispose()
+ {
+ if (LocalizationProvider != null)
+ {
+ LocalizationProvider.LanguageChanged -= OnLanguageChanged;
+ }
+ Navigation.LocationChanged -= OnLocationChanged;
+ }
+}
\ No newline at end of file
diff --git a/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs b/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs
new file mode 100644
index 0000000..19dec8f
--- /dev/null
+++ b/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs
@@ -0,0 +1,584 @@
+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");
+ }
+
+ // ServerIWebHostEnvironment ã 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);
+ // ͨȡ IWebHostEnvironmentServer ʱã
+ 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;
+ }
+ }
+}
diff --git a/Atomx.Admin/Atomx.Admin.Client/wwwroot/js/common.js b/Atomx.Admin/Atomx.Admin.Client/wwwroot/js/common.js
index ed7380e..2be76a1 100644
--- a/Atomx.Admin/Atomx.Admin.Client/wwwroot/js/common.js
+++ b/Atomx.Admin/Atomx.Admin.Client/wwwroot/js/common.js
@@ -1,31 +1,41 @@
-function getCookie(name) {
- console.log("Getting cookie:", name);
- const value = `; ${document.cookie}`;
- console.log("Document cookie string:", value);
- const parts = value.split(`; ${name}=`);
- console.log("Cookie parts:", parts);
- if (parts.length === 2) {
- var text = parts.pop().split(';').shift();
- console.log("Cookie value found:", text);
- return text;
+window.CookieReader = {
+ Read: function (name) {
+ try {
+ const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
+ if (match) return decodeURIComponent(match[2]);
+ return '';
+ } catch (e) {
+ return '';
}
-}
+ },
+ Write: function (name, value, expiresIso) {
+ try {
+ var expires = '';
+ if (expiresIso) {
+ expires = '; expires=' + new Date(expiresIso).toUTCString();
+ }
+ document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/';
+ } catch (e) { }
+ }
+};
-function setCookie(name, value, days) {
- const expires = new Date(Date.now() + days * 864e5).toUTCString();
- document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Strict; Secure`;
-}
+window.getBrowserLanguage = function () {
+ try {
+ return (navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage || '';
+ } catch (e) {
+ return '';
+ }
+};
-function deleteCookie(name) {
- document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
-}
+window.setHtmlLang = function (lang) {
+ try {
+ if (document && document.documentElement) document.documentElement.lang = lang || '';
+ } catch (e) { }
+};
-// 获取浏览器语言
-function getBrowserLanguage() {
- return navigator.language || navigator.userLanguage || 'zh-Hans';
-}
-
-// 设置HTML lang属性
-function setHtmlLang(lang) {
- document.documentElement.lang = lang;
-}
\ No newline at end of file
+// simple cookies wrapper used earlier as cookies.Write
+window.cookies = {
+ Write: function (name, value, expiresIso) {
+ return window.CookieReader.Write(name, value, expiresIso);
+ }
+};
\ No newline at end of file
diff --git a/Atomx.Admin/Atomx.Admin/Atomx.Admin.csproj b/Atomx.Admin/Atomx.Admin/Atomx.Admin.csproj
index 208dc39..b6ede15 100644
--- a/Atomx.Admin/Atomx.Admin/Atomx.Admin.csproj
+++ b/Atomx.Admin/Atomx.Admin/Atomx.Admin.csproj
@@ -43,4 +43,8 @@
+
+
+
+
diff --git a/Atomx.Admin/Atomx.Admin/Components/App.razor b/Atomx.Admin/Atomx.Admin/Components/App.razor
index 04d229a..bbd035d 100644
--- a/Atomx.Admin/Atomx.Admin/Components/App.razor
+++ b/Atomx.Admin/Atomx.Admin/Components/App.razor
@@ -1,4 +1,5 @@
@using Atomx.Admin.Client.Services
+@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
@@ -7,7 +8,6 @@
- @* *@
@@ -17,9 +17,35 @@
-
+
+
+
+