This commit is contained in:
yxw
2025-12-07 18:31:58 +08:00
parent d91954e331
commit 9631e00a12
12 changed files with 60 additions and 464 deletions

View File

@@ -1,61 +0,0 @@
@inject LanguageProvider LanguageProvider
@inject IStringLocalizer<LanguageSelector> Localizer
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@GetLanguageDisplayName(LanguageProvider.CurrentLanguage)
</button>
<ul class="dropdown-menu">
@foreach (var language in LanguageProvider.SupportedLanguages)
{
<li>
<button class="dropdown-item @(language == LanguageProvider.CurrentLanguage ? "active" : "")"
@onclick="() => ChangeLanguage(language)"
type="button">
@GetLanguageDisplayName(language)
@if (language == LanguageProvider.CurrentLanguage)
{
<span class="badge bg-primary">✓</span>
}
</button>
</li>
}
</ul>
</div>
@code {
protected override void OnInitialized()
{
// 订阅变更以便在语言切换时即时更新该组件
LanguageProvider.OnLanguageChanged += OnLanguageChanged;
}
private void OnLanguageChanged()
{
// 在 UI 线程上下文中触发 StateHasChanged
_ = InvokeAsync(StateHasChanged);
}
public void Dispose()
{
LanguageProvider.OnLanguageChanged -= OnLanguageChanged;
}
private string GetLanguageDisplayName(string languageCode)
{
return languageCode switch
{
"zh-Hans" => "简体中文",
"en-US" => "English (US)",
_ => languageCode
};
}
private async Task ChangeLanguage(string languageCode)
{
await LanguageProvider.ChangeLanguageAsync(languageCode);
}
}

View File

