Browse Source

feat: add captcha service and update cache service

norm
root 3 months ago
parent
commit
dc74694c68
  1. 10
      src/CellularManagement.Application/Features/Auth/Commands/GenerateCaptcha/GenerateCaptchaCommand.cs
  2. 79
      src/CellularManagement.Application/Features/Auth/Commands/GenerateCaptcha/GenerateCaptchaCommandHandler.cs
  3. 7
      src/CellularManagement.Application/Features/Auth/Commands/GenerateCaptcha/GenerateCaptchaResponse.cs
  4. 13
      src/CellularManagement.Domain/Services/ICaptchaService.cs
  5. 2
      src/CellularManagement.Infrastructure/CellularManagement.Infrastructure.csproj
  6. 1
      src/CellularManagement.Infrastructure/DependencyInjection.cs
  7. 1
      src/CellularManagement.Infrastructure/Services/CacheService.cs
  8. 77
      src/CellularManagement.Infrastructure/Services/CaptchaService.cs
  9. 47
      src/CellularManagement.Presentation/Controllers/AuthController.cs
  10. 306
      src/CellularManagement.WebUI/public/network.svg
  11. 105
      src/CellularManagement.WebUI/src/components/auth/RegisterForm.tsx
  12. 125
      src/CellularManagement.WebUI/src/services/apiService.ts

10
src/CellularManagement.Application/Features/Auth/Commands/GenerateCaptcha/GenerateCaptchaCommand.cs

@ -0,0 +1,10 @@
using CellularManagement.Domain.Common;
using MediatR;
namespace CellularManagement.Application.Features.Auth.Commands.GenerateCaptcha;
public class GenerateCaptchaCommand : IRequest<OperationResult<GenerateCaptchaResponse>>
{
public int Length { get; set; } = 4;
public string? ClientId { get; set; }
}

79
src/CellularManagement.Application/Features/Auth/Commands/GenerateCaptcha/GenerateCaptchaCommandHandler.cs

@ -0,0 +1,79 @@
using MediatR;
using CellularManagement.Domain.Services;
using CellularManagement.Application.Common;
using System.Text;
using CellularManagement.Domain.Common;
using Microsoft.Extensions.Caching.Memory;
namespace CellularManagement.Application.Features.Auth.Commands.GenerateCaptcha;
public class GenerateCaptchaCommandHandler : IRequestHandler<GenerateCaptchaCommand, OperationResult<GenerateCaptchaResponse>>
{
private readonly ICaptchaService _captchaService;
private readonly ICacheService _cacheService;
private const string RATE_LIMIT_KEY_PREFIX = "captcha_rate_limit:";
private const int MAX_REQUESTS_PER_MINUTE = 30;
public GenerateCaptchaCommandHandler(
ICaptchaService captchaService,
ICacheService cacheService)
{
_captchaService = captchaService;
_cacheService = cacheService;
}
public async Task<OperationResult<GenerateCaptchaResponse>> Handle(
GenerateCaptchaCommand request,
CancellationToken cancellationToken)
{
try
{
// 获取客户端IP或标识
var clientId = request.ClientId ?? "anonymous";
var rateLimitKey = $"{RATE_LIMIT_KEY_PREFIX}{clientId}";
// 检查请求频率
var requestCount = _cacheService.Get<int>(rateLimitKey);
if (requestCount >= MAX_REQUESTS_PER_MINUTE)
{
var remainingTime = _cacheService.Get<DateTime>($"{rateLimitKey}:expiry");
var seconds = remainingTime != default
? (int)(remainingTime - DateTime.UtcNow).TotalSeconds
: 60;
return OperationResult<GenerateCaptchaResponse>.CreateFailure(
$"请求过于频繁,请{seconds}秒后再试");
}
// 更新请求计数
var rateLimitOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(1));
_cacheService.Set(rateLimitKey, requestCount + 1, rateLimitOptions);
_cacheService.Set($"{rateLimitKey}:expiry", DateTime.UtcNow.AddMinutes(1), rateLimitOptions);
// 生成验证码
var (code, imageBytes) = _captchaService.GenerateCaptcha(request.Length);
// 生成唯一ID
var captchaId = Guid.NewGuid().ToString();
// 将验证码存入缓存,设置5分钟过期
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
_cacheService.Set($"captcha:{captchaId}", code, cacheOptions);
// 将图片转换为Base64
var base64Image = Convert.ToBase64String(imageBytes);
return await Task.FromResult(OperationResult<GenerateCaptchaResponse>.CreateSuccess(new GenerateCaptchaResponse
{
CaptchaId = captchaId,
ImageBase64 = base64Image
}));
}
catch (Exception ex)
{
return await Task.FromResult(OperationResult<GenerateCaptchaResponse>.CreateFailure("生成验证码失败"));
}
}
}

7
src/CellularManagement.Application/Features/Auth/Commands/GenerateCaptcha/GenerateCaptchaResponse.cs

