Compare commits

...

2 Commits

Author SHA1 Message Date
24512412e5 fix locale 2025-12-09 04:09:33 +08:00
429fb39140 实现多语言切换 2025-12-09 03:31:07 +08:00
19 changed files with 1334 additions and 61 deletions

View File

@@ -25,4 +25,8 @@
<ProjectReference Include="..\..\Atomx.Common\Atomx.Common.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\localization\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,82 @@
@inject ILocalizationProvider LocalizationProvider
@inject NavigationManager Navigation
@inject IJSRuntime JS
<select @onchange="OnChange" value="@selected">
@foreach (var item in options)
{
<option value="@item.Key">@item.Value</option>
}
</select>
@code {
private string selected = "zh";
private readonly Dictionary<string, string> options = new(StringComparer.OrdinalIgnoreCase)
{
{ "zh", "<22><><EFBFBD><EFBFBD>" },
{ "en", "English" }
};
protected override async Task OnInitializedAsync()
{
var current = LocalizationProvider.CurrentCulture;
var mapping = new Dictionary<string, string>(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<string>() : relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
var mapping = new Dictionary<string, string>(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();
}
}
}
}

View File

@@ -1,9 +1,41 @@
@inherits LayoutComponentBase
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
<div style="min-height:100vh">
@Body
</div>
@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;
}
}
}

View File

@@ -1,5 +1,7 @@
@inherits LayoutComponentBase
@inject ILogger<MainLayout> _logger
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
@inject NavigationManager Navigation
<ErrorBoundary>
<ChildContent>
@@ -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;
}
}
}

View File

@@ -1,18 +1,69 @@
@page "/counter"
@page "/{locale}/counter"
<PageTitle>Counter</PageTitle>
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Counter> L
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
<h1>Counter</h1>
<PageTitle>@L["site.name"]</PageTitle>
<p role="status">Current count: @currentCount</p>
<h1>@L["site.name"]</h1>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
<p role="status">@(L["current.count"] ?? "Current count"): @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">@(L["click.me"] ?? "Click me")</button>
<Atomx.Admin.Client.Components.LangSelector />
<div style="margin-top:16px;">
<strong>Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/account/login">Login</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/weather">Weather</NavLink></span>
</div>
<div style="margin-top:16px;">
<strong>zh Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/zh/account/login">Login</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/zh/weather">Weather</NavLink></span>
</div>
<div style="margin-top:16px;">
<strong>en Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/en/account/login">Login</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/en/weather">Weather</NavLink></span>
</div>
@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;
}
}

View File

@@ -0,0 +1,123 @@
@page "/debug/localization"
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
@inject IHttpClientFactory HttpClientFactory
@inject IJSRuntime JS
@inject ILogger<DebugLocalization> Logger
@inject HttpClient Http
@inject NavigationManager Nav
<h3>Localization Debug</h3>
<p>CurrentCulture: <b>@LocalizationProvider.CurrentCulture</b></p>
<button @onclick="SetProviderZh">Set culture zh</button>
<button @onclick="SetProviderEn">Set culture en</button>
<button @onclick="FetchFiles">Fetch files via HttpClient</button>
<button @onclick="ReadCookie">Read cookie</button>
<h4>Provider Results</h4>
<ul>
@foreach (var kv in providerResults)
{
<li>@kv</li>
}
</ul>
<h4>HTTP Fetch Results</h4>
<ul>
@foreach (var kv in httpResults)
{
<li>@kv</li>
}
</ul>
<h4>JS Read</h4>
<p>@jsResult</p>
@code {
private List<string> providerResults = new();
private List<string> 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<string>("CookieReader.Read", "atomx.culture");
}
catch (Exception ex)
{
jsResult = "JS error: " + ex.Message;
}
}
}

View File

