Files
Atomx/Atomx.Admin/Atomx.Admin.Client/Services/LocalizationProvider.cs
2025-12-14 18:27:21 +08:00

545 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.JSInterop;
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
namespace Atomx.Admin.Client.Services
{
/// <summary>
/// 本类提供多语言 JSON 文件的加载、缓存与运行时切换功能。
/// 主要职责:
/// - 在 Server 环境(例如 prerender 或 Blazor Server时尝试从 webroot 同步读取本地化 JSON 文件并缓存,
/// 以便在服务端渲染阶段立即可用。
/// - 在 WASM 环境时使用注入的 HttpClient 从 /localization/{culture}.json 下载并解析。
/// - 在切换语言时写入名为 `atomx.culture` 的 cookie供后端读取并尝试设置页面的 HTML lang 属性。
/// - 提供事件通知 LanguageChanged供 UI 或其他服务订阅以响应语言变更。
///
/// 说明:为了兼容 Server 与 WASM本 Provider 会尽量根据运行时环境选择最合适的资源加载方式。
/// </summary>
public interface ILocalizationProvider
{
string CurrentCulture { get; }
string? GetString(string key);
Task SetCultureAsync(string cultureShortOrFull);
Task InitializeAsync();
/// <summary>
/// 保证指定文化的资源已被加载(异步)。供外部在需要时触发加载。
/// </summary>
Task LoadCultureAsync(string culture);
event EventHandler<string>? LanguageChanged;
}
/// <summary>
/// LocalizationProvider 的实现:
/// - 维护一个静态缓存,避免重复下载/读取资源。
/// - 支持短码(如 zh / en与完整 culture如 zh-Hans / en-US之间的映射。
/// - 在 Server 端支持同步加载以满足 prerender 场景。
/// </summary>
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;
// 缓存culture -> translations。使用 ConcurrentDictionary 以线程安全地共享。
// 使用静态字段是为了使中间件/服务在同一进程内能够复用已加载的翻译,避免重复 I/O。
private static readonly ConcurrentDictionary<string, Dictionary<string, string>> _cache = new();
// 支持的短码映射,后续如需扩展可在此添加。
private static readonly Dictionary<string, string> ShortToCulture = new(StringComparer.OrdinalIgnoreCase)
{
{ "zh", "zh-Hans" },
{ "en", "en-US" }
};
// 默认文化(当未能从浏览器/URL/Cookie 中解析到文化时使用)
private string _currentCulture = "zh-Hans";
private const string CookieName = "atomx.culture";
/// <summary>
/// 构造函数:通过依赖注入获取必要服务。
/// 注意构造函数中不应执行耗时或 JS 相关的同步操作。
/// </summary>
public LocalizationProvider(IServiceProvider sp, ILogger<LocalizationProvider> logger, IHttpClientFactory? httpClientFactory, IJSRuntime? jsRuntime, ILocalizationService localizationService)
{
_sp = sp;
_httpClientFactory = httpClientFactory;
_jsRuntime = jsRuntime;
_logger = logger;
_localizationService = localizationService;
// 尝试根据当前线程 culture 初始化 _currentCulture不抛出异常
try
{
var threadUi = CultureInfo.DefaultThreadCurrentUICulture ?? CultureInfo.CurrentUICulture;
if (!string.IsNullOrEmpty(threadUi?.Name))
{
_currentCulture = MapToFullCulture(threadUi!.Name);
_logger.LogDebug("LocalizationProvider 构造检测到线程 UI 文化: {Culture}", _currentCulture);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "LocalizationProvider 构造读取线程文化失败");
}
// 如果在 Server 环境且未能使用 JSRuntime意味着非浏览器则尝试同步从文件系统读取本地化文件以支持 prerender
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("(Server 同步) 从路径加载本地化文件 {Path}Culture:{Culture},条目数:{Count}", path, _currentCulture, dict.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "(Server 同步) 读取本地化文件失败: {Path}", path);
}
}
else
{
_logger.LogDebug("本地化文件未在路径找到: {Path}", path);
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "LocalizationProvider 构造中同步文件加载尝试失败");
}
}
public string CurrentCulture => _currentCulture;
public event EventHandler<string>? LanguageChanged;
/// <summary>
/// 从缓存中读取指定键的本地化字符串,未找到返回 null。
/// </summary>
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;
}
/// <summary>
/// 初始化流程(浏览器端)
/// 优先级URL 语言前缀 -> Cookie(atomx.culture) -> 浏览器语言 -> 默认
/// 若解析到短码(如 zh/en则映射为完整文化如 zh-Hans/en-US
/// </summary>
public async Task InitializeAsync()
{
_logger.LogDebug("LocalizationProvider.InitializeAsync 开始. 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("检测到 URL 首段: {Segment}", urlFirstSegment);
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "读取 location.pathname 失败");
}
if (!string.IsNullOrEmpty(urlFirstSegment) && ShortToCulture.TryGetValue(urlFirstSegment, out var mapped))
{
_logger.LogDebug("URL 短码 '{Seg}' 映射为文化 '{Culture}'", urlFirstSegment, mapped);
await SetCultureInternalAsync(mapped, persistCookie: false);
return;
}
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var cookieVal = await _jsRuntime.InvokeAsync<string>("cookies.Read", CookieName);
_logger.LogDebug("读取 Cookie '{CookieName}'='{CookieVal}'", CookieName, cookieVal);
if (!string.IsNullOrEmpty(cookieVal))
{
if (ShortToCulture.TryGetValue(cookieVal, out var mappedFromCookie))
{
_logger.LogDebug("Cookie 短码 '{Cookie}' 映射为文化 {Culture}", cookieVal, mappedFromCookie);
await SetCultureInternalAsync(mappedFromCookie, persistCookie: false);
return;
}
else
{
// cookie 中可能已经是完整 culture进行平滑归一化
await SetCultureInternalAsync(MapToFullCulture(cookieVal), persistCookie: false);
return;
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "读取 Cookie 失败");
}
try
{
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
var browserLang = await _jsRuntime.InvokeAsync<string>("getBrowserLanguage");
_logger.LogDebug("浏览器语言: {BrowserLang}", browserLang);
if (!string.IsNullOrEmpty(browserLang))
{
var mappedFromBrowser = MapToFullCulture(browserLang);
_logger.LogDebug("浏览器语言映射为 {Culture}", mappedFromBrowser);
await SetCultureInternalAsync(mappedFromBrowser, persistCookie: false);
return;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "读取浏览器语言失败");
}
// 回退到当前默认文化并确保加载资源
_logger.LogDebug("InitializeAsync 回退使用当前文化 {Culture}", _currentCulture);
await EnsureCultureLoadedAsync(_currentCulture);
await SetCultureInternalAsync(_currentCulture, persistCookie: false);
}
/// <summary>
/// 对外异步设置文化(可传短码或完整 culture并可选择是否持久化到 Cookie
/// </summary>
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 端用于 prerender 时的同步设置:立即设置线程 Culture 并尝试从 webroot 同步加载本地化文件。
/// 该方法不会触发 JS 操作,适合在中间件或请求处理早期使用。
/// </summary>
public void SetCultureForServer(string cultureShortOrFull)
{
try
{
var cultureFull = MapToFullCulture(cultureShortOrFull);
if (string.IsNullOrEmpty(cultureFull)) return;
// 设置线程 culture影响后续服务端处理例如 IStringLocalizer
try
{
var ci = new CultureInfo(cultureFull);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
_currentCulture = cultureFull;
}
catch { }
// 同步从 webroot 加载 JSON 本地化文件(若存在)
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 同步) 已为 {Culture} 从路径加载本地化文件,条目数: {Count}", cultureFull, dict.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "(Server 同步) 读取本地化文件失败: {Path}", path);
}
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "SetCultureForServer 加载文件时发生错误: {Culture}", cultureFull);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "SetCultureForServer 执行过程中发生异常");
}
}
/// <summary>
/// 内部设置文化并在需要时持久化 Cookie、更新 localization service 并触发事件。
/// </summary>
private async Task SetCultureInternalAsync(string cultureFull, bool persistCookie)
{
//_logger.LogDebug("设置内部文化异步开始: {Culture}, 持久化={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}", cultureFull);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "设置 Culture 失败: {Culture}", cultureFull);
}
if (persistCookie && _jsRuntime != null)
{
try
{
var shortKey = ShortToCulture.FirstOrDefault(kv => string.Equals(kv.Value, cultureFull, StringComparison.OrdinalIgnoreCase)).Key ?? cultureFull;
// 将 shortKey 写入 cookie供后端请求读取例如 Server 模式下的中间件)
await _jsRuntime.InvokeVoidAsync("cookies.Write", CookieName, shortKey, DateTime.UtcNow.AddYears(1).ToString("o"));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "写入 Cookie 失败");
}
}
try
{
if (_jsRuntime != null)
{
// 尝试设置 HTML 的 lang 属性,改善无障碍/SEO
await _jsRuntime.InvokeVoidAsync("setHtmlLang", cultureFull);
}
}
catch { }
// 通知订阅者
LanguageChanged?.Invoke(this, cultureFull);
}
/// <summary>
/// 确保指定文化的 JSON 文件已被加载到缓存。加载顺序WASM HttpClient -> 文件系统 -> 空字典占位。
/// </summary>
private async Task EnsureCultureLoadedAsync(string cultureFull)
{
// 归一化短码(例如 zh -> zh-Hans
cultureFull = MapToFullCulture(cultureFull);
if (string.IsNullOrEmpty(cultureFull)) return;
if (_cache.ContainsKey(cultureFull))
{
_logger.LogDebug("EnsureCultureLoadedAsync: 文化 {Culture} 已缓存", cultureFull);
return;
}
// 如果在浏览器WASM优先使用 HttpClient 下载本地化 JSON
if (_jsRuntime != null && OperatingSystem.IsBrowser())
{
_logger.LogInformation("EnsureCultureLoadedAsync: 在浏览器环境,将尝试通过 HttpClient 下载 {Culture}", cultureFull);
try
{
var http = _sp.GetService(typeof(HttpClient)) as HttpClient;
if (http == null && _httpClientFactory != null)
{
_logger.LogDebug("未从 ServiceProvider 解析到 HttpClient使用 IHttpClientFactory 创建");
http = _httpClientFactory.CreateClient();
}
else
{
_logger.LogDebug("从 ServiceProvider 解析 HttpClient: {HasClient}", http != null);
}
if (http != null)
{
var url = $"/localization/{cultureFull}.json";
Uri? requestUri = null;
// 当 HttpClient 配置了 BaseAddress 时使用;否则通过 JS 获取 location.origin 构建绝对 URI
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))
{
requestUri = new Uri(new Uri(origin), url);
}
}
catch (Exception jsEx)
{
_logger.LogDebug(jsEx, "从 JS 获取 location.origin 失败");
}
}
if (requestUri != null)
{
_logger.LogInformation("从 {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("通过 HttpClient 为 {Culture} 加载到本地化数据,条目数: {Count}", cultureFull, dict.Count);
return;
}
else
{
_logger.LogWarning("HttpClient 无法构建请求 URL跳过通过 HttpClient 加载 {Culture}", cultureFull);
}
}
else
{
_logger.LogWarning("未找到可用的 HttpClient 以加载 {Culture}", cultureFull);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "通过 HttpClient 加载本地化文件失败: {Culture}", cultureFull);
}
}
_logger.LogDebug("EnsureCultureLoadedAsync: 尝试文件系统加载 {Culture}", cultureFull);
// 回退:尝试通过 IWebHostEnvironment 从文件系统读取Server 时可用)
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("查找本地化文件路径: {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("从文件为 {Culture} 加载本地化,条目数: {Count}", cultureFull, dict.Count);
return;
}
else
{
_logger.LogDebug("未在路径找到本地化文件: {Path}", path);
// 备用路径:构建于运行时输出目录的 wwwroot
try
{
var alt = Path.Combine(AppContext.BaseDirectory ?? ".", "wwwroot", "localization", cultureFull + ".json");
_logger.LogDebug("查找备用路径: {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("从备用路径为 {Culture} 加载到本地化文件,条目数: {Count}", cultureFull, dict2.Count);
return;
}
else
{
_logger.LogDebug("备用路径未找到本地化文件: {AltPath}", alt);
}
}
catch (Exception exAlt)
{
_logger.LogDebug(exAlt, "检查备用本地化路径时出错");
}
}
}
else
{
_logger.LogDebug("无法从 ServiceProvider 获取 IWebHostEnvironment 实例");
}
}
else
{
_logger.LogDebug("通过反射未能找到 IWebHostEnvironment 类型");
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "通过文件系统加载本地化文件失败: {Culture}", cultureFull);
}
_logger.LogDebug("EnsureCultureLoadedAsync: 回退为 {Culture} 的空字典占位", cultureFull);
_cache[cultureFull] = new Dictionary<string, string>();
}
/// <summary>
/// 将短码或带区域的字符串映射为内部使用的完整 culture例如 zh -> zh-Hans
/// </summary>
private string MapToFullCulture(string culture)
{
if (string.IsNullOrEmpty(culture)) return culture;
// 直接映射
if (ShortToCulture.TryGetValue(culture, out var mapped)) return mapped;
// 考虑前缀,例如 zh-CN -> zh
var prefix = culture.Split('-', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrEmpty(prefix) && ShortToCulture.TryGetValue(prefix, out var mapped2)) return mapped2;
return culture;
}
}
}