diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor
index dbf5179..0318531 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor
+++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor
@@ -20,19 +20,16 @@
Login
Weather
-
zh Quick links:
Login
Weather
-
en Quick links:
Login
Weather
-
@code {
[Parameter]
@@ -40,35 +37,20 @@
private int currentCount = 0;
- protected override async Task OnInitializedAsync()
+ protected override Task OnInitializedAsync()
{
- if (LocalizationProvider != null)
- {
- LocalizationProvider.LanguageChanged += OnLanguageChanged;
- }
-
- // If running in browser, ensure current culture loaded (WASM loads asynchronously)
- if (OperatingSystem.IsBrowser() && LocalizationProvider != null)
- {
- try
- {
- await LocalizationProvider.LoadCultureAsync(LocalizationProvider.CurrentCulture);
- }
- catch { }
- }
+ // localization handled globally in Routes. No per-page initialization needed.
+ return Task.CompletedTask;
}
private void OnLanguageChanged(object? sender, string culture)
{
- _ = InvokeAsync(StateHasChanged);
+ // no-op; global router remount will update page translations
}
public void Dispose()
{
- if (LocalizationProvider != null)
- {
- LocalizationProvider.LanguageChanged -= OnLanguageChanged;
- }
+ // no per-page unsubscribe required
}
private void IncrementCount()
diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor
index f9a2a21..f45f7dc 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor
+++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Login.razor
@@ -111,11 +111,6 @@ else
{
handler = "Server";
}
-
- if (LocalizationProvider != null)
- {
- LocalizationProvider.LanguageChanged += OnLanguageChanged;
- }
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -133,25 +128,6 @@ else
dataLoaded = true;
StateHasChanged();
}
-
- // Ensure culture loaded on client so translations are available
- if (firstRender && OperatingSystem.IsBrowser() && LocalizationProvider != null)
- {
- try
- {
- await LocalizationProvider.LoadCultureAsync(LocalizationProvider.CurrentCulture);
- }
- catch { }
- }
- }
-
- private async void OnLanguageChanged(object? sender, string culture)
- {
- // ensure UI updates on Blazor sync context
- await InvokeAsync(() => {
- dataLoaded = true; // ensure UI is rendered
- StateHasChanged();
- });
}
private async Task LoginAsync()
diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor
index 3dd22f1..f7ded12 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor
+++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor
@@ -17,6 +17,18 @@
Counter
+
+ zh Quick links:
+ Login
+ Counter
+
+
+
+ en Quick links:
+ Login
+ Counter
+
+
@if (forecasts == null)
{
Loading...
@@ -51,34 +63,13 @@ else
public string Locale { get; set; } = string.Empty;
private WeatherForecast[]? forecasts;
- protected override async Task OnInitializedAsync()
+ protected override Task OnInitializedAsync()
{
- if (LocalizationProvider != null)
- {
- LocalizationProvider.LanguageChanged += OnLanguageChanged;
- }
-
- if (OperatingSystem.IsBrowser() && LocalizationProvider != null)
- {
- try { await LocalizationProvider.LoadCultureAsync(LocalizationProvider.CurrentCulture); } catch { }
- }
-
- // 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;
}
- private void OnLanguageChanged(object? s, string c) => _ = InvokeAsync(StateHasChanged);
-
- public void Dispose() { if (LocalizationProvider != null) LocalizationProvider.LanguageChanged -= OnLanguageChanged; }
+ public void Dispose() { }
private class WeatherForecast
{
diff --git a/Atomx.Admin/Atomx.Admin.Client/Routes.razor b/Atomx.Admin/Atomx.Admin.Client/Routes.razor
index 9a76367..baf152d 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Routes.razor
+++ b/Atomx.Admin/Atomx.Admin.Client/Routes.razor
@@ -1,23 +1,101 @@
@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)
@@ -33,4 +111,19 @@
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
index e5c2c7e..19dec8f 100644
--- a/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs
+++ b/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using Microsoft.JSInterop;
@@ -33,7 +34,9 @@ namespace Atomx.Admin.Client.Services
private readonly ILocalizationService _localizationService;
// 棺culture -> translations
- private readonly Dictionary> _cache = new();
+ // 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)
@@ -231,6 +234,70 @@ namespace Atomx.Admin.Client.Services
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);
diff --git a/Atomx.Admin/Atomx.Admin/Middlewares/RequestCultureMiddleware.cs b/Atomx.Admin/Atomx.Admin/Middlewares/RequestCultureMiddleware.cs
index ae2cc9c..127dd0e 100644
--- a/Atomx.Admin/Atomx.Admin/Middlewares/RequestCultureMiddleware.cs
+++ b/Atomx.Admin/Atomx.Admin/Middlewares/RequestCultureMiddleware.cs
@@ -26,24 +26,55 @@ namespace Atomx.Admin.Middlewares
public async Task InvokeAsync(HttpContext context)
{
+ var logger = context.RequestServices.GetService(typeof(ILogger)) as ILogger;
+
var path = context.Request.Path.Value ?? "/";
+ logger?.LogDebug("RequestCultureMiddleware start for path {Path}", path);
var trimmed = path.Trim('/');
if (!string.IsNullOrEmpty(trimmed))
{
var segments = trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries);
var first = segments.FirstOrDefault();
+ logger?.LogDebug("RequestCultureMiddleware firstSegment='{Seg}'", first);
if (!string.IsNullOrEmpty(first) && ShortToCulture.TryGetValue(first, out var cultureName))
{
+ logger?.LogInformation("RequestCultureMiddleware detected locale prefix '{Prefix}' -> {Culture}", first, cultureName);
+
// 设置线程 Culture(影响后续处理中 IStringLocalizer / 日期格式等)
try
{
var ci = new CultureInfo(cultureName);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
+ logger?.LogDebug("Thread culture set to {Culture}", cultureName);
}
- catch
+ catch (Exception ex)
{
- // 忽略非法 culture
+ logger?.LogWarning(ex, "Failed to set thread culture to {Culture}", cultureName);
+ }
+
+ // Attempt to synchronously load localization for server-side rendering
+ try
+ {
+ var providerObj = context.RequestServices.GetService(typeof(Atomx.Admin.Client.Services.ILocalizationProvider));
+ if (providerObj == null)
+ {
+ logger?.LogDebug("ILocalizationProvider not registered in RequestServices");
+ }
+ else if (providerObj is Atomx.Admin.Client.Services.LocalizationProvider provider)
+ {
+ logger?.LogDebug("Calling SetCultureForServer on LocalizationProvider with {Culture}", cultureName);
+ provider.SetCultureForServer(cultureName);
+ logger?.LogInformation("LocalizationProvider.CurrentCulture after SetCultureForServer: {Culture}", provider.CurrentCulture);
+ }
+ else
+ {
+ logger?.LogDebug("ILocalizationProvider resolved but not the concrete LocalizationProvider type: {Type}", providerObj.GetType().FullName);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.LogWarning(ex, "Error while invoking SetCultureForServer");
}
// 将请求路径去掉首段语言前缀,便于后续路由匹配(例如 /zh/account -> /account)
@@ -66,6 +97,7 @@ namespace Atomx.Admin.Middlewares
}
}
+ logger?.LogDebug("RequestCultureMiddleware found no locale prefix for path {Path}", path);
await _next(context);
}
}
diff --git a/Atomx.Admin/Atomx.Admin/Program.cs b/Atomx.Admin/Atomx.Admin/Program.cs
index 8dfa217..2f1bd4c 100644
--- a/Atomx.Admin/Atomx.Admin/Program.cs
+++ b/Atomx.Admin/Atomx.Admin/Program.cs
@@ -207,8 +207,8 @@ app.UseResponseCompression();
// ʹ CORS
app.UseCors("DefaultCors");
-//// ע RequestCultureMiddlewareʹⲿ Culture ǰ
-//app.UseMiddleware();
+// ע RequestCultureMiddlewareʹⲿ Culture ǰ
+app.UseMiddleware();
app.UseStaticFiles(new StaticFileOptions