@@ -4,8 +4,11 @@
@layout EmptyLayout
@inject ILogger<Login> Logger
@inject IJSRuntime JS
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Login> L
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
<PageTitle>登录</PageTitle>
<PageTitle>@L["login.title"]</PageTitle>
@if (!dataLoaded)
{
@@ -20,29 +23,29 @@ else
<Form @ref="form" Model="@login" OnFinish="LoginAsync">
<FluentValidationValidator />
<FormItem>
<AntDesign.Input Placeholder="登录账号" Size="InputSize.Large" @bind-Value="@login.Account">
<AntDesign.Input Placeholder="@L["login.account.placeholder"]" Size="InputSize.Large" @bind-Value="@login.Account">
<Prefix><Icon Type="user" /></Prefix>
</AntDesign.Input>
</FormItem>
<FormItem>
<AntDesign.Input Placeholder="登录密码" Size="InputSize.Large" @bind-Value="@login.Password" Type="InputType.Password">
<AntDesign.Input Placeholder="@L["login.password.placeholder"]" Size="InputSize.Large" @bind-Value="@login.Password" Type="InputType.Password">
<Prefix><Icon Type="lock" /></Prefix>
</AntDesign.Input>
</FormItem>
<FormItem>
<a style="float: left;">
忘记密码
@L["login.forgot"]
</a>
<a style="float: right;">
<NavLink href="/register">马上注册</NavLink>
<NavLink href="/register">@L["login.register"]</NavLink>
</a>
</FormItem>
<FormItem>
<Button Type="ButtonType.Primary" HtmlType="submit" Class="submit" Size="ButtonSize.Large" Block>登录</Button>
<Button Type="ButtonType.Primary" HtmlType="submit" Class="submit" Size="ButtonSize.Large" Block>@L["login.submit"]</Button>
</FormItem>
<FormItem>
<a @onclick="setAccount">
设置开发帐号
@L["login.setdev"]
</a>
</FormItem>
</Form>
@@ -50,9 +53,31 @@ else
</GridCol>
</GridRow>
<GridRow Style="padding-top:40px">
<span style="font-size:12px;padding-right:3px;">Copyright © 2025-@DateTime.Now.Year Atomlust.com All rights reserved.</span>
<span style="font-size:12px;padding-right:3px;">@L["copyright"]</span>
<span style="font-size:12px">runing as @handler</span>
</GridRow>
<GridRow>
<Atomx.Admin.Client.Components.LangSelector />
</GridRow>
<GridRow Style="padding-top:10px">
<div>
<strong>Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/counter">Counter</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/weather">Weather</NavLink></span>
</div>
<div>
<strong>zh Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/zh/counter">Counter</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/zh/weather">Weather</NavLink></span>
</div>
<div>
<strong>en Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/en/counter">Counter</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/en/weather">Weather</NavLink></span>
</div>
</GridRow>
</Flex>
}
@@ -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 *@

View File

@@ -1,10 +1,33 @@
@page "/weather"
@page "/{locale}/weather"
<PageTitle>Weather</PageTitle>
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Weather> L
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
<h1>Weather</h1>
<PageTitle>@L["weather.title"]</PageTitle>
<p>This component demonstrates showing data.</p>
<h1>@L["weather.title"]</h1>
<p>@L["weather.summary"]</p>
<div style="margin-top:12px; margin-bottom:12px;">
<strong>Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/account/login">Login</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/counter">Counter</NavLink></span>
</div>
<div style="margin-top:12px; margin-bottom:12px;">
<strong>zh Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/zh/account/login">Login</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/zh/counter">Counter</NavLink></span>
</div>
<div style="margin-top:12px; margin-bottom:12px;">
<strong>en Quick links:</strong>
<span style="padding-left:10px;"><NavLink href="/en/account/login">Login</NavLink></span>
<span style="padding-left:10px;"><NavLink href="/en/counter">Counter</NavLink></span>
</div>
@if (forecasts == null)
{
@@ -16,7 +39,7 @@ else
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Celsius">@L["weather.temperature"]</th>
<th aria-label="Temperature in Farenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
@@ -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;
}
}

View File

