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