chore fix

This commit is contained in:
2025-12-06 13:30:17 +08:00
parent 2972886576
commit 8aca372fc1
43 changed files with 984 additions and 595 deletions

View File

@@ -1,29 +0,0 @@
@inject IJSRuntime JSRuntime
@inject ILocalizationService LocalizationService
@inject NavigationManager Navigation
@* <select @bind="_selectedCulture" @onchange="OnCultureChanged">
<option value="en-US">English</option>
<option value="zh-CN">中文</option>
<option value="ja-JP">日本語</option>
</select> *@
@code {
private string _selectedCulture = "en-US";
protected override async Task OnInitializedAsync()
{
_selectedCulture = await JSRuntime.InvokeAsync<string>("blazorCulture.get") ?? "en-US";
}
private async Task OnCultureChanged(ChangeEventArgs e)
{
var culture = e.Value?.ToString();
if (!string.IsNullOrEmpty(culture))
{
await JSRuntime.InvokeVoidAsync("blazorCulture.set", culture);
await LocalizationService.LoadResourcesAsync(culture);
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
}
}

View File

@@ -0,0 +1,61 @@
@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,26 +0,0 @@
@inject ILocalizationService LocalizationService
@Text
@code {
private string? _text;
[Parameter]
public string Key { get; set; } = string.Empty;
[Parameter]
public string? Culture { get; set; }
private string Text => _text ?? Key;
protected override async Task OnParametersSetAsync()
{
await LoadText();
}
private async Task LoadText()
{
_text = await LocalizationService.GetStringAsync(Key, Culture) ?? Key;
StateHasChanged();
}
}

View File

@@ -0,0 +1,17 @@
using Atomx.Common.Entities;
namespace Atomx.Admin.Client.Models
{
public class LocaleResourceItem:LocaleResource
{
/// <summary>
/// 语言标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 语言名称
/// </summary>
public string Culture { get; set; } = string.Empty;
}
}

View File

@@ -12,6 +12,11 @@
/// </summary>
public int LanguageId { get; set; }
/// <summary>
/// 语言标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 语言名称
/// </summary>

View File

@@ -3,6 +3,7 @@
@layout EmptyLayout
@inject ILogger<Login> Logger
@inject IJSRuntime JS
@inject IStringLocalizer<Login> Localizer
<PageTitle>登录</PageTitle>
@@ -12,6 +13,10 @@
}
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>

View File