@@ -26,8 +26,14 @@ builder.Services.AddScoped<IconsExtension>();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// ע<><D7A2>IStringLocalizer
builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
// ע<><D7A2> LocalizationProvider (<28><><EFBFBD><EFBFBD> WASM)
// Use Scoped lifetime because LocalizationProvider depends on IJSRuntime (scoped) and IHttpClient etc.
builder.Services.AddScoped<ILocalizationProvider, LocalizationProvider>();
// ע<><D7A2> ILocalizationService <20><><EFBFBD><EFBFBD>ͬ<EFBFBD><CDAC> Culture <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><E4B4AB>
builder.Services.AddScoped<ILocalizationService, LocalizationService>();
// ע<><D7A2> IStringLocalizer<T> ʵ<><CAB5>
builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(JsonStringLocalizer<>));
// <20><><EFBFBD>ӱ<EFBFBD><D3B1>ػ<EFBFBD><D8BB><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddLocalization();
@@ -64,4 +70,6 @@ builder.Services.AddScoped<HttpService>(sp =>
builder.Services.AddAntDesign();
await builder.Build().RunAsync();
var host = builder.Build();
await host.RunAsync();

View File

@@ -1,13 +1,129 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<CascadingValue Value="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
</CascadingValue>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@using Microsoft.AspNetCore.Components.Routing
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
@inject NavigationManager Navigation
<div @key="LocalizationProvider.CurrentCulture">
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<CascadingValue Value="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
</CascadingValue>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</div>
<AntContainer />
@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<string>() : relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
var first = segments.FirstOrDefault();
var mapping = new Dictionary<string, string>(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<string>() : relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
var first = segments.FirstOrDefault();
if (string.IsNullOrEmpty(first)) return;
var mapping = new Dictionary<string, string>(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;
}
}

View File

@@ -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
{
/// <summary>
/// <20><EFBFBD><E1B9A9><EFBFBD><EFBFBD><EFBFBD><EFBFBD> JSON <20>ļ<EFBFBD><C4BC><EFBFBD><EFBFBD>ء<EFBFBD><D8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʱ<EFBFBD>л<EFBFBD><D0BB><EFBFBD>ʵ<EFBFBD>֡<EFBFBD>
/// - <20><> Server <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͨ<EFBFBD><CDA8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> IWebHostEnvironment <20><> webroot<6F><74><EFBFBD><EFBFBD><EFBFBD>ļ<EFBFBD>ϵͳ<CFB5><CDB3>ȡ {culture}.json <20>ļ<EFBFBD><C4BC><EFBFBD>
/// - <20><> WASM <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʹ<EFBFBD><CAB9>ע<EFBFBD><D7A2><EFBFBD><EFBFBD> HttpClient <20><> /localization/{culture}.json <20><><EFBFBD>ز<EFBFBD><D8B2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
/// ͬʱ<CDAC><CAB1><EFBFBD>л<EFBFBD><D0BB><EFBFBD><EFBFBD><EFBFBD>ʱд<CAB1><D0B4> Cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ҳ<EFBFBD><D2B3> HTML lang <20><><EFBFBD>ԡ<EFBFBD>
/// </summary>
public interface ILocalizationProvider
{
string CurrentCulture { get; }
string? GetString(string key);
Task SetCultureAsync(string cultureShortOrFull);
Task InitializeAsync();
/// <summary>
/// <20><>ָ֤<D6A4><D6B8><EFBFBD>Ļ<EFBFBD><C4BB><EFBFBD><EFBFBD><EFBFBD>Դ<EFBFBD>ѱ<EFBFBD><D1B1><EFBFBD><EFBFBD>أ<EFBFBD><D8A3><EFBFBD><ECB2BD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ⲿ<EFBFBD><E2B2BF><EFBFBD><EFBFBD>Ҫʱ<D2AA><CAB1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ء<EFBFBD>
/// </summary>
Task LoadCultureAsync(string culture);
event EventHandler<string>? LanguageChanged;
}
public class LocalizationProvider : ILocalizationProvider
{
private readonly IServiceProvider _sp;
private readonly IHttpClientFactory? _httpClientFactory;
private readonly IJSRuntime? _jsRuntime;
private readonly ILogger<LocalizationProvider> _logger;
private readonly ILocalizationService _localizationService;
// <20><><EFBFBD>棺culture -> translations
// 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<string, Dictionary<string, string>> _cache = new();
// <20><><EFBFBD><EFBFBD><EBB5BD><EFBFBD><EFBFBD> culture <20><>ӳ<EFBFBD><D3B3>
private static readonly Dictionary<string, string> ShortToCulture = new(StringComparer.OrdinalIgnoreCase)
{
{ "zh", "zh-Hans" },
{ "en", "en-US" }
};
private string _currentCulture = "zh-Hans";
private const string CookieName = "atomx.culture";
public LocalizationProvider(IServiceProvider sp, IHttpClientFactory? httpClientFactory, IJSRuntime? jsRuntime, ILogger<LocalizationProvider> logger, ILocalizationService localizationService)
{
_sp = sp;
_httpClientFactory = httpClientFactory;
_jsRuntime = jsRuntime;
_logger = logger;
_localizationService = localizationService;
// <20><><EFBFBD>ڹ<EFBFBD><DAB9><EFBFBD><ECBAAF><EFBFBD>н<EFBFBD><D0BD><EFBFBD> JS <20><><EFBFBD><EFBFBD>ͬ<EFBFBD><CDAC> IO<49><4F><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ը<EFBFBD><D4B8><EFBFBD><EFBFBD>߳<EFBFBD> culture <20><><EFBFBD>ó<EFBFBD>ʼֵ<CABC><D6B5><EFBFBD><EFBFBD>һ<EFBFBD><D2BB>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD> culture
try
{
var threadUi = CultureInfo.DefaultThreadCurrentUICulture ?? CultureInfo.CurrentUICulture;
if (!string.IsNullOrEmpty(threadUi?.Name))
{
_currentCulture = MapToFullCulture(threadUi!.Name);
_logger?.LogDebug("LocalizationProvider ctor detected thread UI culture: {Culture}", _currentCulture);
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "LocalizationProvider ctor failed to read thread culture");
}
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Server<65><72>IWebHostEnvironment <20><><EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD> JSRuntime <20><><EFBFBD><EFBFBD><EFBFBD>ã<EFBFBD>˵<EFBFBD><CBB5><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͬ<EFBFBD><CDAC><EFBFBD><EFBFBD><EFBFBD>ļ<EFBFBD><C4BC><EFBFBD><EFBFBD>ر<EFBFBD><D8B1>ػ<EFBFBD><D8BB>ļ<EFBFBD>
try
{
var envType = Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment, Microsoft.AspNetCore.Hosting.Abstractions")
?? Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment");
if (envType != null)
{
var env = _sp.GetService(envType);
if (env != null && _jsRuntime == null)
{
var webRootProp = envType.GetProperty("WebRootPath");
var contentRootProp = envType.GetProperty("ContentRootPath");
var webRoot = webRootProp?.GetValue(env) as string ?? Path.Combine(contentRootProp?.GetValue(env) as string ?? ".", "wwwroot");
var path = Path.Combine(webRoot ?? ".", "localization", _currentCulture + ".json");
if (File.Exists(path))
{
try
{
var json = File.ReadAllText(path);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
_cache[_currentCulture] = dict;
_logger?.LogInformation("Loaded localization file for {Culture} from path {Path}, entries: {Count}", _currentCulture, path, dict.Count);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to read localization file synchronously: {Path}", path);
}
}
else
{
_logger?.LogDebug("Localization file not found at {Path}", path);
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "Synchronous file load attempt failed in ctor");
}
}
public string CurrentCulture => _currentCulture;
public event EventHandler<string>? LanguageChanged;
public string? GetString(string key)
{
if (string.IsNullOrEmpty(key)) return null;
if (_cache.TryGetValue(_currentCulture, out var dict) && dict.TryGetValue(key, out var val))
{
return val;
}
return null;
}
public async Task InitializeAsync()
{
_logger?.LogDebug("LocalizationProvider.InitializeAsync start. CurrentCulture={Culture}", _currentCulture);
string? urlFirstSegment = null;
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var path = await _jsRuntime.InvokeAsync<string>("eval", "location.pathname");
_logger?.LogDebug("JS location.pathname='{Path}'", path);
if (!string.IsNullOrEmpty(path))
{
var trimmed = path.Trim('/');
if (!string.IsNullOrEmpty(trimmed))
{
var seg = trimmed.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
urlFirstSegment = seg;
_logger?.LogDebug("Detected url first segment: {Segment}", urlFirstSegment);
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "<22><>ȡ location.pathname ʧ<><CAA7>");
}
if (!string.IsNullOrEmpty(urlFirstSegment) && ShortToCulture.TryGetValue(urlFirstSegment, out var mapped))
{
_logger?.LogDebug("URL short segment '{Seg}' mapped to culture '{Culture}'", urlFirstSegment, mapped);
await SetCultureInternalAsync(mapped, persistCookie: false);
return;
}
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var cookieVal = await _jsRuntime.InvokeAsync<string>("CookieReader.Read", CookieName);
_logger?.LogDebug("Cookie '{CookieName}'='{CookieVal}'", CookieName, cookieVal);
if (!string.IsNullOrEmpty(cookieVal))
{
if (ShortToCulture.TryGetValue(cookieVal, out var mappedFromCookie))
{
_logger?.LogDebug("Cookie short '{Cookie}' mapped to {Culture}", cookieVal, mappedFromCookie);
await SetCultureInternalAsync(mappedFromCookie, persistCookie: false);
return;
}
else
{
// <20><><EFBFBD><EFBFBD> cookie <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> culture<72><65><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʽ<EFBFBD><CABD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>һ<EFBFBD><D2BB><EFBFBD><EFBFBD>ʹ<EFBFBD><CAB9>
await SetCultureInternalAsync(MapToFullCulture(cookieVal), persistCookie: false);
return;
}
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "<22><>ȡ Cookie ʧ<><CAA7>");
}
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var browserLang = await _jsRuntime.InvokeAsync<string>("getBrowserLanguage");
_logger?.LogDebug("Browser language: {BrowserLang}", browserLang);
if (!string.IsNullOrEmpty(browserLang))
{
var mappedFromBrowser = MapToFullCulture(browserLang);
_logger?.LogDebug("Browser mapped to {Culture}", mappedFromBrowser);
await SetCultureInternalAsync(mappedFromBrowser, persistCookie: false);
return;
}
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "<22><>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʧ<EFBFBD><CAA7>");
}
// <20><><EFBFBD><EFBFBD>ȷ<EFBFBD><C8B7><EFBFBD><EFBFBD><EFBFBD>ص<EFBFBD>ǰ culture
_logger?.LogDebug("InitializeAsync falling back to current culture {Culture}", _currentCulture);
await EnsureCultureLoadedAsync(_currentCulture);
await SetCultureInternalAsync(_currentCulture, persistCookie: false);
}
public async Task SetCultureAsync(string cultureShortOrFull)
{
if (string.IsNullOrEmpty(cultureShortOrFull)) return;
if (ShortToCulture.TryGetValue(cultureShortOrFull, out var mapped))
{
await SetCultureInternalAsync(mapped, persistCookie: true);
}
else
{
await SetCultureInternalAsync(MapToFullCulture(cultureShortOrFull), persistCookie: true);
}
}
public Task LoadCultureAsync(string culture) => EnsureCultureLoadedAsync(MapToFullCulture(culture));
/// <summary>
/// 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.
/// </summary>
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<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
_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, "<22><><EFBFBD><EFBFBD> Culture ʧ<><CAA7>: {Culture}", cultureFull);
}
if (persistCookie && _jsRuntime != null)
{
try
{
var shortKey = ShortToCulture.FirstOrDefault(kv => string.Equals(kv.Value, cultureFull, StringComparison.OrdinalIgnoreCase)).Key ?? cultureFull;
await _jsRuntime.InvokeVoidAsync("cookies.Write", CookieName, shortKey, DateTime.UtcNow.AddYears(1).ToString("o"));
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "д Cookie ʧ<><CAA7>");
}
}
try
{
if (_jsRuntime != null)
{
await _jsRuntime.InvokeVoidAsync("setHtmlLang", cultureFull);
}
}
catch { }
LanguageChanged?.Invoke(this, cultureFull);
}
private async Task EnsureCultureLoadedAsync(string cultureFull)
{
// Normalize possible short codes (e.g. zh -> zh-Hans, en -> en-US) and variants (zh-CN -> zh-Hans)
cultureFull = MapToFullCulture(cultureFull);
if (string.IsNullOrEmpty(cultureFull)) return;
if (_cache.ContainsKey(cultureFull))
{
_logger?.LogDebug("EnsureCultureLoadedAsync: culture {Culture} already cached", cultureFull);
return;
}
// Prefer HttpClient when running in browser (WASM)
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
_logger?.LogInformation("EnsureCultureLoadedAsync: running in browser, will attempt HttpClient for {Culture}", cultureFull);
try
{
var http = _sp.GetService(typeof(HttpClient)) as HttpClient;
if (http == null && _httpClientFactory != null)
{
_logger?.LogDebug("HttpClient not found from service provider, using factory");
http = _httpClientFactory.CreateClient();
}
else
{
_logger?.LogDebug("HttpClient resolved from service provider: {HasClient}", http != null);
}
if (http != null)
{
var url = $"/localization/{cultureFull}.json";
Uri? requestUri = null;
// If HttpClient has a BaseAddress, use it. Otherwise, if running in browser, build absolute URI from location.origin
if (http.BaseAddress != null)
{
requestUri = new Uri(http.BaseAddress, url);
}
else if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
try
{
var origin = await _jsRuntime.InvokeAsync<string>("eval", "location.origin");
if (!string.IsNullOrEmpty(origin))
{
// ensure no double slashes
requestUri = new Uri(new Uri(origin), url);
}
}
catch (Exception jsEx)
{
_logger?.LogDebug(jsEx, "Failed to get location.origin from JS");
}
}
if (requestUri != null)
{
_logger?.LogInformation("Downloading localization from {Url}", requestUri);
var txt = await http.GetStringAsync(requestUri);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(txt) ?? new Dictionary<string, string>();
_cache[cultureFull] = dict;
_logger?.LogInformation("Loaded localization via HttpClient for {Culture}, entries: {Count}", cultureFull, dict.Count);
return;
}
else
{
_logger?.LogWarning("HttpClient has no BaseAddress and JSRuntime unavailable to construct absolute URL; skipping HttpClient load for {Culture}", cultureFull);
}
}
else
{
_logger?.LogWarning("No HttpClient available to load localization for {Culture}", cultureFull);
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, <><CDA8> HttpClient <20><><EFBFBD>ر<EFBFBD><D8B1>ػ<EFBFBD><D8BB>ļ<EFBFBD>ʧ<EFBFBD><CAA7>: {Culture}", cultureFull);
}
}
_logger?.LogDebug("EnsureCultureLoadedAsync trying filesystem for {Culture}", cultureFull);
// <20><><EFBFBD><EFBFBD>ͨ<EFBFBD><CDA8><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȡ IWebHostEnvironment<6E><74>Server ʱ<><CAB1><EFBFBD>ã<EFBFBD>
try
{
var envType = Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment, Microsoft.AspNetCore.Hosting.Abstractions")
?? Type.GetType("Microsoft.AspNetCore.Hosting.IWebHostEnvironment");
if (envType != null)
{
var env = _sp.GetService(envType);
if (env != null)
{
var webRootProp = envType.GetProperty("WebRootPath");
var contentRootProp = envType.GetProperty("ContentRootPath");
var webRoot = webRootProp?.GetValue(env) as string ?? Path.Combine(contentRootProp?.GetValue(env) as string ?? ".", "wwwroot");
var path = Path.Combine(webRoot ?? ".", "localization", cultureFull + ".json");
_logger?.LogDebug("Looking for localization file at {Path}", path);
if (File.Exists(path))
{
var json = await File.ReadAllTextAsync(path);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
_cache[cultureFull] = dict;
_logger?.LogInformation("Loaded localization from file for {Culture}, entries: {Count}", cultureFull, dict.Count);
return;
}
else
{
_logger?.LogDebug("Localization file not found at {Path}", path);
// Fallback: check build output wwwroot under AppContext.BaseDirectory
try
{
var alt = Path.Combine(AppContext.BaseDirectory ?? ".", "wwwroot", "localization", cultureFull + ".json");
_logger?.LogDebug("Looking for localization file at alternative path {AltPath}", alt);
if (File.Exists(alt))
{
var json2 = await File.ReadAllTextAsync(alt);
var dict2 = JsonSerializer.Deserialize<Dictionary<string, string>>(json2) ?? new Dictionary<string, string>();
_cache[cultureFull] = dict2;
_logger?.LogInformation("Loaded localization from alternative file path for {Culture}, entries: {Count}", cultureFull, dict2.Count);
return;
}
else
{
_logger?.LogDebug("Localization file not found at alternative path {AltPath}", alt);
}
}
catch (Exception exAlt)
{
_logger?.LogDebug(exAlt, "Error while checking alternative localization path");
}
}
}
else
{
_logger?.LogDebug("IWebHostEnvironment not resolved from service provider");
}
}
else
{
_logger?.LogDebug("IWebHostEnvironment type not found via reflection");
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "<22><><EFBFBD>ļ<EFBFBD>ϵͳ<CFB5><CDB3><EFBFBD>ر<EFBFBD><D8B1>ػ<EFBFBD><D8BB>ļ<EFBFBD>ʧ<EFBFBD><CAA7>: {Culture}", cultureFull);
}
_logger?.LogDebug("EnsureCultureLoadedAsync fallback to empty dict for {Culture}", cultureFull);
_cache[cultureFull] = new Dictionary<string, string>();
}
private string MapToFullCulture(string culture)
{
if (string.IsNullOrEmpty(culture)) return culture;
// direct mapping
if (ShortToCulture.TryGetValue(culture, out var mapped)) return mapped;
// consider prefix, e.g. zh-CN -> zh
var prefix = culture.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrEmpty(prefix) && ShortToCulture.TryGetValue(prefix, out var mapped2)) return mapped2;
return culture;
}
}
/// <summary>
/// <20><><EFBFBD><EFBFBD> ILocalizationProvider <20><> IStringLocalizer ʵ<>֣<EFBFBD>
/// ʹ<><CAB9> JSON <20>ļ<EFBFBD><C4BC>еļ<D0B5>ֵ<EFBFBD><D6B5>δ<EFBFBD>ҵ<EFBFBD><D2B5><EFBFBD><EFBFBD><EFBFBD> key <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
/// <20><><EFBFBD>Ƹ<EFBFBD>Ϊ JsonStringLocalizer <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܵ<EFBFBD> StringLocalizer <20><>ͻ<EFBFBD><CDBB>
/// </summary>
public class JsonStringLocalizer<T> : IStringLocalizer<T>
{
private readonly ILocalizationProvider _provider;
public JsonStringLocalizer(ILocalizationProvider provider)
{
_provider = provider;
}
public LocalizedString this[string name]
{
get
{
var value = _provider.GetString(name);
if (value == null)
{
// Avoid synchronous blocking during server prerender. Start background load and return key.
try
{
_ = _provider.LoadCultureAsync(_provider.CurrentCulture);
}
catch { }
}
var result = value ?? name;
return new LocalizedString(name, result, resourceNotFound: result == name);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
var fmt = _provider.GetString(name);
if (fmt == null)
{
try
{
_ = _provider.LoadCultureAsync(_provider.CurrentCulture);
}
catch { }
}
var format = fmt ?? name;
var value = string.Format(format, arguments);
return new LocalizedString(name, value, resourceNotFound: format == name);
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
var list = new List<LocalizedString>();
var providerType = _provider.GetType();
var currentProp = providerType.GetProperty("CurrentCulture");
var culture = currentProp?.GetValue(_provider) as string ?? string.Empty;
var cacheField = providerType.GetField("_cache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (!string.IsNullOrEmpty(culture) && cacheField?.GetValue(_provider) is Dictionary<string, Dictionary<string, string>> cache && cache.TryGetValue(culture, out var dict))
{
foreach (var kv in dict)
{
list.Add(new LocalizedString(kv.Key, kv.Value, resourceNotFound: false));
}
}
return list;
}
public IStringLocalizer WithCulture(CultureInfo culture)
{
return this;
}
}
}

View File

@@ -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);
}
};

