fix localization

This commit is contained in:
yxw
2025-12-09 17:39:21 +08:00
parent 7334a9576f
commit 2318dff192
11 changed files with 95 additions and 43 deletions

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Blazilla" Version="2.0.1" /> <PackageReference Include="Blazilla" Version="2.0.1" />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" /> <PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" /> <PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />

View File

@@ -113,7 +113,7 @@
{ {
try try
{ {
jsResult = await JS.InvokeAsync<string>("CookieReader.Read", "atomx.culture"); jsResult = await JS.InvokeAsync<string>("cookies.Read", "atomx.culture");
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -6,6 +6,9 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using System.Net.Http; using System.Net.Http;
using FluentValidation;
using System.Linq;
using System.Reflection;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
@@ -67,6 +70,8 @@ builder.Services.AddScoped<HttpService>(sp =>
return new HttpService(httpClient, httpContextAccessor); return new HttpService(httpClient, httpContextAccessor);
}); });
builder.Services.AddValidatorsFromAssembly(typeof(Atomx.Admin.Client.Validators.LoginModelValidator).Assembly);
builder.Services.AddAntDesign(); builder.Services.AddAntDesign();

View File

@@ -169,7 +169,7 @@ namespace Atomx.Admin.Client.Services
{ {
if (_jsRuntime != null && OperatingSystem.IsBrowser()) if (_jsRuntime != null && OperatingSystem.IsBrowser())
{ {
var cookieVal = await _jsRuntime.InvokeAsync<string>("CookieReader.Read", CookieName); var cookieVal = await _jsRuntime.InvokeAsync<string>("cookies.Read", CookieName);
_logger?.LogDebug("Cookie '{CookieName}'='{CookieVal}'", CookieName, cookieVal); _logger?.LogDebug("Cookie '{CookieName}'='{CookieVal}'", CookieName, cookieVal);
if (!string.IsNullOrEmpty(cookieVal)) if (!string.IsNullOrEmpty(cookieVal))
{ {

View File

@@ -1,25 +1,36 @@
using Atomx.Admin.Client.Models; using Atomx.Admin.Client.Models;
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.Localization;
namespace Atomx.Admin.Client.Validators namespace Atomx.Admin.Client.Validators
{ {
public class LoginModelValidator : AbstractValidator<LoginModel> public class LoginModelValidator : AbstractValidator<LoginModel>
{ {
public LoginModelValidator() public LoginModelValidator(IStringLocalizer<LoginModelValidator> localizer)
{ {
RuleFor(p => p.Account).NotEmpty().WithMessage("登录账号不能为空"); // helper funcs to get localized text or fallback
RuleFor(p => p.Account).Length(2, 100).When(p => !string.IsNullOrEmpty(p.Account)).WithMessage("用户名长度必须再2-100个字符之间"); string AccountEmpty() => localizer?["Login.Account.Empty"].Value ?? "登录账号不能为空";
//RuleFor(p => p.Account).EmailAddress().When(p => !p.Account.Contains("@") && !string.IsNullOrEmpty(p.Account)).WithMessage("电子邮件地址不正确"); string AccountLength() => localizer?["Login.Account.Length"].Value ?? "用户名长度必须再2-100个字符之间";
string PasswordEmpty() => localizer?["Login.Password.Empty"].Value ?? "请输入登录密码";
string PasswordLength() => localizer?["Login.Password.Length"].Value ?? "登录密码必须在6-32位长度之间";
//RuleFor(p => p.Username).NotEmpty().WithMessage("用户名不能为空"); RuleFor(p => p.Account)
//RuleFor(p => p.Username).Length(2, 50).When(p => !string.IsNullOrEmpty(p.Username)).WithMessage("用户名长度必须再2-50个字符之间"); .NotEmpty()
//RuleFor(p => p.Account).NotEmpty().WithMessage("电子邮件地址不能为空"); .WithMessage(_ => AccountEmpty());
//RuleFor(p => p.Email).EmailAddress().When(p => !string.IsNullOrEmpty(p.Email)).WithMessage(p => localizer["Form.Email.Invalid"]);
//RuleFor(p => p.Email).MaximumLength(128).When(p => !string.IsNullOrEmpty(p.Email)).WithMessage(p => localizer["Form.Email.LengthInvalid"]); RuleFor(p => p.Account)
RuleFor(p => p.Password).NotEmpty().WithMessage("请输入登录密码"); .Length(2, 100)
RuleFor(p => p.Password).Length(6, 32).When(p => !string.IsNullOrEmpty(p.Password)).WithMessage("登录密码必须在6-32位长度之间"); .When(p => !string.IsNullOrEmpty(p.Account))
//RuleFor(p => p.ConfirmPassword).NotEmpty().WithMessage(p => localizer["Form.ConfirmPassword.Empty"]); .WithMessage(_ => AccountLength());
//RuleFor(p => p.ConfirmPassword).Equal(p => p.Password).When(p => !string.IsNullOrEmpty(p.Password) && !string.IsNullOrEmpty(p.ConfirmPassword)).WithMessage(p => localizer["Form.ConfirmPassword.Different"]);
RuleFor(p => p.Password)
.NotEmpty()
.WithMessage(_ => PasswordEmpty());
RuleFor(p => p.Password)
.Length(6, 32)
.When(p => !string.IsNullOrEmpty(p.Password))
.WithMessage(_ => PasswordLength());
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>

View File

@@ -0,0 +1,6 @@
namespace Atomx.Admin.Client.Validators
{
public sealed class ValidatorsMarker
{
}
}

View File

@@ -1,4 +1,4 @@
window.CookieReader = { window.cookies = {
Read: function (name) { Read: function (name) {
try { try {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
@@ -32,10 +32,3 @@ window.setHtmlLang = function (lang) {
if (document && document.documentElement) document.documentElement.lang = lang || ''; if (document && document.documentElement) document.documentElement.lang = lang || '';
} catch (e) { } } 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

@@ -20,6 +20,8 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace Atomx.Admin.Controllers namespace Atomx.Admin.Controllers
{ {
@@ -43,6 +45,8 @@ namespace Atomx.Admin.Controllers
private readonly JwtSetting _jwtSetting; private readonly JwtSetting _jwtSetting;
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
private readonly AuthenticationStateProvider _authenticationStateProvider; private readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly IValidator<LoginModel> _loginValidator;
private readonly IStringLocalizer<SignController> _localizer;
public SignController( public SignController(
ILogger<SignController> logger, ILogger<SignController> logger,
@@ -52,7 +56,9 @@ namespace Atomx.Admin.Controllers
DataContext dbContext, DataContext dbContext,
JwtSetting jwtSetting, JwtSetting jwtSetting,
ICacheService cacheService, ICacheService cacheService,
AuthenticationStateProvider authenticationStateProvider) AuthenticationStateProvider authenticationStateProvider,
IValidator<LoginModel> loginValidator,
IStringLocalizer<SignController> localizer)
{ {
_logger = logger; _logger = logger;
_identityService = identityService; _identityService = identityService;
@@ -62,6 +68,8 @@ namespace Atomx.Admin.Controllers
_jwtSetting = jwtSetting; _jwtSetting = jwtSetting;
_cacheService = cacheService; _cacheService = cacheService;
_authenticationStateProvider = authenticationStateProvider; _authenticationStateProvider = authenticationStateProvider;
_loginValidator = loginValidator;
_localizer = localizer;
} }
/// <summary> /// <summary>
@@ -73,8 +81,7 @@ namespace Atomx.Admin.Controllers
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginModel model) public async Task<IActionResult> Login([FromBody] LoginModel model)
{ {
var validator = new LoginModelValidator(); var validation = await _loginValidator.ValidateAsync(model);
var validation = validator.Validate(model);
if (!validation.IsValid) if (!validation.IsValid)
{ {
var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty; var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty;
@@ -94,18 +101,19 @@ namespace Atomx.Admin.Controllers
if (user == null) if (user == null)
{ {
return new JsonResult(new ApiResult<AuthResponse>().IsFail("用户不存在", null)); return new JsonResult(new ApiResult<AuthResponse>().IsFail(_localizer["Sign.UserNotFound"], null));
} }
// 简单密码校验(项目 uses MD5 存储示例) // 简单密码校验(项目 uses MD5 存储示例)
if (user.Password != model.Password.ToMd5Password()) if (user.Password != model.Password.ToMd5Password())
{ {
return new JsonResult(new ApiResult<AuthResponse>().IsFail("账号密码不正确", null)); return new JsonResult(new ApiResult<AuthResponse>().IsFail(_localizer["Sign.InvalidCredentials"], null));
} }
if (user.LockoutEndTime.HasValue && user.LockoutEndTime.Value > DateTime.UtcNow) if (user.LockoutEndTime.HasValue && user.LockoutEndTime.Value > DateTime.UtcNow)
{ {
return new JsonResult(new ApiResult<AuthResponse>().IsFail($"账号已锁定,解锁时间:{user.LockoutEndTime.Value.ToLocalTime()}", null)); var unlock = user.LockoutEndTime.Value.ToLocalTime();
return new JsonResult(new ApiResult<AuthResponse>().IsFail(string.Format(_localizer["Sign.Locked"], unlock), null));
} }
var tokenHandler = new JwtSecurityTokenHandler(); var tokenHandler = new JwtSecurityTokenHandler();
@@ -171,9 +179,6 @@ namespace Atomx.Admin.Controllers
var cacheKey = $"token:{HashToken(accessToken)}"; var cacheKey = $"token:{HashToken(accessToken)}";
await _cacheService.SetCacheAsync(cacheKey, user.Id, _jwtSetting.AccessTokenExpirationMinutes); await _cacheService.SetCacheAsync(cacheKey, user.Id, _jwtSetting.AccessTokenExpirationMinutes);
var authResponse = new AuthResponse var authResponse = new AuthResponse
{ {
Token = accessToken, Token = accessToken,
@@ -218,17 +223,17 @@ namespace Atomx.Admin.Controllers
var uid = _identityService.GetUserId(); var uid = _identityService.GetUserId();
if (uid == 0) if (uid == 0)
{ {
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的令牌请求", null)); return BadRequest(new ApiResult<AuthResponse>().IsFail(_localizer["Sign.InvalidTokenRequest"], null));
} }
if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken)) if (request == null || string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.RefreshToken))
{ {
return BadRequest(new ApiResult<AuthResponse>().IsFail("无效的刷新请求", null)); return BadRequest(new ApiResult<AuthResponse>().IsFail(_localizer["Sign.InvalidRefreshRequest"], null));
} }
var user = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == uid); var user = await _dbContext.Admins.FirstOrDefaultAsync(a => a.Id == uid);
if (user == null) if (user == null)
throw new SecurityTokenException("用户不存在或已被禁用"); throw new SecurityTokenException(_localizer["Sign.UserNotFound"].ToString());
try try
{ {
@@ -242,7 +247,7 @@ namespace Atomx.Admin.Controllers
!rt.IsRevoked); !rt.IsRevoked);
if (storedToken == null) if (storedToken == null)
throw new SecurityTokenException("无效的刷新令牌"); throw new SecurityTokenException(_localizer["Sign.InvalidRefreshToken"].ToString());
// 标记该 refresh token 为已撤销(一次性) // 标记该 refresh token 为已撤销(一次性)
storedToken.IsRevoked = true; storedToken.IsRevoked = true;
@@ -333,12 +338,12 @@ namespace Atomx.Admin.Controllers
catch (SecurityTokenException ex) catch (SecurityTokenException ex)
{ {
_logger.LogWarning(ex, "刷新失败:刷新令牌无效或 access token 无效"); _logger.LogWarning(ex, "刷新失败:刷新令牌无效或 access token 无效");
return Unauthorized(new ApiResult<AuthResponse>().IsFail("刷新令牌无效或已过期", null)); return Unauthorized(new ApiResult<AuthResponse>().IsFail(_localizer["Sign.RefreshTokenInvalid"], null));
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "刷新令牌时发生内部错误"); _logger.LogError(ex, "刷新令牌时发生内部错误");
return StatusCode(500, new ApiResult<AuthResponse>().IsFail("服务器内部错误", null)); return StatusCode(500, new ApiResult<AuthResponse>().IsFail(_localizer["Sign.InternalServerError"], null));
} }
} }
@@ -359,7 +364,7 @@ namespace Atomx.Admin.Controllers
.FirstOrDefaultAsync(rt => rt.Token == hashedToken); .FirstOrDefaultAsync(rt => rt.Token == hashedToken);
if (token == null || token.IsRevoked) if (token == null || token.IsRevoked)
return new JsonResult(new ApiResult<string>().IsSuccess("已退出")); return new JsonResult(new ApiResult<string>().IsSuccess(_localizer["Sign.LogoutSuccess"].ToString()));
token.IsRevoked = true; token.IsRevoked = true;
token.RevokedTime = DateTime.UtcNow; token.RevokedTime = DateTime.UtcNow;
@@ -399,10 +404,9 @@ namespace Atomx.Admin.Controllers
_logger.LogWarning(ex, "登出时清除 Cookie 失败(允许)"); _logger.LogWarning(ex, "登出时清除 Cookie 失败(允许)");
} }
return new JsonResult(new ApiResult<string>().IsSuccess("已退出")); return new JsonResult(new ApiResult<string>().IsSuccess(_localizer["Sign.LogoutSuccess"].ToString()));
} }
/// <summary> /// <summary>
/// 生成随机 refresh token明文由服务返回到客户端数据库仅存哈希 /// 生成随机 refresh token明文由服务返回到客户端数据库仅存哈希
/// </summary> /// </summary>

View File

@@ -11,6 +11,7 @@ using Atomx.Data;
using Atomx.Data.Services; using Atomx.Data.Services;
using Atomx.Utils.Json.Converts; using Atomx.Utils.Json.Converts;
using Blazored.LocalStorage; using Blazored.LocalStorage;
using FluentValidation;
using Mapster; using Mapster;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
@@ -149,6 +150,9 @@ builder.Services.AddOpenApi();
builder.Services.AddAntDesign(); builder.Services.AddAntDesign();
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring")); builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection("Monitoring"));
// ע<><D7A2> FluentValidation <20><>֤<EFBFBD><D6A4>
builder.Services.AddValidatorsFromAssembly(typeof(Atomx.Admin.Client.Validators.LoginModelValidator).Assembly);
var app = builder.Build(); var app = builder.Build();
// Preload localization files on startup to ensure server-side rendering finds translations // Preload localization files on startup to ensure server-side rendering finds translations

View File

@@ -15,5 +15,18 @@
"weather.title": "Weather", "weather.title": "Weather",
"weather.summary": "A quick weather overview", "weather.summary": "A quick weather overview",
"weather.temperature": "Temperature", "weather.temperature": "Temperature",
"weather.refresh": "Refresh" "weather.refresh": "Refresh",
"Login.Account.Empty": "Please enter your account",
"Login.Account.Length": "Account length must be between 2 and 100 characters",
"Login.Password.Empty": "Please enter your password",
"Login.Password.Length": "Password length must be between 6 and 32 characters",
"Sign.UserNotFound": "User not found",
"Sign.InvalidCredentials": "Account or password is incorrect",
"Sign.Locked": "Account locked, unlock time: {0}",
"Sign.InvalidTokenRequest": "Invalid token request",
"Sign.InvalidRefreshRequest": "Invalid refresh request",
"Sign.InvalidRefreshToken": "Invalid refresh token",
"Sign.RefreshTokenInvalid": "Refresh token invalid or expired",
"Sign.InternalServerError": "Internal server error",
"Sign.LogoutSuccess": "Logged out"
} }

View File

@@ -15,5 +15,20 @@
"weather.title": "天气", "weather.title": "天气",
"weather.summary": "天气概览", "weather.summary": "天气概览",
"weather.temperature": "温度", "weather.temperature": "温度",
"weather.refresh": "刷新" "weather.refresh": "刷新",
"Login.Account.Empty": "请输入登录账号",
"Login.Account.Length": "用户名长度必须在2-100个字符之间",
"Login.Password.Empty": "请输入登录密码",
"Login.Password.Length": "登录密码必须在6-32位长度之间",
"Sign.UserNotFound": "用户不存在",
"Sign.InvalidCredentials": "账号或密码不正确",
"Sign.Locked": "账号已锁定,解锁时间:{0}",
"Sign.InvalidTokenRequest": "无效的令牌请求",
"Sign.InvalidRefreshRequest": "无效的刷新请求",
"Sign.InvalidRefreshToken": "无效的刷新令牌",
"Sign.RefreshTokenInvalid": "刷新令牌无效或已过期",
"Sign.InternalServerError": "服务器内部错误",
"Sign.LogoutSuccess": "已退出"
} }