@ -0,0 +1,7 @@
namespace CellularManagement.Application.Features.Auth.Commands.GenerateCaptcha;
public class GenerateCaptchaResponse
{
public string CaptchaId { get; set; }
public string ImageBase64 { get; set; }
}

13
src/CellularManagement.Domain/Services/ICaptchaService.cs

@ -0,0 +1,13 @@
using System.Drawing;
namespace CellularManagement.Domain.Services;
public interface ICaptchaService
{
/// <summary>
/// 生成图形验证码
/// </summary>
/// <param name="length">验证码长度</param>
/// <returns>包含验证码图片和验证码值的元组</returns>
(string Code, byte[] ImageBytes) GenerateCaptcha(int length = 4);
}

2
src/CellularManagement.Infrastructure/CellularManagement.Infrastructure.csproj

@ -30,6 +30,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Scrutor" Version="4.2.2" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
<PackageReference Include="SkiaSharp" Version="2.88.6" />
</ItemGroup>
</Project>

1
src/CellularManagement.Infrastructure/DependencyInjection.cs

@ -115,6 +115,7 @@ public static class DependencyInjection
// 注册缓存服务
services.AddScoped<ICacheService, CacheService>();
services.AddScoped<ICaptchaService, CaptchaService>();
// 配置认证设置
services.Configure<AuthConfiguration>(configuration.GetSection("Auth"));

1
src/CellularManagement.Infrastructure/Services/CacheService.cs

@ -1,4 +1,3 @@
using CellularManagement.Domain.Services;
using Microsoft.Extensions.Caching.Memory;

77
src/CellularManagement.Infrastructure/Services/CaptchaService.cs

@ -0,0 +1,77 @@
using CellularManagement.Domain.Services;
using SkiaSharp;
namespace CellularManagement.Infrastructure.Services;
public class CaptchaService : ICaptchaService
{
private readonly Random _random = new();
private readonly string[] _fonts = { "Arial", "Verdana", "Times New Roman", "Tahoma" };
private readonly SKColor[] _colors = {
SKColors.Black, SKColors.Red, SKColors.Blue, SKColors.Green, SKColors.Purple, SKColors.Orange
};
public (string Code, byte[] ImageBytes) GenerateCaptcha(int length = 4)
{
string code = GenerateRandomCode(length);
int width = 120, height = 40;
using var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.White);
// 干扰线
for (int i = 0; i < 5; i++)
{
using var paint = new SKPaint
{
Color = _colors[_random.Next(_colors.Length)],
StrokeWidth = 1
};
canvas.DrawLine(
_random.Next(width), _random.Next(height),
_random.Next(width), _random.Next(height),
paint
);
}
// 干扰点
for (int i = 0; i < 100; i++)
{
using var paint = new SKPaint
{
Color = _colors[_random.Next(_colors.Length)],
StrokeWidth = 1
};
canvas.DrawPoint(_random.Next(width), _random.Next(height), paint);
}
// 绘制验证码字符
for (int i = 0; i < code.Length; i++)
{
using var paint = new SKPaint
{
Color = _colors[_random.Next(_colors.Length)],
TextSize = 24,
IsAntialias = true,
Typeface = SKTypeface.FromFamilyName(_fonts[_random.Next(_fonts.Length)], SKFontStyle.Bold)
};
float x = 10 + i * 25;
float y = 30 + _random.Next(-5, 5);
canvas.DrawText(code[i].ToString(), x, y, paint);
}
using var ms = new MemoryStream();
using var image = SKImage.FromBitmap(bitmap);
image.Encode(SKEncodedImageFormat.Png, 100).SaveTo(ms);
return (code, ms.ToArray());
}
private string GenerateRandomCode(int length)
{
const string chars = "2345678ABCDEFGHJKLMNPQRSTUVWXYZ";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[_random.Next(s.Length)]).ToArray());
}
}

47
src/CellularManagement.Presentation/Controllers/AuthController.cs