@@ -0,0 +1,207 @@
@page "/system/locale/resource/detail/{Name}"
@inject ILogger<LocaleResourceList> Logger
@attribute [Authorize]
<PageTitle>本地化语言资源</PageTitle>
<Title Level="4">多语言本地资源管理</Title>
<Spin Spinning="loading">
<Card Class="mt-3">
<Table DataSource="ResourceItems" PageSize="100" HidePagination="true" Resizable>
<TitleTemplate>
<Flex Justify="FlexJustify.SpaceBetween">
@(@Name)多语言资源列表,可用语言@(@languages.Count)种
<div>
<Button Class="me-3" OnClick="OnCreateClick" Type="ButtonType.Primary">新增</Button>
</div>
</Flex>
</TitleTemplate>
<ColumnDefinitions>
<PropertyColumn Property="c => c.Name" Title="资源Key">
</PropertyColumn>
<PropertyColumn Property="c => c.Title" Title="语言">
</PropertyColumn>
<PropertyColumn Property="c => c.Value" Title="内容">
</PropertyColumn>
<PropertyColumn Property="c => c.UpdateTime" Title="最后更新" Width="120px" />
<ActionColumn Title="操作" Align="ColumnAlign.Right" Width="100px">
<Space>
<SpaceItem>
<Dropdown Trigger="@(new Trigger[] { Trigger.Click })">
<Overlay>
<Menu>
<MenuItem>
<a @onclick="(e) => OnEditClick(context)"> <Icon Type="@IconType.Outline.Edit" /> 编辑</a>
</MenuItem>
<MenuDivider />
<MenuItem>
<Popconfirm Placement="@Placement.Left" Title="@("删除这条数据无法恢复,您确定要删除吗?")"
OnConfirm="@(e=>HandleDeleteConfirmAsync(e,context.Id))"
OkText="确定"
CancelText="取消">
<a> <Icon Type="@IconType.Outline.Delete" /> 删除</a>
</Popconfirm>
</MenuItem>
</Menu>
</Overlay>
<ChildContent>
<a class="ant-dropdown-link" @onclick:preventDefault>
<Icon Type="@IconType.Outline.Menu" />
</a>
</ChildContent>
</Dropdown>
</SpaceItem>
</Space>
</ActionColumn>
</ColumnDefinitions>
</Table>
</Card>
</Spin>
<Modal Title="@("语言资源设置")" Visible="@modalVisible" Width="700" MaskClosable="true" OkText="@("保存")" CancelText="@("取消")" OnOk="@HandleModalOk" OnCancel="@HandleCancel">
<Form Model="@model" @ref="@editform" LabelCol="new ColLayoutParam { Span = 5 }" WrapperCol="new ColLayoutParam { Span = 15 }" Name="modalForm" OnFinish="OnFormFinish">
<FluentValidationValidator />
<FormItem Label="语言文字">
@if (context.Id == 0)
{
<Select DataSource="@languages" @bind-Value="@context.LanguageId" ItemValue="p => p.Id" ItemLabel="p => p.Title">
</Select>
}
else
{
<Input Placeholder="资源名称" @bind-Value="@context.Title" Disabled />
}
</FormItem>
<FormItem Label="资源名称">
<Input Placeholder="资源名称" @bind-Value="@context.Name" Disabled />
</FormItem>
<FormItem Label="资源内容">
<TextArea Placeholder="资源内容" @bind-Value="@context.Value" />
</FormItem>
</Form>
</Modal>
@code {
bool loading = false;
[Parameter]
public string Name { get; set; }
[SupplyParameterFromForm]
LocaleResourceModel model { get; set; } = default!;
Form<LocaleResourceModel> editform = null!;
List<LocaleResourceItem> ResourceItems = new();
List<Language> languages = new();
bool modalVisible = false;
protected override void OnInitialized()
{
model ??= new LocaleResourceModel() { };
base.OnInitialized();
}
protected override async Task OnParametersSetAsync()
{
_ = LoadLanguages();
_ = LoadList();
}
private async Task LoadLanguages()
{
var url = $"/api/language/enabled";
var apiResult = await HttpService.Get<ApiResult<List<Language>>>(url);
if (apiResult.Success)
{
if (apiResult.Data != null)
{
languages = apiResult.Data;
StateHasChanged();
}
}
}
private async Task LoadList()
{
loading = true;
var url = $"/api/localeresource/{Name}";
var apiResult = await HttpService.Get<ApiResult<List<LocaleResourceItem>>>(url);
if (apiResult.Success)
{
if (apiResult.Data != null)
{
ResourceItems = apiResult.Data;
}
}
loading = false;
StateHasChanged();
}
void OnCreateClick()
{
Console.WriteLine("OnCreateClick");
model = new() { Name = Name };
modalVisible = true;
}
void OnEditClick(LocaleResourceItem data)
{
this.model = data.Adapt<LocaleResourceModel>();
modalVisible = true;
}
async Task HandleDeleteConfirmAsync(MouseEventArgs e, long id)
{
var url = $"/api/localeresource/delete/{id}";
var apiResult = await HttpService.Post<ApiResult<string>>(url, new());
if (apiResult.Success)
{
_ = LoadList();
await ModalService.InfoAsync(new ConfirmOptions() { Title = "操作提示", Content = "删除数据成功" });
}
else
{
await ModalService.ErrorAsync(new ConfirmOptions() { Title = "操作提示", Content = $"数据删除失败.{apiResult.Message}" });
}
}
void HandleModalOk()
{
editform.Submit();
}
async Task OnFormFinish()
{
if (editform.Validate())
{
var result = new ApiResult<bool>();
var url = $"api/localeresource/save";
result = await HttpService.Post<ApiResult<bool>>(url, model);
if (result.Success)
{
modalVisible = false;
_ = LoadList();
}
else
{
await ModalService.ErrorAsync(new ConfirmOptions() { Title = "服务异常", Content = result.Message });
}
}
}
void HandleCancel()
{
modalVisible = false;
}
}

