From 429fb39140b707ac57f33f8da8ddd6a536c61453 Mon Sep 17 00:00:00 2001 From: Seany <17074267@qq.com> Date: Tue, 9 Dec 2025 03:31:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=A4=9A=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Atomx.Admin.Client.csproj | 4 + .../Components/LangSelector.razor | 82 +++ .../Layout/EmptyLayout.razor | 32 ++ .../Layout/MainLayout.razor | 51 +- .../Atomx.Admin.Client/Pages/Counter.razor | 77 ++- .../Pages/DebugLocalization.razor | 123 +++++ .../Atomx.Admin.Client/Pages/Login.razor | 74 ++- .../Atomx.Admin.Client/Pages/Weather.razor | 44 +- Atomx.Admin/Atomx.Admin.Client/Program.cs | 14 +- Atomx.Admin/Atomx.Admin.Client/Routes.razor | 41 +- .../Services/LocalizationProvider.cs | 517 ++++++++++++++++++ .../Atomx.Admin.Client/wwwroot/js/common.js | 41 ++ Atomx.Admin/Atomx.Admin/Atomx.Admin.csproj | 4 + Atomx.Admin/Atomx.Admin/Components/App.razor | 30 +- Atomx.Admin/Atomx.Admin/Program.cs | 40 +- .../Atomx.Admin/appsettings.Development.json | 4 +- .../wwwroot/localization/en-US.json | 18 +- .../wwwroot/localization/zh-Hans.json | 18 +- 18 files changed, 1173 insertions(+), 41 deletions(-) create mode 100644 Atomx.Admin/Atomx.Admin.Client/Components/LangSelector.razor create mode 100644 Atomx.Admin/Atomx.Admin.Client/Pages/DebugLocalization.razor create mode 100644 Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs create mode 100644 Atomx.Admin/Atomx.Admin.Client/wwwroot/js/common.js diff --git a/Atomx.Admin/Atomx.Admin.Client/Atomx.Admin.Client.csproj b/Atomx.Admin/Atomx.Admin.Client/Atomx.Admin.Client.csproj index 1417e7b..44da03a 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Atomx.Admin.Client.csproj +++ b/Atomx.Admin/Atomx.Admin.Client/Atomx.Admin.Client.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/Atomx.Admin/Atomx.Admin.Client/Components/LangSelector.razor b/Atomx.Admin/Atomx.Admin.Client/Components/LangSelector.razor new file mode 100644 index 0000000..5531141 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin.Client/Components/LangSelector.razor @@ -0,0 +1,82 @@ +@inject ILocalizationProvider LocalizationProvider +@inject NavigationManager Navigation +@inject IJSRuntime JS + + + +@code { + private string selected = "zh"; + + private readonly Dictionary options = new(StringComparer.OrdinalIgnoreCase) + { + { "zh", "" }, + { "en", "English" } + }; + + protected override async Task OnInitializedAsync() + { + var current = LocalizationProvider.CurrentCulture; + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "zh", "zh-Hans" }, + { "en", "en-US" } + }; + var found = mapping.FirstOrDefault(kv => string.Equals(kv.Value, current, StringComparison.OrdinalIgnoreCase)).Key; + if (!string.IsNullOrEmpty(found)) selected = found; + + LocalizationProvider.LanguageChanged += (s, c) => + { + var found2 = mapping.FirstOrDefault(kv => string.Equals(kv.Value, c, StringComparison.OrdinalIgnoreCase)).Key; + if (!string.IsNullOrEmpty(found2)) selected = found2; + StateHasChanged(); + }; + } + + private async Task OnChange(ChangeEventArgs e) + { + if (e?.Value is string val) + { + selected = val; + await LocalizationProvider.SetCultureAsync(selected); + + try + { + // Use NavigationManager to inspect and modify the current client-side path + var relative = Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/'); + var segments = string.IsNullOrEmpty(relative) ? Array.Empty() : relative.Split('/', StringSplitOptions.RemoveEmptyEntries); + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "zh", "zh-Hans" }, + { "en", "en-US" } + }; + + string newUrl; + if (segments.Length > 0 && mapping.ContainsKey(segments[0])) + { + // replace existing locale prefix + var remaining = segments.Length > 1 ? "/" + string.Join('/', segments.Skip(1)) : "/"; + newUrl = "/" + selected + remaining; + } + else + { + // keep current path, but trigger remount by navigating to same URI + newUrl = Navigation.Uri; + } + + // trigger client-side navigation (no hard reload) so components remount and re-evaluate localizer + Navigation.NavigateTo(newUrl, forceLoad: false); + return; + } + catch + { + // fallback: just request UI refresh + StateHasChanged(); + } + } + } +} diff --git a/Atomx.Admin/Atomx.Admin.Client/Layout/EmptyLayout.razor b/Atomx.Admin/Atomx.Admin.Client/Layout/EmptyLayout.razor index e1953d3..97b601c 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Layout/EmptyLayout.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Layout/EmptyLayout.razor @@ -1,9 +1,41 @@ @inherits LayoutComponentBase +@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
@Body
@code { + protected override void OnInitialized() + { + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged += OnLanguageChanged; + } + } + private void OnLanguageChanged(object? sender, string culture) + { + _ = InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && LocalizationProvider != null) + { + try + { + await LocalizationProvider.InitializeAsync(); + } + catch { } + } + } + + public void Dispose() + { + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged -= OnLanguageChanged; + } + } } \ No newline at end of file diff --git a/Atomx.Admin/Atomx.Admin.Client/Layout/MainLayout.razor b/Atomx.Admin/Atomx.Admin.Client/Layout/MainLayout.razor index 907969f..e8c7074 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Layout/MainLayout.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Layout/MainLayout.razor @@ -1,5 +1,7 @@ @inherits LayoutComponentBase @inject ILogger _logger +@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider +@inject NavigationManager Navigation @@ -86,10 +88,10 @@ private AvatarMenuItem[] AvatarMenuItems => [ - new() { Key = "center", IconType = "user", Option = "通知消息"}, - new() { Key = "setting", IconType = "setting", Option ="修改资料" }, - new() { IsDivider = true }, - new() { Key = "logout", IconType = "logout", Option = "退出登录"} + new() { Key = "center", IconType = "user", Option = "通知消息" }, + new() { Key = "setting", IconType = "setting", Option = "修改资料" }, + new() { IsDivider = true }, + new() { Key = "logout", IconType = "logout", Option = "退出登录" } ]; public void HandleSelectUser(AntDesign.MenuItem item) @@ -131,5 +133,46 @@ } } + protected override void OnInitialized() + { + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged += OnLanguageChanged; + } + } + private void OnLanguageChanged(object? sender, string culture) + { + // ensure UI updates on the SyncContext; small delay to let cache update + _ = InvokeAsync(async () => + { + await Task.Yield(); + try + { + // Force route and page components to remount and re-render translations + Navigation.NavigateTo(Navigation.Uri, forceLoad: false); + } + catch { } + }); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && LocalizationProvider != null) + { + try + { + await LocalizationProvider.InitializeAsync(); + } + catch { } + } + } + + public void Dispose() + { + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged -= OnLanguageChanged; + } + } } \ No newline at end of file diff --git a/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor b/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor index ef23cb3..dbf5179 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Counter.razor @@ -1,18 +1,87 @@ @page "/counter" +@page "/{locale}/counter" -Counter +@using Microsoft.Extensions.Localization +@inject IStringLocalizer 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 async 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 { } + } + } + + private void OnLanguageChanged(object? sender, string culture) + { + _ = InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged -= OnLanguageChanged; + } + } + 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..f9a2a21 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
- + - + - 忘记密码 + @L["login.forgot"] - 马上注册 + @L["login.register"] - + - 设置开发帐号 + @L["login.setdev"] @@ -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 +
+
} @@ -86,6 +111,11 @@ else { handler = "Server"; } + + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged += OnLanguageChanged; + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -103,6 +133,25 @@ 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() @@ -200,6 +249,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..3dd22f1 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Pages/Weather.razor @@ -1,10 +1,21 @@ @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 +
@if (forecasts == null) { @@ -16,7 +27,7 @@ else Date - Temp. (C) + @L["weather.temperature"] Temp. (F) Summary @@ -36,10 +47,22 @@ else } @code { + [Parameter] + public string Locale { get; set; } = string.Empty; private WeatherForecast[]? forecasts; protected override async 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); @@ -53,6 +76,10 @@ else }).ToArray(); } + private void OnLanguageChanged(object? s, string c) => _ = InvokeAsync(StateHasChanged); + + public void Dispose() { if (LocalizationProvider != null) LocalizationProvider.LanguageChanged -= OnLanguageChanged; } + private class WeatherForecast { public DateOnly Date { get; set; } @@ -60,4 +87,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..9a76367 100644 --- a/Atomx.Admin/Atomx.Admin.Client/Routes.razor +++ b/Atomx.Admin/Atomx.Admin.Client/Routes.razor @@ -1,13 +1,36 @@ - +@using Microsoft.AspNetCore.Components.Routing +@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider + + - - - - - - - + + + + + + + - \ No newline at end of file + + +@code { + private bool _initialized; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && !_initialized) + { + _initialized = true; + try + { + if (LocalizationProvider != null) + { + await LocalizationProvider.InitializeAsync(); + } + } + catch { } + } + } +} \ 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..e5c2c7e --- /dev/null +++ b/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs @@ -0,0 +1,517 @@ +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 + private readonly Dictionary> _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)); + + 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 new file mode 100644 index 0000000..2be76a1 --- /dev/null +++ b/Atomx.Admin/Atomx.Admin.Client/wwwroot/js/common.js @@ -0,0 +1,41 @@ +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) { } + } +}; + +window.getBrowserLanguage = function () { + try { + return (navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage || ''; + } catch (e) { + return ''; + } +}; + +window.setHtmlLang = function (lang) { + try { + if (document && document.documentElement) document.documentElement.lang = lang || ''; + } catch (e) { } +}; + +// 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 @@ - +
+ +
+ + +@code { + protected override void OnInitialized() + { + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged += OnLanguageChanged; + } + } + + private void OnLanguageChanged(object? sender, string culture) + { + _ = InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + if (LocalizationProvider != null) + { + LocalizationProvider.LanguageChanged -= OnLanguageChanged; + } + } +} diff --git a/Atomx.Admin/Atomx.Admin/Program.cs b/Atomx.Admin/Atomx.Admin/Program.cs index fb8643a..8dfa217 100644 --- a/Atomx.Admin/Atomx.Admin/Program.cs +++ b/Atomx.Admin/Atomx.Admin/Program.cs @@ -60,9 +60,11 @@ builder.Services.AddHttpContextAccessor(); // HttpClient ݷ builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost"); - -// עIStringLocalizer -builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>)); +// ע LocalizationProvider Server ģʽʹ +// Use Scoped to avoid consuming scoped services from singleton +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(JsonStringLocalizer<>)); // ӱػ builder.Services.AddLocalization(); @@ -149,6 +151,38 @@ builder.Services.Configure(builder.Configuration.GetSection(" var app = builder.Build(); +// Preload localization files on startup to ensure server-side rendering finds translations +try +{ + using var scope = app.Services.CreateScope(); + var logger = scope.ServiceProvider.GetService()?.CreateLogger("LocalizationStartup"); + var provider = scope.ServiceProvider.GetService(); + if (provider != null) + { + logger?.LogInformation("Preloading localization files for server startup"); + // preload supported cultures + var supported = new[] { "zh-Hans", "en-US" }; + foreach (var c in supported) + { + try + { + provider.LoadCultureAsync(c).GetAwaiter().GetResult(); + logger?.LogInformation("Preloaded localization for {Culture}", c); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to preload localization for {Culture}", c); + } + } + } +} +catch (Exception ex) +{ + // don't block startup + var l = app.Services.GetService()?.CreateLogger("LocalizationStartup"); + l?.LogWarning(ex, "Failed to run localization preload"); +} + app.AddDataMigrate(); // Forwarded headers diff --git a/Atomx.Admin/Atomx.Admin/appsettings.Development.json b/Atomx.Admin/Atomx.Admin/appsettings.Development.json index 0c208ae..34f00ef 100644 --- a/Atomx.Admin/Atomx.Admin/appsettings.Development.json +++ b/Atomx.Admin/Atomx.Admin/appsettings.Development.json @@ -1,8 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Debug", + "Microsoft.AspNetCore": "Information" } } } diff --git a/Atomx.Admin/Atomx.Admin/wwwroot/localization/en-US.json b/Atomx.Admin/Atomx.Admin/wwwroot/localization/en-US.json index d23a24e..3723e3e 100644 --- a/Atomx.Admin/Atomx.Admin/wwwroot/localization/en-US.json +++ b/Atomx.Admin/Atomx.Admin/wwwroot/localization/en-US.json @@ -1,3 +1,19 @@ { - "site.name": "Atomx Studio" + "site.name": "Atomx Studio", + "current.count": "Current count", + "click.me": "Click me", + "login.title": "Login", + "login.account.placeholder": "Account", + "login.password.placeholder": "Password", + "login.forgot": "Forgot password", + "login.register": "Register now", + "login.submit": "Sign in", + "login.setdev": "Set development account", + "copyright": "Copyright © 2025 Atomlust.com All rights reserved.", + "handler.server": "Server", + "handler.wasm": "Wasm", + "weather.title": "Weather", + "weather.summary": "A quick weather overview", + "weather.temperature": "Temperature", + "weather.refresh": "Refresh" } diff --git a/Atomx.Admin/Atomx.Admin/wwwroot/localization/zh-Hans.json b/Atomx.Admin/Atomx.Admin/wwwroot/localization/zh-Hans.json index 366f28c..eeaab86 100644 --- a/Atomx.Admin/Atomx.Admin/wwwroot/localization/zh-Hans.json +++ b/Atomx.Admin/Atomx.Admin/wwwroot/localization/zh-Hans.json @@ -1,3 +1,19 @@ { - "site.name": "Atomx 管理员" + "site.name": "Atomx 管理员", + "current.count": "当前计数", + "click.me": "点击我", + "login.title": "登录", + "login.account.placeholder": "登录账号", + "login.password.placeholder": "登录密码", + "login.forgot": "忘记密码", + "login.register": "马上注册", + "login.submit": "登录", + "login.setdev": "设置开发帐号", + "copyright": "Copyright © 2025-2025 Atomlust.com All rights reserved.", + "handler.server": "Server", + "handler.wasm": "Wasm", + "weather.title": "天气", + "weather.summary": "天气概览", + "weather.temperature": "温度", + "weather.refresh": "刷新" }