@@ -1,9 +1,9 @@
@page "/account/login"
@page "/{locale}/account/login"
@using System.Text.Json
@layout EmptyLayout
@inject ILogger<Login> Logger
@inject IJSRuntime JS
@inject IStringLocalizer<Login> Localizer
<PageTitle>登录</PageTitle>
@@ -13,10 +13,6 @@
}
else
{
<LanguageSelector></LanguageSelector>
<p>
@LanguageProvider.CurrentLanguage 网站名称 @Localizer["site.name"]
</p>
<Flex Style="height:100vh" Justify="FlexJustify.Center" Align="FlexAlign.Center" Direction="FlexDirection.Vertical">
<GridRow Justify="RowJustify.Center" Class="">
<GridCol>
@@ -63,6 +59,9 @@ else
@code {
string handler = "Server";
[Parameter]
public string Locale { get; set; } = string.Empty;
[Parameter]
[SupplyParameterFromQuery(Name = "ReturnUrl")]
public string? ReturnUrl { get; set; }

View File

@@ -25,17 +25,12 @@ builder.Services.AddScoped<IconsExtension>();
// <20><> HttpClient <20><><EFBFBD>ڼ<EFBFBD><DABC><EFBFBD> wwwroot/Localization/{culture}.json <20>Ⱦ<EFBFBD>̬<EFBFBD><CCAC><EFBFBD><EFBFBD><EFBFBD>ļ<EFBFBD>
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// ע<><EFBFBD>ػ<EFBFBD><D8BB><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʹ<EFBFBD><CAB9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>վ<EFBFBD><D5BE><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ŀ¼<C4BF><C2BC> "Localization" <20><>ע<EFBFBD><D7A2><EFBFBD><EFBFBD><EFBFBD>ڼ<EFBFBD><DABC>ؾ<EFBFBD>̬<EFBFBD>ļ<EFBFBD><C4BC><EFBFBD> HttpClient<6E><74>
builder.Services.AddScoped<IStringLocalizerFactory>(sp =>
new JsonStringLocalizerFactory("localization", sp.GetRequiredService<HttpClient>()));
// ע<><D7A2>IStringLocalizer
builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
// <20><><EFBFBD>ӱ<EFBFBD><D3B1>ػ<EFBFBD><D8BB><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddLocalization();
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><E1B9A9>
builder.Services.AddScoped<LanguageProvider>();
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Զ<EFBFBD><D4B6><EFBFBD><EFBFBD><EFBFBD> token & ˢ<>µ<EFBFBD> DelegatingHandler
builder.Services.AddScoped<AuthHeaderHandler>();

View File

@@ -1,141 +0,0 @@
using Microsoft.Extensions.Localization;
using System.Globalization;
using System.Net.Http;
using System.Text.Json;
namespace Atomx.Admin.Client.Services
{
public class JsonStringLocalizer : IStringLocalizer
{
private readonly string _resourcesPath;
private readonly Dictionary<string, Dictionary<string, string>> _resourcesCache = new();
private readonly object _lock = new();
private readonly HttpClient? _httpClient;
// resourcesPath 应为相对于站点根的“目录”名称,例如 "Localization"
// 在 Blazor WebAssembly 场景下,会使用注入的 HttpClient 从 wwwroot/Localization/{culture}.json 获取资源
public JsonStringLocalizer(string resourcesPath, HttpClient? httpClient = null)
{
_resourcesPath = (resourcesPath ?? "localization").Trim('/'); // 规范化
_httpClient = httpClient;
}
public LocalizedString this[string name]
{
get
{
var value = GetString(name);
return new LocalizedString(name, value ?? name, resourceNotFound: value == null);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
var format = GetString(name);
var value = string.Format(format ?? name, arguments);
return new LocalizedString(name, value, resourceNotFound: format == null);
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
var culture = CultureInfo.CurrentUICulture.Name;
var resources = LoadResources(culture);
return resources.Select(r => new LocalizedString(r.Key, r.Value, false));
}
private string? GetString(string name)
{
var culture = CultureInfo.CurrentUICulture.Name;
var resources = LoadResources(culture);
// 尝试当前文化
if (resources.TryGetValue(name, out var value))
return value;
// 如果还找不到,尝试英文作为后备
if (culture != "en-US")
{
var enResources = LoadResources("en-US");
if (enResources.TryGetValue(name, out var enValue))
return enValue;
}
return null;
}
private Dictionary<string, string> LoadResources(string culture)
{
lock (_lock)
{
if (_resourcesCache.TryGetValue(culture, out var cachedResources))
return cachedResources;
var resources = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var fileName = $"{culture}.json";
// 在浏览器WASM环境下通过 HttpClient 从静态资源目录获取
if (OperatingSystem.IsBrowser() && _httpClient != null)
{
try
{
// 构造相对 URL例如 "Localization/zh-Hans.json"
var relativeUrl = $"{_resourcesPath}/{fileName}";
var json = _httpClient.GetStringAsync(relativeUrl).ConfigureAwait(false).GetAwaiter().GetResult();
var jsonResources = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
if (jsonResources != null)
{
foreach (var item in jsonResources)
{
resources[item.Key] = item.Value;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading localization file {fileName} via HttpClient: {ex.Message}");
}
_resourcesCache[culture] = resources;
return resources;
}
// 非浏览器(例如 Server 或在 prerender 阶段)尝试从文件系统读取。
// 尝试几种可能的路径:基路径为 AppContext.BaseDirectory 或当前工作目录,或直接使用传入的路径。
try
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, _resourcesPath, fileName),
Path.Combine(Directory.GetCurrentDirectory(), _resourcesPath, fileName),
Path.Combine(_resourcesPath, fileName),
};
var filePath = candidates.FirstOrDefault(File.Exists);
if (!string.IsNullOrEmpty(filePath))
{
var json = File.ReadAllText(filePath);
var jsonResources = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
if (jsonResources != null)
{
foreach (var item in jsonResources)
{
resources[item.Key] = item.Value;
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading localization file {fileName} from disk: {ex.Message}");
}
_resourcesCache[culture] = resources;
return resources;
}
}
}
}

View File

@@ -1,26 +0,0 @@
using Microsoft.Extensions.Localization;
namespace Atomx.Admin.Client.Services
{
public class JsonStringLocalizerFactory : IStringLocalizerFactory
{
private readonly string _resourcesPath;
private readonly HttpClient _httpClient;
public JsonStringLocalizerFactory(string resourcesPath, HttpClient httpClient)
{
_resourcesPath = (resourcesPath ?? "localization").Trim('/');
_httpClient = httpClient;
}
public IStringLocalizer Create(Type resourceSource)
{
return new JsonStringLocalizer(_resourcesPath, _httpClient);
}
public IStringLocalizer Create(string baseName, string location)
{
return new JsonStringLocalizer(_resourcesPath, _httpClient);
}
}
}

View File

@@ -1,119 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace Atomx.Admin.Client.Services
{
/// <summary>
/// 语言提供者服务
/// </summary>
public class LanguageProvider
{
private readonly IJSRuntime _jsRuntime;
private readonly NavigationManager _navigationManager;
private string _currentLanguage = "zh-Hans";
public event Action? OnLanguageChanged;
public LanguageProvider(IJSRuntime jsRuntime, NavigationManager navigationManager)
{
_jsRuntime = jsRuntime;
_navigationManager = navigationManager;
}
public string CurrentLanguage
{
get => _currentLanguage;
private set
{
if (_currentLanguage != value)
{
_currentLanguage = value;
OnLanguageChanged?.Invoke();
}
}
}
public List<string> SupportedLanguages { get; } = new()
{
"zh-Hans", // 简体中文
"en-US" // 英文(美国)
};
/// <summary>
/// 初始化语言
/// </summary>
public async Task InitializeAsync()
{
// 尝试从本地存储获取保存的语言
Console.WriteLine("尝试从本地存储获取保存的语言 Initializing LanguageProvider...");
try
{
var savedLanguage = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", "preferred-language");
if (!string.IsNullOrEmpty(savedLanguage) && SupportedLanguages.Contains(savedLanguage))
{
CurrentLanguage = savedLanguage;
}
else
{
// 从浏览器获取语言
var browserLanguage = await _jsRuntime.InvokeAsync<string>("getBrowserLanguage");
CurrentLanguage = GetSupportedLanguage(browserLanguage);
}
}
catch
{
// JS互操作可能不可用在预渲染时
CurrentLanguage = "zh-Hans";
}
}
/// <summary>
/// 切换语言
/// </summary>
public async Task ChangeLanguageAsync(string languageCode)
{
Console.WriteLine("切换语言 ChangeLanguageAsync to " + languageCode);
if (SupportedLanguages.Contains(languageCode) && CurrentLanguage != languageCode)
{
CurrentLanguage = languageCode;
// 保存到本地存储
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "preferred-language", languageCode);
}
catch
{
// 忽略错误
}
// 通知语言已更改
OnLanguageChanged?.Invoke();
}
}
/// <summary>
/// 获取支持的语言
/// </summary>
private string GetSupportedLanguage(string browserLanguage)
{
if (string.IsNullOrEmpty(browserLanguage))
return "zh-Hans";
// 检查完全匹配
if (SupportedLanguages.Contains(browserLanguage))
return browserLanguage;
// 检查中性语言匹配
var neutralLanguage = browserLanguage.Split('-')[0];
foreach (var supported in SupportedLanguages)
{
if (supported.StartsWith(neutralLanguage))
return supported;
}
return "zh-Hans"; // 默认语言
}
}
}

View File

@@ -0,0 +1,53 @@
using System.Globalization;
namespace Atomx.Admin.Client.Services
{
public interface ILocalizationService
{
/// <summary>
/// 获取当前文化环境
/// </summary>d
CultureInfo CurrentCulture { get; }
/// <summary>
/// 当语言发生改变时触发的事件。
/// </summary>
event EventHandler<CultureInfo> LanguageChanged;
/// <summary>
/// 当语言发生改变时触发的事件。调用 <see cref="InteractiveStringLocalizer"/> 来更改语言环境。
/// </summary>
/// <param name="culture"></param>
void SetLanguage(CultureInfo culture);
}
public class LocalizationService : ILocalizationService
{
private CultureInfo? _currentCulture;
/// <summary>
/// 获取当前文化环境
/// </summary>
public CultureInfo CurrentCulture => _currentCulture ?? CultureInfo.CurrentCulture;
public event EventHandler<CultureInfo> LanguageChanged = default!;
public void SetLanguage(CultureInfo culture)
{
if (!culture.Equals(CultureInfo.CurrentCulture))
{
CultureInfo.CurrentCulture = culture;
}
if (_currentCulture == null || !_currentCulture.Equals(culture))
{
_currentCulture = culture;
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
LanguageChanged?.Invoke(this, culture);
}
}
}
}

View File

@@ -1,31 +0,0 @@
using Atomx.Admin.Client.Services;
using Microsoft.AspNetCore.Components;
namespace Atomx.Admin.Client.Utils
{
/// <summary>
/// 继承此基类的组件会自动订阅 LanguageProvider 的语言变更事件并在变更时重新渲染。
/// </summary>
public abstract class LocalizedComponentBase : ComponentBase, IDisposable
{
[Inject]
protected LanguageProvider LanguageProvider { get; set; } = null!;
protected override void OnInitialized()
{
base.OnInitialized();
LanguageProvider.OnLanguageChanged += LanguageChangedHandler;
}
private void LanguageChangedHandler()
{
// 在组件上下文中安全调用 StateHasChanged
_ = InvokeAsync(StateHasChanged);
}
public void Dispose()
{
LanguageProvider.OnLanguageChanged -= LanguageChangedHandler;
}
}
}

View File

@@ -39,4 +39,3 @@
@inject HttpService HttpService
@inject MessageService MessageService
@inject ModalService ModalService
@inject LanguageProvider LanguageProvider

View File

@@ -1,5 +1,4 @@
using AntDesign;
using Atomx.Admin.Client.Models;
using Atomx.Admin.Client.Models;
using Atomx.Admin.Client.Validators;
using Atomx.Admin.Services;
using Atomx.Common.Entities;
@@ -8,10 +7,8 @@ using Atomx.Data;
using Atomx.Data.CacheServices;
using Atomx.Data.Services;
using Atomx.Utils.Models;
using IdGen;
using MapsterMapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Atomx.Admin.Controllers
{

View File

@@ -1,48 +0,0 @@
using Atomx.Admin.Client.Services;
namespace Atomx.Admin.Middlewares
{
/// <summary>
/// 文化设置中间件
/// </summary>
public class CultureMiddleware
{
private readonly RequestDelegate _next;
public CultureMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, LanguageProvider languageProvider)
{
// 从查询字符串、Cookie或Header中获取语言设置
var cultureQuery = context.Request.Query["culture"];
var cultureCookie = context.Request.Cookies["preferred-language"];
string? culture = null;
if (!string.IsNullOrEmpty(cultureQuery))
{
culture = cultureQuery;
}
else if (!string.IsNullOrEmpty(cultureCookie))
{
culture = cultureCookie;
}
if (!string.IsNullOrEmpty(culture))
{
var supportedCultures = new[] { "zh-Hans", "en-US" };
if (supportedCultures.Contains(culture))
{
var cultureInfo = new System.Globalization.CultureInfo(culture);
System.Threading.Thread.CurrentThread.CurrentCulture = cultureInfo;
System.Threading.Thread.CurrentThread.CurrentUICulture = cultureInfo;
}
}
await _next(context);
}
}
}

View File

@@ -60,26 +60,6 @@ builder.Services.AddHttpContextAccessor();
// HttpClient <20><><EFBFBD><EFBFBD><EFBFBD>ݷ<EFBFBD><DDB7><EFBFBD>
builder.Services.AddHttpClientApiService(builder.Configuration["WebApi:ServerUrl"] ?? "http://localhost");
// ע<><D7A2>JSON<4F><4E><EFBFBD>ػ<EFBFBD><D8BB><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddSingleton<IStringLocalizerFactory>(sp =>
{
var env = sp.GetRequiredService<IWebHostEnvironment>();
var resourcesPath = Path.Combine(env.WebRootPath, "localization");
// <20><><EFBFBD>Դ<EFBFBD> DI <20><>ȡ IHttpClientFactory<72><79><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʹ<EFBFBD>ã<EFBFBD><C3A3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˵<EFBFBD> new HttpClient()
var httpFactory = sp.GetService<IHttpClientFactory>();
HttpClient httpClient;
if (httpFactory != null)
{
httpClient = httpFactory.CreateClient();
}
else
{
// <20>ڷ<EFBFBD><DAB7><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫʹ<D2AA><CAB9><EFBFBD>ļ<EFBFBD>ϵͳ<CFB5><CDB3>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ļ<EFBFBD><C4BC><EFBFBD><EFBFBD>˴<EFBFBD><CBB4><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> HttpClient ֻ<><D6BB>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ httpClient <20><><EFBFBD><EFBFBD><ECBAAF><EFBFBD>Ĺ<EFBFBD><C4B9><EFBFBD><EFBFBD><EFBFBD>
httpClient = new HttpClient();
}
return new JsonStringLocalizerFactory(resourcesPath, httpClient);
});
// ע<><D7A2>IStringLocalizer
builder.Services.AddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
@@ -99,8 +79,7 @@ builder.Services.AddScoped<PersistentAuthenticationStateProvider>();
builder.Services.AddScoped<IIdCreatorService, IdCreatorService>();
builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<LocalizationFile, LocalizationFile>();
// ע<><D7A2><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ߣ<EFBFBD><DFA3><EFBFBD>ΪScoped<65><64><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
builder.Services.AddScoped<LanguageProvider>();
builder.Services.AddScoped<AuthHeaderHandler>();
// SignalR<6C><52><EFBFBD><EFBFBD><EFBFBD>÷<EFBFBD><C3B7><EFBFBD><EFBFBD><EFBFBD> Hub ֧<>֣<EFBFBD>ע<EFBFBD>⣺JWT <20><> OnMessageReceived <20><><EFBFBD><EFBFBD> AuthorizationExtension <20>д<EFBFBD><D0B4><EFBFBD><EFBFBD><EFBFBD>