View File

@@ -33,7 +33,7 @@
<Table DataSource="PagingList.Items" PageSize="100" HidePagination="true" Resizable>
<TitleTemplate>
<Flex Justify="FlexJustify.SpaceBetween">
多语言列表
@(@language.Name)语言资源列表
<div>
<Button Class="me-3" OnClick="OnCreateClick" Type="ButtonType.Primary">新增</Button>
</div>
@@ -45,11 +45,11 @@
<PropertyColumn Property="c => c.Value" Title="内容">
</PropertyColumn>
<PropertyColumn Property="c => c.UpdateTime" Title="最后更新" />
<ActionColumn Title="操作" Align="ColumnAlign.Right">
<PropertyColumn Property="c => c.UpdateTime" Title="最后更新" Width="120px" />
<ActionColumn Title="操作" Align="ColumnAlign.Right" Width="120px">
<Space>
<SpaceItem>
<a href="@($"/system/locale/resource/list/{context.Id}")"> <Icon Type="@IconType.Outline.Edit" /> 语言资源</a>
<a @onclick="(e) => OnEditClick(context)"> <Icon Type="@IconType.Outline.Edit" /> 编辑</a>
</SpaceItem>
<SpaceItem>
<Dropdown Trigger="@(new Trigger[] { Trigger.Click })">
@@ -57,7 +57,7 @@
<Menu>
<MenuItem>
<a @onclick="(e) => OnEditClick(context)"> <Icon Type="@IconType.Outline.Edit" /> 编辑</a>
<a href="@($"/system/locale/resource/detail/{context.Name}")"> <Icon Type="@IconType.Outline.Edit" /> 其他语言比对</a>
</MenuItem>
<MenuDivider />
<MenuItem>
@@ -88,14 +88,17 @@
</Card>
</Spin>
<Modal Title="@("消息模版设置")" Visible="@modalVisible" Width="700" MaskClosable="true" OkText="@("保存")" CancelText="@("取消")" OnOk="@HandleModalOk" OnCancel="@HandleCancel">
<Modal Title="@("语言资源设置")" Visible="@modalVisible" Width="700" MaskClosable="true" OkText="@("保存")" CancelText="@("取消")" OnOk="@HandleModalOk" OnCancel="@HandleCancel">
<Form Model="@model" @ref="@editform" LabelCol="new ColLayoutParam { Span = 5 }" WrapperCol="new ColLayoutParam { Span = 15 }" Name="modalForm" OnFinish="OnFormFinish">
<FluentValidationValidator />
<FormItem Label="消息模版名称">
<Input Placeholder="消息模版名称" @bind-Value="@context.Name" />
<FormItem Label="语言文字">
@language.Name
</FormItem>
<FormItem Label="消息内容">
<TextArea Placeholder="消息内容" @bind-Value="@context.Value" />
<FormItem Label="资源名称">
<Input Placeholder="资源名称" @bind-Value="@context.Name" />
</FormItem>
<FormItem Label="资源内容">
<TextArea Placeholder="资源内容" @bind-Value="@context.Value" />
</FormItem>
</Form>
</Modal>
@@ -121,6 +124,7 @@
LocaleResourceModel model { get; set; } = default!;
Form<LocaleResourceModel> editform = null!;
Language language = new();
PagingList<LocaleResource> PagingList = new();
bool modalVisible = false;
@@ -134,9 +138,23 @@
protected override async Task OnParametersSetAsync()
{
await LoadLanguage();
await LoadList();
}
private async Task LoadLanguage()
{
var url = $"/api/language/{Id}";
var apiResult = await HttpService.Get<ApiResult<Language>>(url);
if (apiResult.Success)
{
if (apiResult.Data != null)
{
language = apiResult.Data;
}
}
}
private async Task LoadList()
{
loading = true;
@@ -172,18 +190,20 @@
void OnCreateClick()
{
model = new() { Culture = LanguageCulture.zhHans };
Console.WriteLine("OnCreateClick");
model = new() { Culture = LanguageCulture.zhHans, LanguageId = Id };
modalVisible = true;
}
void OnEditClick(LocaleResource data)
{
this.model = data.Adapt<LocaleResourceModel>();
// drawerVisible = true;
modalVisible = true;
}
async Task HandleDeleteConfirmAsync(MouseEventArgs e, long id)
{
var url = $"/api/language/delete/{id}";
var url = $"/api/localeresource/delete/{id}";
var apiResult = await HttpService.Post<ApiResult<string>>(url, new());
if (apiResult.Success)
{
@@ -196,35 +216,6 @@
}
}
async Task OnFormFinish()
{
if (editform.Validate())
{
var url = $"api/language/save";
var result = await HttpService.Post<ApiResult<bool>>(url, model);
if (result.Success)
{
if (result.Data)
{
// CloseDrawer();
_ = LoadList();
await ModalService.InfoAsync(new ConfirmOptions() { Title = "提示", Content = "数据提交成功!" });
}
else
{
await ModalService.ErrorAsync(new ConfirmOptions() { Title = "服务异常", Content = result.Message });
}
}
else
{
await ModalService.ErrorAsync(new ConfirmOptions() { Title = "服务异常", Content = result.Message });
}
}
}
private void OnSearch(int page)
{
var queryString = search.BuildQueryString();
@@ -257,9 +248,34 @@
OnSearch(args.Page);
}
void CloseDrawer()
void HandleModalOk()
{
// drawerVisible = false;
editform.Reset();
editform.Submit();
}
async Task OnFormFinish()
{
if (editform.Validate())
{
var result = new ApiResult<bool>();
var url = $"api/localeresource/save";
result = await HttpService.Post<ApiResult<bool>>(url, model);
if (result.Success)
{
modalVisible = false;
_ = LoadList();
}
else
{
await ModalService.ErrorAsync(new ConfirmOptions() { Title = "服务异常", Content = result.Message });
}
}
}
void HandleCancel()
{
modalVisible = false;
}
}