View File

@@ -43,4 +43,8 @@
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\js\" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
@using Atomx.Admin.Client.Services
@inject Atomx.Admin.Client.Services.ILocalizationProvider LocalizationProvider
<!DOCTYPE html>
<html lang="en">
@@ -7,7 +8,6 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
@* <link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" /> *@
<link rel="stylesheet" href="@Assets["_content/AntDesign/css/ant-design-blazor.variable.css"] " />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Atomx.Admin.styles.css"]" />
@@ -17,9 +17,35 @@
</head>
<body>
<Routes @rendermode="InteractiveAuto" />
<div @key="LocalizationProvider.CurrentCulture">
<Routes @rendermode="InteractiveAuto" />
</div>
<script src="/js/common.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@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;
}
}
}

View File

@@ -26,24 +26,55 @@ namespace Atomx.Admin.Middlewares
public async Task InvokeAsync(HttpContext context)
{
var logger = context.RequestServices.GetService(typeof(ILogger<RequestCultureMiddleware>)) as ILogger<RequestCultureMiddleware>;
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);
}
}

View File

@@ -60,9 +60,11 @@ builder.Services.AddHttpContextAccessor();
// HttpClient <20><><EFBFBD><EFBFBD><EFBFBD>ݷ<EFBFBD><DDB7><EFBFBD>
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost");
// ע<EFBFBD><EFBFBD>IStringLocalizer
builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
// ע<><D7A2> LocalizationProvider <20><> Server ģʽ<C4A3><CABD>ʹ<EFBFBD><CAB9>
// Use Scoped to avoid consuming scoped services from singleton
builder.Services.AddScoped<ILocalizationProvider, LocalizationProvider>();
builder.Services.AddScoped<ILocalizationService, LocalizationService>();
builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(JsonStringLocalizer<>));
// <20><><EFBFBD>ӱ<EFBFBD><D3B1>ػ<EFBFBD><D8BB><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddLocalization();
@@ -149,6 +151,38 @@ builder.Services.Configure<MonitoringOptions>(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<ILoggerFactory>()?.CreateLogger("LocalizationStartup");
var provider = scope.ServiceProvider.GetService<ILocalizationProvider>();
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<ILoggerFactory>()?.CreateLogger("LocalizationStartup");
l?.LogWarning(ex, "Failed to run localization preload");
}
app.AddDataMigrate();
// Forwarded headers<72><73><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
@@ -173,8 +207,8 @@ app.UseResponseCompression();
// ʹ<><CAB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> CORS <20><><EFBFBD><EFBFBD>
app.UseCors("DefaultCors");
//// ע<><D7A2> RequestCultureMiddleware<72><65>ʹ<EFBFBD>ⲿ<EFBFBD><E2B2BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Culture <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ǰ׺
//app.UseMiddleware<RequestCultureMiddleware>();
// ע<><D7A2> RequestCultureMiddleware<72><65>ʹ<EFBFBD>ⲿ<EFBFBD><E2B2BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Culture <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ǰ׺
app.UseMiddleware<RequestCultureMiddleware>();
app.UseStaticFiles(new StaticFileOptions

View File

@@ -1,8 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}

View File

@@ -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"
}

View File

@@ -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": "刷新"
}