@ -16,6 +16,7 @@ using Swashbuckle.AspNetCore.Filters;
using CellularManagement.Domain.Common;
using CellularManagement.Application.Features.Auth.Commands.SendVerificationCode;
using CellularManagement.Application.Features.Auth.Commands.VerifyCode;
using CellularManagement.Application.Features.Auth.Commands.GenerateCaptcha;
namespace CellularManagement.Presentation.Controllers;
@ -38,6 +39,7 @@ public class AuthController : ApiController
/// <param name="logger">日志记录器</param>
/// <param name="cache">缓存服务</param>
/// <param name="authConfig">认证配置</param>
/// <param name="captchaService">验证码服务</param>
public AuthController(
IMediator mediator,
ILogger<AuthController> logger,
@ -330,4 +332,49 @@ public class AuthController : ApiController
OperationResult<VerifyCodeResponse>.CreateFailure("系统错误,请稍后重试"));
}
}
/// <summary>
/// 获取图形验证码
/// </summary>
/// <remarks>
/// 获取用于登录或注册的图形验证码
/// </remarks>
/// <returns>验证码图片(Base64格式)和验证码ID</returns>
/// <response code="200">成功获取验证码</response>
/// <response code="500">服务器错误</response>
[HttpGet("captcha")]
[ProducesResponseType(typeof(OperationResult<GenerateCaptchaResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OperationResult<GenerateCaptchaResponse>), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<OperationResult<GenerateCaptchaResponse>>> GetCaptcha()
{
try
{
var random = new Random();
int captchaLength = random.Next(4, 7); // 4, 5, or 6
var command = new GenerateCaptchaCommand
{
Length = captchaLength,
ClientId = HttpContext.Connection.RemoteIpAddress?.ToString()
};
var result = await mediator.Send(command);
if (result.IsSuccess)
{
_logger.LogInformation("成功生成图形验证码");
}
else
{
_logger.LogWarning("生成图形验证码失败: {Error}",
result.ErrorMessages?.FirstOrDefault());
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "生成图形验证码时发生异常");
return StatusCode(StatusCodes.Status500InternalServerError,
OperationResult<GenerateCaptchaResponse>.CreateFailure("系统错误,请稍后重试"));
}
}
}

306
src/CellularManagement.WebUI/public/network.svg

@ -0,0 +1,306 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg">
<!-- Background gradient -->
<defs>
<linearGradient id="skyGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#051937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0B2D5A;stop-opacity:1" />
</linearGradient>
<!-- Glow effects -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<filter id="signalGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
<!-- Connection patterns -->
<pattern id="gridPattern" patternUnits="userSpaceOnUse" width="40" height="40" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="40" style="stroke:#0066cc; stroke-width:0.5; stroke-opacity:0.2" />
<line x1="0" y1="0" x2="40" y2="0" style="stroke:#0066cc; stroke-width:0.5; stroke-opacity:0.2" />
</pattern>
</defs>
<!-- Sky background -->
<rect width="800" height="600" fill="url(#skyGradient)" />
<rect width="800" height="600" fill="url(#gridPattern)" opacity="0.3" />
<!-- City base/ground -->
<polygon points="0,500 800,450 800,600 0,600" fill="#0A2744" />
<!-- Buildings - Back Row -->
<rect x="50" y="250" width="60" height="250" fill="#0D2C4A" stroke="#1E5AA8" stroke-width="1" />
<rect x="150" y="220" width="70" height="280" fill="#0E3157" stroke="#1E5AA8" stroke-width="1" />
<rect x="250" y="200" width="80" height="300" fill="#0A2642" stroke="#1E5AA8" stroke-width="1" />
<rect x="450" y="270" width="65" height="230" fill="#0D2C4A" stroke="#1E5AA8" stroke-width="1" />
<rect x="550" y="220" width="50" height="280" fill="#0E3157" stroke="#1E5AA8" stroke-width="1" />
<rect x="650" y="240" width="90" height="260" fill="#0A2642" stroke="#1E5AA8" stroke-width="1" />
<!-- Main communication tower -->
<rect x="355" y="150" width="90" height="350" fill="#0D2F54" stroke="#2979CC" stroke-width="1.5" />
<!-- Tower top and communication equipment -->
<polygon points="355,150 445,150 425,90 375,90" fill="#0F3B6A" stroke="#4B9EE6" stroke-width="1" />
<rect x="375" y="90" width="50" height="20" fill="#1C4B7D" stroke="#4B9EE6" stroke-width="1" />
<circle cx="400" cy="70" r="15" fill="#2D6CB1" stroke="#64AFED" stroke-width="1.5" filter="url(#glow)" />
<!-- Communication dishes and antennas -->
<!-- Main tower equipment -->
<circle cx="400" cy="110" r="5" fill="#4B9EE6" stroke="#7DC1F7" stroke-width="0.5" filter="url(#glow)" />
<path d="M400,110 L420,95 L435,100" fill="none" stroke="#64AFED" stroke-width="1.5" />
<ellipse cx="440" cy="100" rx="8" ry="4" fill="none" stroke="#64AFED" stroke-width="1.5" transform="rotate(-15,440,100)" />
<path d="M400,110 L380,95 L365,100" fill="none" stroke="#64AFED" stroke-width="1.5" />
<ellipse cx="360" cy="100" rx="8" ry="4" fill="none" stroke="#64AFED" stroke-width="1.5" transform="rotate(15,360,100)" />
<!-- Tower signal emitters -->
<line x1="400" y1="70" x2="400" y2="40" stroke="#64AFED" stroke-width="1.5" />
<circle cx="400" cy="40" r="3" fill="#7DC1F7" filter="url(#glow)" />
<!-- Antennas on buildings -->
<line x1="80" y1="250" x2="80" y2="230" stroke="#4B9EE6" stroke-width="1.5" />
<circle cx="80" cy="230" r="3" fill="#7DC1F7" filter="url(#glow)" />
<line x1="185" y1="220" x2="185" y2="200" stroke="#4B9EE6" stroke-width="1.5" />
<circle cx="185" cy="200" r="3" fill="#7DC1F7" filter="url(#glow)" />
<line x1="290" y1="200" x2="290" y2="180" stroke="#4B9EE6" stroke-width="1.5" />
<circle cx="290" cy="180" r="3" fill="#7DC1F7" filter="url(#glow)" />
<line x1="482" y1="270" x2="482" y2="250" stroke="#4B9EE6" stroke-width="1.5" />
<circle cx="482" cy="250" r="3" fill="#7DC1F7" filter="url(#glow)" />
<line x1="575" y1="220" x2="575" y2="200" stroke="#4B9EE6" stroke-width="1.5" />
<circle cx="575" cy="200" r="3" fill="#7DC1F7" filter="url(#glow)" />
<line x1="695" y1="240" x2="695" y2="220" stroke="#4B9EE6" stroke-width="1.5" />
<circle cx="695" cy="220" r="3" fill="#7DC1F7" filter="url(#glow)" />
<!-- Building windows -->
<!-- Pattern for windows -->
<g>
<!-- Building 1 windows -->
<rect x="57" y="265" width="10" height="15" fill="#64AFED" opacity="0.7" />
<rect x="77" y="265" width="10" height="15" fill="#64AFED" opacity="0.7" />
<rect x="57" y="290" width="10" height="15" fill="#64AFED" opacity="0.7" />
<rect x="77" y="290" width="10" height="15" fill="#64AFED" opacity="0.5" />
<rect x="57" y="315" width="10" height="15" fill="#64AFED" opacity="0.7" />
<rect x="77" y="315" width="10" height="15" fill="#64AFED" opacity="0.6" />
<rect x="57" y="340" width="10" height="15" fill="#64AFED" opacity="0.5" />
<rect x="77" y="340" width="10" height="15" fill="#64AFED" opacity="0.7" />
<rect x="57" y="365" width="10" height="15" fill="#64AFED" opacity="0.7" />
<rect x="77" y="365" width="10" height="15" fill="#64AFED" opacity="0.6" />
<!-- Building 2 windows -->
<rect x="160" y="235" width="12" height="18" fill="#64AFED" opacity="0.6" />
<rect x="182" y="235" width="12" height="18" fill="#64AFED" opacity="0.7" />
<rect x="160" y="265" width="12" height="18" fill="#64AFED" opacity="0.7" />
<rect x="182" y="265" width="12" height="18" fill="#64AFED" opacity="0.5" />
<rect x="160" y="295" width="12" height="18" fill="#64AFED" opacity="0.6" />
<rect x="182" y="295" width="12" height="18" fill="#64AFED" opacity="0.7" />
<rect x="160" y="325" width="12" height="18" fill="#64AFED" opacity="0.5" />
<rect x="182" y="325" width="12" height="18" fill="#64AFED" opacity="0.7" />
<rect x="160" y="355" width="12" height="18" fill="#64AFED" opacity="0.6" />
<rect x="182" y="355" width="12" height="18" fill="#64AFED" opacity="0.7" />
<rect x="160" y="385" width="12" height="18" fill="#64AFED" opacity="0.7" />
<rect x="182" y="385" width="12" height="18" fill="#64AFED" opacity="0.5" />
<!-- Building 3 windows -->
<rect x="260" y="220" width="15" height="20" fill="#64AFED" opacity="0.7" />
<rect x="285" y="220" width="15" height="20" fill="#64AFED" opacity="0.6" />
<rect x="310" y="220" width="15" height="20" fill="#64AFED" opacity="0.5" />
<rect x="260" y="250" width="15" height="20" fill="#64AFED" opacity="0.6" />
<rect x="285" y="250" width="15" height="20" fill="#64AFED" opacity="0.7" />
<rect x="310" y="250" width="15" height="20" fill="#64AFED" opacity="0.5" />
<rect x="260" y="280" width="15" height="20" fill="#64AFED" opacity="0.7" />
<rect x="285" y="280" width="15" height="20" fill="#64AFED" opacity="0.5" />
<rect x="310" y="280" width="15" height="20" fill="#64AFED" opacity="0.6" />
<rect x="260" y="310" width="15" height="20" fill="#64AFED" opacity="0.5" />
<rect x="285" y="310" width="15" height="20" fill="#64AFED" opacity="0.7" />
<rect x="310" y="310" width="15" height="20" fill="#64AFED" opacity="0.6" />
<rect x="260" y="340" width="15" height="20" fill="#64AFED" opacity="0.7" />
<rect x="285" y="340" width="15" height="20" fill="#64AFED" opacity="0.6" />
<rect x="310" y="340" width="15" height="20" fill="#64AFED" opacity="0.5" />
<rect x="260" y="370" width="15" height="20" fill="#64AFED" opacity="0.6" />
<rect x="285" y="370" width="15" height="20" fill="#64AFED" opacity="0.7" />
<rect x="310" y="370" width="15" height="20" fill="#64AFED" opacity="0.6" />
<!-- Main tower windows -->
<rect x="370" y="170" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="395" y="170" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="420" y="170" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="370" y="205" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="395" y="205" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="420" y="205" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="370" y="240" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="395" y="240" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="420" y="240" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="370" y="275" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="395" y="275" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="420" y="275" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="370" y="310" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="395" y="310" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="420" y="310" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="370" y="345" width="15" height="25" fill="#64AFED" opacity="0.8" />
<rect x="395" y="345" width="15" height="25" fill="#64AFED" opacity="0.7" />
<rect x="420" y="345" width="15" height="25" fill="#64AFED" opacity="0.8" />
</g>
<!-- Flying vehicles -->
<g id="flyingCar1">
<ellipse cx="150" cy="140" rx="15" ry="5" fill="#1A4A7D" stroke="#4B9EE6" stroke-width="1" />
<ellipse cx="150" cy="135" rx="10" ry="3" fill="#2979CC" stroke="#64AFED" stroke-width="0.5" />
<!-- Signal connection to car -->
<path d="M150,130 C150,115 180,90 200,70" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<g id="flyingCar2">
<ellipse cx="520" cy="190" rx="15" ry="5" fill="#1A4A7D" stroke="#4B9EE6" stroke-width="1" />
<ellipse cx="520" cy="185" rx="10" ry="3" fill="#2979CC" stroke="#64AFED" stroke-width="0.5" />
<!-- Signal connection to car -->
<path d="M520,180 C520,165 450,120 400,70" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<g id="flyingCar3">
<ellipse cx="600" cy="160" rx="15" ry="5" fill="#1A4A7D" stroke="#4B9EE6" stroke-width="1" />
<ellipse cx="600" cy="155" rx="10" ry="3" fill="#2979CC" stroke="#64AFED" stroke-width="0.5" />
<!-- Signal connection to car -->
<path d="M600,150 C600,135 500,90 400,70" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<!-- Self-driving cars -->
<g id="groundCar1">
<rect x="120" y="490" width="30" height="15" rx="5" ry="5" fill="#2979CC" stroke="#4B9EE6" stroke-width="1" />
<rect x="125" y="485" width="20" height="8" rx="2" ry="2" fill="#1A4A7D" stroke="#4B9EE6" stroke-width="0.5" />
<circle cx="125" cy="505" r="3" fill="#0D2F54" stroke="#4B9EE6" stroke-width="0.5" />
<circle cx="145" cy="505" r="3" fill="#0D2F54" stroke="#4B9EE6" stroke-width="0.5" />
<!-- Signal connection to car -->
<path d="M135,485 C135,440 300,250 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<g id="groundCar2">
<rect x="320" y="510" width="30" height="15" rx="5" ry="5" fill="#2979CC" stroke="#4B9EE6" stroke-width="1" />
<rect x="325" y="505" width="20" height="8" rx="2" ry="2" fill="#1A4A7D" stroke="#4B9EE6" stroke-width="0.5" />
<circle cx="325" cy="525" r="3" fill="#0D2F54" stroke="#4B9EE6" stroke-width="0.5" />
<circle cx="345" cy="525" r="3" fill="#0D2F54" stroke="#4B9EE6" stroke-width="0.5" />
<!-- Signal connection to car -->
<path d="M335,505 C335,450 375,250 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<g id="groundCar3">
<rect x="520" y="480" width="30" height="15" rx="5" ry="5" fill="#2979CC" stroke="#4B9EE6" stroke-width="1" />
<rect x="525" y="475" width="20" height="8" rx="2" ry="2" fill="#1A4A7D" stroke="#4B9EE6" stroke-width="0.5" />
<circle cx="525" cy="495" r="3" fill="#0D2F54" stroke="#4B9EE6" stroke-width="0.5" />
<circle cx="545" cy="495" r="3" fill="#0D2F54" stroke="#4B9EE6" stroke-width="0.5" />
<!-- Signal connection to car -->
<path d="M535,475 C535,420 450,250 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<!-- People with devices -->
<g id="person1">
<!-- Body -->
<circle cx="200" cy="480" r="6" fill="#4B9EE6" />
<line x1="200" y1="486" x2="200" y2="500" stroke="#4B9EE6" stroke-width="3" />
<line x1="200" y1="490" x2="193" y2="497" stroke="#4B9EE6" stroke-width="2" />
<line x1="200" y1="490" x2="207" y2="497" stroke="#4B9EE6" stroke-width="2" />
<line x1="200" y1="500" x2="195" y2="510" stroke="#4B9EE6" stroke-width="2" />
<line x1="200" y1="500" x2="205" y2="510" stroke="#4B9EE6" stroke-width="2" />
<!-- Device -->
<rect x="193" y="497" width="4" height="6" fill="#7DC1F7" stroke="#B3E0FF" stroke-width="0.5" />
<!-- Signal connection -->
<path d="M195,497 C195,450 300,250 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<g id="person2">
<!-- Body -->
<circle cx="400" cy="520" r="6" fill="#4B9EE6" />
<line x1="400" y1="526" x2="400" y2="540" stroke="#4B9EE6" stroke-width="3" />
<line x1="400" y1="530" x2="393" y2="537" stroke="#4B9EE6" stroke-width="2" />
<line x1="400" y1="530" x2="407" y2="537" stroke="#4B9EE6" stroke-width="2" />
<line x1="400" y1="540" x2="395" y2="550" stroke="#4B9EE6" stroke-width="2" />
<line x1="400" y1="540" x2="405" y2="550" stroke="#4B9EE6" stroke-width="2" />
<!-- Device -->
<rect x="393" y="537" width="4" height="6" fill="#7DC1F7" stroke="#B3E0FF" stroke-width="0.5" />
<!-- Signal connection -->
<path d="M395,537 C395,450 398,250 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<g id="person3">
<!-- Body -->
<circle cx="600" cy="490" r="6" fill="#4B9EE6" />
<line x1="600" y1="496" x2="600" y2="510" stroke="#4B9EE6" stroke-width="3" />
<line x1="600" y1="500" x2="593" y2="507" stroke="#4B9EE6" stroke-width="2" />
<line x1="600" y1="500" x2="607" y2="507" stroke="#4B9EE6" stroke-width="2" />
<line x1="600" y1="510" x2="595" y2="520" stroke="#4B9EE6" stroke-width="2" />
<line x1="600" y1="510" x2="605" y2="520" stroke="#4B9EE6" stroke-width="2" />
<!-- Device -->
<rect x="607" y="507" width="4" height="6" fill="#7DC1F7" stroke="#B3E0FF" stroke-width="0.5" />
<!-- Signal connection -->
<path d="M609,507 C609,450 500,250 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<!-- AI symbols -->
<g id="aiSymbol1">
<circle cx="280" cy="320" r="15" fill="#0A2642" stroke="#4B9EE6" stroke-width="1" />
<text x="280" y="325" font-family="Arial" font-size="12" fill="#64AFED" text-anchor="middle">AI</text>
<!-- Signal connection -->
<path d="M280,305 C280,250 350,200 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<g id="aiSymbol2">
<circle cx="500" cy="300" r="15" fill="#0A2642" stroke="#4B9EE6" stroke-width="1" />
<text x="500" y="305" font-family="Arial" font-size="12" fill="#64AFED" text-anchor="middle">AI</text>
<!-- Signal connection -->
<path d="M500,285 C500,230 450,180 400,150" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.7" filter="url(#signalGlow)" />
</g>
<!-- Signal beams from main tower -->
<path d="M400,40 C300,60 200,80 80,230" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" filter="url(#signalGlow)" />
<path d="M400,40 C350,80 300,120 185,200" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" filter="url(#signalGlow)" />
<path d="M400,40 C370,80 340,120 290,180" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" filter="url(#signalGlow)" />
<path d="M400,40 C430,80 460,120 482,250" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" filter="url(#signalGlow)" />
<path d="M400,40 C450,80 500,120 575,200" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" filter="url(#signalGlow)" />
<path d="M400,40 C500,80 600,120 695,220" stroke="#64AFED" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" filter="url(#signalGlow)" />
<!-- Circles for network nodes -->
<circle cx="80" cy="230" r="2" fill="#B3E0FF" filter="url(#glow)" />
<circle cx="185" cy="200" r="2" fill="#B3E0FF" filter="url(#glow)" />
<circle cx="290" cy="180" r="2" fill="#B3E0FF" filter="url(#glow)" />
<circle cx="482" cy="250" r="2" fill="#B3E0FF" filter="url(#glow)" />
<circle cx="575" cy="200" r="2" fill="#B3E0FF" filter="url(#glow)" />
<circle cx="695" cy="220" r="2" fill="#B3E0FF" filter="url(#glow)" />
<!-- Data flow effect -->
<g opacity="0.8">
<circle cx="80" cy="230" r="1.5">
<animate attributeName="opacity" values="1;0.2;1" dur="3s" repeatCount="indefinite" />
</circle>
<circle cx="185" cy="200" r="1.5">
<animate attributeName="opacity" values="1;0.2;1" dur="4s" repeatCount="indefinite" />
</circle>
<circle cx="290" cy="180" r="1.5">
<animate attributeName="opacity" values="1;0.2;1" dur="3.5s" repeatCount="indefinite" />
</circle>
<circle cx="482" cy="250" r="1.5">
<animate attributeName="opacity" values="1;0.2;1" dur="2.8s" repeatCount="indefinite" />
</circle>
<circle cx="575" cy="200" r="1.5">
<animate attributeName="opacity" values="1;0.2;1" dur="3.2s" repeatCount="indefinite" />
</circle>
<circle cx="695" cy="220" r="1.5">
<animate attributeName="opacity" values="1;0.2;1" dur="3.7s" repeatCount="indefinite" />
</circle>
</g>
<!-- Digital cloud representation -->
<g opacity="0.6">
<ellipse cx="400" cy="30" rx="50" ry="20" fill="none" stroke="#7DC1F7" stroke-width="0.5" />
<ellipse cx="400" cy="30" rx="40" ry="15" fill="none" stroke="#7DC1F7" stroke-width="0.5" />
<ellipse cx="400" cy="30" rx="30" ry="10" fill="none" stroke="#7DC1F7" stroke-width="0.5" />
<text x="400" y="33" font-family="Arial" font-size="8" fill="#B3E0FF" text-anchor="middle">DATA CLOUD</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

105
src/CellularManagement.WebUI/src/components/auth/RegisterForm.tsx

@ -1,5 +1,7 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { z } from 'zod';
import { toast } from '@/components/ui/use-toast';
import { apiService } from '@/services/apiService';
// 定义表单验证规则
const registerSchema = z.object({
@ -22,14 +24,17 @@ const registerSchema = z.object({
.min(1, '确认密码不能为空'),
phoneNumber: z.string()
.regex(/^1[3-9]\d{9}$/, '电话号码格式不正确')
.optional()
.optional(),
captcha: z.string()
.min(1, '验证码不能为空')
.max(6, '验证码长度不能超过6个字符'),
}).refine((data) => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword'],
});
interface RegisterFormProps {
onSubmit: (username: string, email: string, password: string, phoneNumber?: string) => Promise<void>;
onSubmit: (username: string, email: string, password: string, phoneNumber?: string, captcha?: string, captchaId?: string) => Promise<void>;
}
export function RegisterForm({ onSubmit }: RegisterFormProps) {
@ -39,10 +44,51 @@ export function RegisterForm({ onSubmit }: RegisterFormProps) {
password: '',
confirmPassword: '',
phoneNumber: '',
captcha: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [captchaImage, setCaptchaImage] = useState<string>('');
const [captchaId, setCaptchaId] = useState<string>('');
const [captchaAvailable, setCaptchaAvailable] = useState(true);
// 获取验证码
const fetchCaptcha = async () => {
try {
const result = await apiService.getCaptcha();
if (result.success && result.data) {
setCaptchaImage(result.data.imageBase64);
setCaptchaId(result.data.captchaId);
setCaptchaAvailable(true);
} else {
setCaptchaImage('');
setCaptchaId('');
setCaptchaAvailable(false);
toast({
title: '获取验证码失败',
description: result.message || '请刷新页面重试',
variant: 'destructive',
duration: 3000,
});
}
} catch (error) {
setCaptchaImage('');
setCaptchaId('');
setCaptchaAvailable(false);
toast({
title: '获取验证码失败',
description: '请刷新页面重试',
variant: 'destructive',
duration: 3000,
});
}
};
// 组件加载时获取验证码
useEffect(() => {
fetchCaptcha();
}, []);
// 密码强度计算
const calculatePasswordStrength = (password: string): number => {
@ -129,7 +175,9 @@ export function RegisterForm({ onSubmit }: RegisterFormProps) {
formData.username,
formData.email,
formData.password,
formData.phoneNumber || undefined
formData.phoneNumber || undefined,
formData.captcha,
captchaId
);
} catch (err) {
setError(err instanceof Error ? err.message : '注册失败');
@ -260,6 +308,53 @@ export function RegisterForm({ onSubmit }: RegisterFormProps) {
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p>
)}
</div>
<div>
<label htmlFor="captcha" className="block text-sm font-medium">
</label>
<div className="flex space-x-2">
<input
id="captcha"
name="captcha"
type="text"
required
value={formData.captcha}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.captcha ? 'border-red-500' : 'border-input'
} bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`}
placeholder="请输入验证码"
disabled={isLoading || !captchaAvailable}
/>
<div className="mt-1">
{captchaImage ? (
<img
src={`data:image/png;base64,${captchaImage}`}
alt="验证码"
className="h-10 cursor-pointer"
onClick={fetchCaptcha}
title="点击刷新验证码"
/>
) : (
!captchaAvailable && (
<div className="flex items-center h-10 text-xs text-red-500">
<button
type="button"
className="ml-2 text-blue-500 underline"
onClick={fetchCaptcha}
>
</button>
</div>
)
)}
</div>
</div>
{errors.captcha && (
<p className="mt-1 text-sm text-red-500">{errors.captcha}</p>
)}
</div>
{error && (
<div className="text-sm text-red-500">
{error}
@ -269,7 +364,7 @@ export function RegisterForm({ onSubmit }: RegisterFormProps) {
<button
type="submit"
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50"
disabled={isLoading}
disabled={isLoading || !captchaAvailable}
>
{isLoading ? '注册中...' : '注册'}
</button>

125
src/CellularManagement.WebUI/src/services/apiService.ts

@ -2,12 +2,25 @@ import { LoginRequest, RegisterRequest, LoginResponse, User, OperationResult } f
import { httpClient } from '@/lib/http-client';
import { AUTH_CONSTANTS } from '@/constants/auth';
interface CaptchaResponse {
captchaId: string;
imageBase64: string;
}
interface ApiResponse<T> {
successMessage: string | null;
errorMessages: string[] | null;
data: T;
isSuccess: boolean;
}
export interface ApiService {
login: (request: LoginRequest) => Promise<OperationResult<LoginResponse>>;
register: (request: RegisterRequest) => Promise<OperationResult<void>>;
refreshToken: (refreshToken: string) => Promise<OperationResult<LoginResponse>>;
logout: () => Promise<OperationResult<void>>;
getCurrentUser: () => Promise<OperationResult<User>>;
getCaptcha: () => Promise<OperationResult<CaptchaResponse>>;
}
class ApiError extends Error {
@ -20,12 +33,19 @@ class ApiError extends Error {
export const apiService: ApiService = {
login: async (request: LoginRequest): Promise<OperationResult<LoginResponse>> => {
try {
const response = await httpClient.post<LoginResponse>('/auth/login', request);
return {
success: true,
data: response.data,
message: AUTH_CONSTANTS.MESSAGES.LOGIN_SUCCESS
};
const response = await httpClient.post<ApiResponse<LoginResponse>>('/auth/login', request);
if (response.isSuccess) {
return {
success: true,
data: response.data,
message: response.successMessage || AUTH_CONSTANTS.MESSAGES.LOGIN_SUCCESS
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || AUTH_CONSTANTS.MESSAGES.LOGIN_FAILED
};
}
} catch (error: any) {
return {
success: false,
@ -36,11 +56,18 @@ export const apiService: ApiService = {
register: async (request: RegisterRequest): Promise<OperationResult<void>> => {
try {
await httpClient.post('/auth/register', request);
return {
success: true,
message: AUTH_CONSTANTS.MESSAGES.REGISTER_SUCCESS
};
const response = await httpClient.post<ApiResponse<void>>('/auth/register', request);
if (response.isSuccess) {
return {
success: true,
message: response.successMessage || AUTH_CONSTANTS.MESSAGES.REGISTER_SUCCESS
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || AUTH_CONSTANTS.MESSAGES.REGISTER_FAILED
};
}
} catch (error: any) {
return {
success: false,
@ -51,12 +78,19 @@ export const apiService: ApiService = {
refreshToken: async (refreshToken: string): Promise<OperationResult<LoginResponse>> => {
try {
const response = await httpClient.post<LoginResponse>('/auth/refresh-token', { refreshToken });
return {
success: true,
data: response.data,
message: AUTH_CONSTANTS.MESSAGES.TOKEN_REFRESHED
};
const response = await httpClient.post<ApiResponse<LoginResponse>>('/auth/refresh-token', { refreshToken });
if (response.isSuccess) {
return {
success: true,
data: response.data,
message: response.successMessage || AUTH_CONSTANTS.MESSAGES.TOKEN_REFRESHED
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || AUTH_CONSTANTS.MESSAGES.TOKEN_REFRESH_FAILED
};
}
} catch (error: any) {
return {
success: false,
@ -67,11 +101,18 @@ export const apiService: ApiService = {
logout: async (): Promise<OperationResult<void>> => {
try {
await httpClient.post('/auth/logout');
return {
success: true,
message: AUTH_CONSTANTS.MESSAGES.LOGOUT_SUCCESS
};
const response = await httpClient.post<ApiResponse<void>>('/auth/logout');
if (response.isSuccess) {
return {
success: true,
message: response.successMessage || AUTH_CONSTANTS.MESSAGES.LOGOUT_SUCCESS
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || AUTH_CONSTANTS.MESSAGES.LOGOUT_FAILED
};
}
} catch (error: any) {
return {
success: false,
@ -82,16 +123,46 @@ export const apiService: ApiService = {
getCurrentUser: async (): Promise<OperationResult<User>> => {
try {
const response = await httpClient.get<User>('/users/current');
const response = await httpClient.get<ApiResponse<User>>('/users/current');
if (response.isSuccess) {
return {
success: true,
data: response.data,
message: response.successMessage || AUTH_CONSTANTS.MESSAGES.USER_FETCHED
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED
};
}
} catch (error: any) {
return {
success: true,
data: response.data,
message: AUTH_CONSTANTS.MESSAGES.USER_FETCHED
success: false,
message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED
};
}
},
getCaptcha: async (): Promise<OperationResult<CaptchaResponse>> => {
try {
const response = await httpClient.get<ApiResponse<CaptchaResponse>>('/auth/captcha');
if (response.isSuccess) {
return {
success: true,
data: response.data,
message: response.successMessage || '验证码获取成功'
};
} else {
return {
success: false,
message: response?.errorMessages?.join(', ') || '验证码获取失败'
};
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || AUTH_CONSTANTS.MESSAGES.USER_FETCH_FAILED
message: error.response?.data?.message || '验证码获取失败'
};
}
}

Loading…
Cancel
Save