View File

@@ -4,6 +4,8 @@ using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Localization;
using System.Net.Http;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -18,7 +20,22 @@ builder.Services.AddSingleton<AuthenticationStateProvider, PersistentAuthenticat
// Ȩ<><C8A8> & <20><><EFBFBD>ػ<EFBFBD><D8BB><EFBFBD><EFBFBD>ͻ<EFBFBD><CDBB><EFBFBD>ʵ<EFBFBD>֣<EFBFBD>
builder.Services.AddScoped<IPermissionService, ClientPermissionService>();
builder.Services.AddScoped<IconsExtension>();
builder.Services.AddScoped<ILocalizationService, LocalizationClientService>();
// Ϊ<><CEAA>̬<EFBFBD><CCAC>Դ<EFBFBD><D4B4>wwwroot<6F><74>ע<EFBFBD><D7A2>һ<EFBFBD><D2BB>ר<EFBFBD><D7A8> HttpClient<6E><74>BaseAddress ָ<><D6B8>Ӧ<EFBFBD>ø<EFBFBD><C3B8><EFBFBD>
// <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>();
@@ -38,7 +55,7 @@ builder.Services.AddHttpClient("RefreshClient", client =>
client.BaseAddress = new Uri(apiBase);
});
// Ĭ<><C4AC> HttpClient<6E><74><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2> HttpClient<6E><74>
// Ĭ<><C4AC> HttpClient<6E><74><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ע<EFBFBD><D7A2> HttpClient<6E><74><EFBFBD>˴<EFBFBD><EFBFBD><EFBFBD>ʹ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ApiClient<6E><74>
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ApiClient"));
// <20><> WASM <20><> Program.cs<63><73><EFBFBD>ͻ<EFBFBD><CDBB>ˣ<EFBFBD><CBA3><EFBFBD>ע<EFBFBD><D7A2> HttpService ʱ<><CAB1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ܴ<EFBFBD><DCB4>ڵ<EFBFBD> IHttpContextAccessor<6F><72>Server <20><EFBFBD><E1B9A9>WASM Ϊ null<6C><6C>

View File

@@ -0,0 +1,141 @@
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

@@ -0,0 +1,27 @@
using Microsoft.Extensions.Localization;
using System.Net.Http;
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

