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="Blazored.LocalStorage" Version="4.5.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.WebAssembly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />

View File

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

View File

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

View File

@@ -169,7 +169,7 @@ namespace Atomx.Admin.Client.Services
{
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);
if (!string.IsNullOrEmpty(cookieVal))
{

View File

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

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) {
try {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
@@ -32,10 +32,3 @@ window.setHtmlLang = function (lang) {
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

@@ -20,6 +20,8 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace Atomx.Admin.Controllers
{
@@ -43,6 +45,8 @@ namespace Atomx.Admin.Controllers
private readonly JwtSetting _jwtSetting;
private readonly ICacheService _cacheService;
private readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly IValidator<LoginModel> _loginValidator;
private readonly IStringLocalizer<SignController> _localizer;
public SignController(
ILogger<SignController> logger,
@@ -52,7 +56,9 @@ namespace Atomx.Admin.Controllers
DataContext dbContext,
JwtSetting jwtSetting,
ICacheService cacheService,
AuthenticationStateProvider authenticationStateProvider)
AuthenticationStateProvider authenticationStateProvider,
IValidator<LoginModel> loginValidator,
IStringLocalizer<SignController> localizer)
{
_logger = logger;
_identityService = identityService;
@@ -62,6 +68,8 @@ namespace Atomx.Admin.Controllers
_jwtSetting = jwtSetting;
_cacheService = cacheService;
_authenticationStateProvider = authenticationStateProvider;
_loginValidator = loginValidator;
_localizer = localizer;
}
/// <summary>
@@ -73,8 +81,7 @@ namespace Atomx.Admin.Controllers
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginModel model)
{
var validator = new LoginModelValidator();
var validation = validator.Validate(model);
var validation = await _loginValidator.ValidateAsync(model);
if (!validation.IsValid)
{
var message = validation.Errors.FirstOrDefault()?.ErrorMessage ?? string.Empty;
@@ -94,18 +101,19 @@ namespace Atomx.Admin.Controllers
if (user == null)
{
return new JsonResult(new ApiResult<AuthResponse>().IsFail("用户不存在", null));
return new JsonResult(new ApiResult<AuthResponse>().IsFail(_localizer["Sign.UserNotFound"], null));
}
// 简单密码校验(项目 uses MD5 存储示例)
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)
{
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();
@@ -171,9 +179,6 @@ namespace Atomx.Admin.Controllers
var cacheKey = $"token:{HashToken(accessToken)}";
await _cacheService.SetCacheAsync(cacheKey, user.Id, _jwtSetting.AccessTokenExpirationMinutes);
var authResponse = new AuthResponse
{
Token = accessToken,
@@ -218,17 +223,17 @@ namespace Atomx.Admin.Controllers
var uid = _identityService.GetUserId();
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))
{
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);
if (user == null)
throw new SecurityTokenException("用户不存在或已被禁用");
throw new SecurityTokenException(_localizer["Sign.UserNotFound"].ToString());
try
{
@@ -242,7 +247,7 @@ namespace Atomx.Admin.Controllers
!rt.IsRevoked);
if (storedToken == null)
throw new SecurityTokenException("无效的刷新令牌");
throw new SecurityTokenException(_localizer["Sign.InvalidRefreshToken"].ToString());
// 标记该 refresh token 为已撤销(一次性)
storedToken.IsRevoked = true;
@@ -333,12 +338,12 @@ namespace Atomx.Admin.Controllers
catch (SecurityTokenException ex)
{
_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)
{
_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);
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.RevokedTime = DateTime.UtcNow;
@@ -399,10 +404,9 @@ namespace Atomx.Admin.Controllers
_logger.LogWarning(ex, "登出时清除 Cookie 失败(允许)");
}
return new JsonResult(new ApiResult<string>().IsSuccess("已退出"));
return new JsonResult(new ApiResult<string>().IsSuccess(_localizer["Sign.LogoutSuccess"].ToString()));
}
/// <summary>
/// 生成随机 refresh token明文由服务返回到客户端数据库仅存哈希
/// </summary>

View File

@@ -11,6 +11,7 @@ using Atomx.Data;
using Atomx.Data.Services;
using Atomx.Utils.Json.Converts;
using Blazored.LocalStorage;
using FluentValidation;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
@@ -149,6 +150,9 @@ builder.Services.AddOpenApi();
builder.Services.AddAntDesign();
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();
// Preload localization files on startup to ensure server-side rendering finds translations

View File

@@ -15,5 +15,18 @@
"weather.title": "Weather",
"weather.summary": "A quick weather overview",
"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.summary": "天气概览",
"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": "已退出"
}