@@ -0,0 +1,129 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using System.Globalization;
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;
// 设置全局线程文化,确保 IStringLocalizer 等在随后的渲染中读取到新文化
try
{
var ci = new CultureInfo(value);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
}
catch
{
// 忽略无效 culture 字符串
}
OnLanguageChanged?.Invoke();
}
}
}
public List<string> SupportedLanguages { get; } = new()
{
"zh-Hans", // 简体中文
"en-US" // 英文(美国)
};
/// <summary>
/// 初始化语言
/// </summary>
public async Task InitializeAsync()
{
// 尝试从本地存储获取保存的语言
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)
{
if (SupportedLanguages.Contains(languageCode) && CurrentLanguage != languageCode)
{
CurrentLanguage = languageCode;
// 保存到本地存储
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "preferred-language", languageCode);
}
catch
{
// 忽略错误
}
// setter 已触发 OnLanguageChanged
}
}
/// <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

@@ -1,322 +0,0 @@
using Atomx.Common.Models;
using Microsoft.JSInterop;
using System.Text.Json;
namespace Atomx.Admin.Client.Services
{
public interface ILocalizationService
{
/// <summary>
/// 根据name获取制定文化语言的译文
/// </summary>
/// <param name="name"></param>
/// <param name="culture"></param>
/// <returns></returns>
Task<string?> GetStringAsync(string name, string? culture = null);
/// <summary>
/// 把本地化文化语言加载到内存中
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
Task<bool> LoadResourcesAsync(string culture);
event EventHandler<string>? ResourcesUpdated;
}
public class LocalizationClientService : ILocalizationService, IAsyncDisposable
{
private readonly HttpService _httpService;
private readonly IJSRuntime _jsRuntime;
private readonly ILogger<LocalizationClientService> _logger;
private readonly Dictionary<string, Dictionary<string, string>> _resources = new();
private readonly Dictionary<string, string> _versions = new();
private readonly SemaphoreSlim _semaphore = new(1, 1);
public event EventHandler<string>? ResourcesUpdated;
public LocalizationClientService(
HttpService httpService,
IJSRuntime jsRuntime,
ILogger<LocalizationClientService> logger)
{
_httpService = httpService;
_jsRuntime = jsRuntime;
_logger = logger;
}
/// <summary>
/// 根据name获取制定文化语言的译文
/// </summary>
/// <param name="name"></param>
/// <param name="culture"></param>
/// <returns></returns>
public async Task<string?> GetStringAsync(string name, string? culture = null)
{
culture ??= await GetCurrentCultureAsync();
if (_resources.TryGetValue(culture, out var cultureResources))
{
if (cultureResources.TryGetValue(name, out var value))
{
return value;
}
// 键不存在,触发资源更新检查
_ = Task.Run(async () => await CheckAndUpdateResourcesAsync(culture));
}
else
{
// 资源未加载,立即加载
await LoadResourcesAsync(culture);
// 重试获取
if (_resources.TryGetValue(culture, out cultureResources) &&
cultureResources.TryGetValue(name, out var value))
{
return value;
}
}
_logger.LogWarning("Localization key not found: {Key} for culture: {Culture}", name, culture);
return null;
}
/// <summary>
/// 把本地化文化语言加载到内存中
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
public async Task<bool> LoadResourcesAsync(string culture)
{
await _semaphore.WaitAsync();
try
{
// 先尝试从localStorage加载
if (await TryLoadFromLocalStorage(culture))
{
// 检查服务器版本,如果需要更新则从服务器加载
if (await CheckAndUpdateFromServer(culture))
{
return true;
}
return true; // 本地版本仍然有效
}
// 从服务器加载
return await LoadFromServer(culture);
}
finally
{
_semaphore.Release();
}
}
/// <summary>
/// 从单例对象中获取版本记录
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private async Task<string?> GetResourceVersionAsync(string culture)
{
if (_versions.TryGetValue(culture, out var version))
{
return version;
}
var storedVersion = await GetStoredVersion(culture);
return storedVersion;
}
/// <summary>
/// 检查更新资源
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private async Task<bool> CheckAndUpdateResourcesAsync(string culture)
{
return await CheckAndUpdateFromServer(culture);
}
/// <summary>
/// 从本地加载文化语言数据
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private async Task<bool> TryLoadFromLocalStorage(string culture)
{
try
{
var resourcesJson = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", $"locales_{culture}");
var version = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", $"locales_version_{culture}");
if (string.IsNullOrEmpty(resourcesJson) || string.IsNullOrEmpty(version))
{
return false;
}
var resources = JsonSerializer.Deserialize<Dictionary<string, string>>(resourcesJson);
if (resources != null && version != null)
{
_resources[culture] = resources;
_versions[culture] = version;
return true;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load resources from localStorage for culture: {Culture}", culture);
}
return false;
}
/// <summary>
/// 从服务器更新获取文化语言数据
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private async Task<bool> LoadFromServer(string culture)
{
try
{
var response = await _httpService.Get<ApiResult<LocalizationFile>>($"/api/localeresource/resources/{culture}");
if (response.Success)
{
_resources[culture] = response.Data.Translations;
_versions[culture] = response.Data.ResourceVersion;
// 保存到localStorage
await SaveToLocalStorage(culture, response.Data.Translations, response.Data.ResourceVersion);
ResourcesUpdated?.Invoke(this, culture);
return true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load resources from server for culture: {Culture}", culture);
}
return false;
}
/// <summary>
/// 根据版本信息上从服务器数据对比版本更新数据
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private async Task<bool> CheckAndUpdateFromServer(string culture)
{
try
{
var currentVersion = await GetResourceVersionAsync(culture);
var serverVersion = await GetServerVersion(culture);
if (serverVersion != null && (currentVersion == null || serverVersion != currentVersion))
{
_logger.LogInformation("Updating resources for culture: {Culture}", culture);
return await LoadFromServer(culture);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check and update resources for culture: {Culture}", culture);
}
return false;
}
/// <summary>
/// 从服务器获取当前多语言文化数据版本信息
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private async Task<string?> GetServerVersion(string culture)
{
try
{
var api = $"/api/localeresource/version/{culture}";
var result = await _httpService.Get<ApiResult<string>>(api);
if (result.Success)
{
return result.Data;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get server version for culture: {Culture}", culture);
}
return null;
}
/// <summary>
/// 获取存储在本地的文化语言版本信息
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
private async Task<string?> GetStoredVersion(string culture)
{
try
{
var version = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", $"locales_version_{culture}");
if (!string.IsNullOrEmpty(version))
{
return version;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get stored version for culture: {Culture}", culture);
}
return null;
}
/// <summary>
/// 存储版本和文化语言信息
/// </summary>
/// <param name="culture"></param>
/// <param name="resources"></param>
/// <param name="version"></param>
/// <returns></returns>
private async Task SaveToLocalStorage(string culture, Dictionary<string, string> resources, string version)
{
try
{
var resourcesJson = JsonSerializer.Serialize(resources);
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", $"locales_{culture}", resourcesJson);
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", $"locales_version_{culture}", version);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save resources to localStorage for culture: {Culture}", culture);
}
}
/// <summary>
/// 获取浏览器上的文化语言信息
/// </summary>
/// <returns></returns>
private async Task<string> GetCurrentCultureAsync()
{
try
{
return await _jsRuntime.InvokeAsync<string>("blazorCulture.get");
}
catch
{
return "en-US"; // 默认文化
}
}
public async ValueTask DisposeAsync()
{
_semaphore?.Dispose();
}
}
}

View File

@@ -0,0 +1,31 @@
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

@@ -9,6 +9,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Microsoft.Extensions.Localization
@using Atomx.Admin.Client.Components
@using Atomx.Admin.Client
@using Atomx.Admin.Client.Services
@@ -37,4 +38,5 @@
@inject NavigationManager Navigation
@inject HttpService HttpService
@inject MessageService MessageService
@inject ModalService ModalService
@inject ModalService ModalService
@inject LanguageProvider LanguageProvider

View File

@@ -0,0 +1,9 @@
// 获取浏览器语言
function getBrowserLanguage() {
return navigator.language || navigator.userLanguage || 'zh-Hans';
}
// 设置HTML lang属性
function setHtmlLang(lang) {
document.documentElement.lang = lang;
}