feat: Support multiple vector database selection, add InMemory and Qdrant adapters, introduce admin dashboard

This commit is contained in:
shiyu
2025-05-31 21:00:48 +08:00
parent b2bacc54a9
commit 44d2616fd4
51 changed files with 5498 additions and 1214 deletions

View File

@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Foxel.Models;
using Foxel.Models.Response.Picture;
using Foxel.Controllers;
using Foxel.Services.Management;
namespace Foxel.Api.Management;
[Authorize(Roles = "Administrator")]
[Route("api/management/picture")]
public class PictureManagementController(IPictureManagementService pictureManagementService) : BaseApiController
{
[HttpGet("get_pictures")]
public async Task<ActionResult<PaginatedResult<PictureResponse>>> GetPictures(
[FromQuery] int page = 1, [FromQuery] int pageSize = 10)
{
try
{
var pictures = await pictureManagementService.GetPicturesAsync(page, pageSize);
return PaginatedSuccess(pictures.Data, pictures.TotalCount, pictures.Page, pictures.PageSize);
}
catch (Exception ex)
{
return PaginatedError<PictureResponse>($"获取图片列表失败: {ex.Message}", 500);
}
}
[HttpGet("get_picture/{id}")]
public async Task<ActionResult<BaseResult<PictureResponse>>> GetPictureById(int id)
{
try
{
var picture = await pictureManagementService.GetPictureByIdAsync(id);
return Success(picture, "图片获取成功");
}
catch (KeyNotFoundException)
{
return Error<PictureResponse>("找不到指定图片", 404);
}
catch (Exception ex)
{
return Error<PictureResponse>($"获取图片失败: {ex.Message}", 500);
}
}
[HttpPost("delete_picture")]
public async Task<ActionResult<BaseResult<bool>>> DeletePicture([FromBody] int id)
{
try
{
var result = await pictureManagementService.DeletePictureAsync(id);
return Success(result, "图片删除成功");
}
catch (KeyNotFoundException)
{
return Error<bool>("找不到要删除的图片", 404);
}
catch (Exception ex)
{
return Error<bool>($"删除图片失败: {ex.Message}", 500);
}
}
[HttpPost("batch_delete_pictures")]
public async Task<ActionResult<BaseResult<BatchDeleteResult>>> BatchDeletePictures([FromBody] List<int> ids)
{
try
{
if (ids.Count == 0)
{
return Error<BatchDeleteResult>("未提供图片ID");
}
var result = await pictureManagementService.BatchDeletePicturesAsync(ids);
return Success(result, $"成功删除 {result.SuccessCount} 张图片,失败 {result.FailedCount} 张");
}
catch (Exception ex)
{
return Error<BatchDeleteResult>($"批量删除图片失败: {ex.Message}", 500);
}
}
[HttpGet("get_pictures_by_user/{userId}")]
public async Task<ActionResult<PaginatedResult<PictureResponse>>> GetPicturesByUserId(
int userId, [FromQuery] int page = 1, [FromQuery] int pageSize = 10)
{
try
{
var pictures = await pictureManagementService.GetPicturesByUserIdAsync(userId, page, pageSize);
return PaginatedSuccess(pictures.Data, pictures.TotalCount, pictures.Page, pictures.PageSize);
}
catch (Exception ex)
{
return PaginatedError<PictureResponse>($"获取用户图片列表失败: {ex.Message}", 500);
}
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Foxel.Controllers;
using Foxel.Models;
using Foxel.Services.VectorDB;
namespace Foxel.Api.Management;
[Authorize(Roles = "Administrator")]
[Route("api/management/system")]
public class SystemManagementController(IVectorDbService vectorDbService) : BaseApiController
{
private readonly VectorDbManager _vectorDbManager = (VectorDbManager)vectorDbService;
[HttpPost("vector-db/switch")]
public async Task<ActionResult<BaseResult<bool>>> SwitchVectorDb([FromBody] SwitchVectorDbRequest request)
{
try
{
// 将字符串转换为枚举类型(如果需要)
if (Enum.TryParse<VectorDbType>(request.Type, out var dbType))
{
await _vectorDbManager.SwitchVectorDbAsync(dbType);
return Success(true, $"已切换到 {request.Type} 向量数据库");
}
else
{
return Error<bool>($"无效的向量数据库类型: {request.Type}", 400);
}
}
catch (Exception ex)
{
return Error<bool>($"切换向量数据库失败: {ex.Message}", 500);
}
}
[HttpGet("vector-db/current")]
public ActionResult<BaseResult<VectorDbInfo>> GetCurrentVectorDb()
{
try
{
var currentType = _vectorDbManager.GetCurrentVectorDbType();
var info = new VectorDbInfo { Type = currentType.ToString() };
return Success(info, "获取当前向量数据库类型成功");
}
catch (Exception ex)
{
return Error<VectorDbInfo>($"获取当前向量数据库类型失败: {ex.Message}", 500);
}
}
[HttpDelete("vector-db/clear")]
public async Task<ActionResult<BaseResult<bool>>> ClearVectors()
{
try
{
await _vectorDbManager.ClearVectorsAsync();
return Success(true, "向量数据库清空成功");
}
catch (Exception ex)
{
return Error<bool>($"清空向量数据库失败: {ex.Message}", 500);
}
}
[HttpPost("vector-db/rebuild")]
public async Task<ActionResult<BaseResult<bool>>> RebuildVectors()
{
try
{
await _vectorDbManager.ClearVectorsAsync();
_ = _vectorDbManager.BuildUserPictureVectorsAsync();
return Success(true, "向量数据库重建中,请稍后检查状态");
}
catch (Exception ex)
{
return Error<bool>($"重建向量数据库失败: {ex.Message}", 500);
}
}
}
public class SwitchVectorDbRequest
{
public string Type { get; set; } = string.Empty;
}
public class VectorDbInfo
{
public string Type { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,124 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Foxel.Models;
using Foxel.Models.Request.User;
using Foxel.Models.Response.User;
using Foxel.Controllers;
using Foxel.Services.Management;
namespace Foxel.Api.Management;
[Authorize(Roles = "Administrator")]
[Route("api/management/user")]
public class UserManagementController(IUserManagementService userManagementService) : BaseApiController
{
[HttpGet("get_users")]
public async Task<ActionResult<PaginatedResult<UserResponse>>> GetUsers(
[FromQuery] int page = 1, [FromQuery] int pageSize = 10)
{
try
{
var users = await userManagementService.GetUsersAsync(page, pageSize);
return PaginatedSuccess(users.Data, users.TotalCount, users.Page, users.PageSize);
}
catch (Exception ex)
{
return PaginatedError<UserResponse>($"获取用户列表失败: {ex.Message}", 500);
}
}
[HttpGet("get_user/{id}")]
public async Task<ActionResult<BaseResult<UserResponse>>> GetUserById(int id)
{
try
{
var user = await userManagementService.GetUserByIdAsync(id);
return Success(user, "用户获取成功");
}
catch (KeyNotFoundException)
{
return Error<UserResponse>("找不到指定用户", 404);
}
catch (Exception ex)
{
return Error<UserResponse>($"获取用户失败: {ex.Message}", 500);
}
}
[HttpPost("create_user")]
public async Task<ActionResult<BaseResult<UserResponse>>> CreateUser([FromBody] CreateUserRequest request)
{
try
{
var user = await userManagementService.CreateUserAsync(request.UserName, request.Email, request.Password, request.Role);
return Success(user, "用户创建成功");
}
catch (ArgumentException ex)
{
return Error<UserResponse>(ex.Message);
}
catch (Exception ex)
{
return Error<UserResponse>($"创建用户失败: {ex.Message}", 500);
}
}
[HttpPost("update_user")]
public async Task<ActionResult<BaseResult<UserResponse>>> UpdateUser([FromBody] UpdateUserRequest request)
{
try
{
var user = await userManagementService.UpdateUserAsync(request.Id, request.UserName, request.Email, request.Role);
return Success(user, "用户更新成功");
}
catch (KeyNotFoundException)
{
return Error<UserResponse>("找不到要更新的用户", 404);
}
catch (ArgumentException ex)
{
return Error<UserResponse>(ex.Message);
}
catch (Exception ex)
{
return Error<UserResponse>($"更新用户失败: {ex.Message}", 500);
}
}
[HttpPost("delete_user")]
public async Task<ActionResult<BaseResult<bool>>> DeleteUser([FromBody] int id)
{
try
{
var result = await userManagementService.DeleteUserAsync(id);
return Success(result, "用户删除成功");
}
catch (KeyNotFoundException)
{
return Error<bool>("找不到要删除的用户", 404);
}
catch (Exception ex)
{
return Error<bool>($"删除用户失败: {ex.Message}", 500);
}
}
[HttpPost("batch_delete_users")]
public async Task<ActionResult<BaseResult<BatchDeleteResult>>> BatchDeleteUsers([FromBody] List<int> ids)
{
try
{
if (ids.Count == 0)
{
return Error<BatchDeleteResult>("未提供用户ID");
}
var result = await userManagementService.BatchDeleteUsersAsync(ids);
return Success(result, $"成功删除 {result.SuccessCount} 个用户,失败 {result.FailedCount} 个");
}
catch (Exception ex)
{
return Error<BatchDeleteResult>($"批量删除用户失败: {ex.Message}", 500);
}
}
}

View File

@@ -23,5 +23,6 @@ public static class ApplicationBuilderExtensions
public static void UseApplicationOpenApi(this WebApplication app)
{
app.MapOpenApi();
app.MapScalarApiReference();
}
}

View File

@@ -9,9 +9,11 @@ using Foxel.Services.Auth;
using Foxel.Services.Background;
using Foxel.Services.Configuration;
using Foxel.Services.Initializer;
using Foxel.Services.Management;
using Foxel.Services.Media;
using Foxel.Services.Storage;
using Foxel.Services.Storage.Providers;
using Foxel.Services.VectorDB;
namespace Foxel.Extensions;
@@ -25,6 +27,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IAuthService, AuthService>();
services.AddSingleton<ITagService, TagService>();
services.AddSingleton<IAlbumService, AlbumService>();
services.AddSingleton<IUserManagementService, UserManagementService>();
services.AddSingleton<IPictureManagementService, PictureManagementService>();
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
services.AddHostedService<QueuedHostedService>();
services.AddSingleton<LocalStorageProvider>();
@@ -97,4 +101,11 @@ public static class ServiceCollectionExtensions
policy => { policy.WithOrigins().AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); });
});
}
public static void AddVectorDbServices(this IServiceCollection services)
{
services.AddSingleton<VectorDbManager>();
services.AddSingleton<IVectorDbService>(provider =>
provider.GetRequiredService<VectorDbManager>());
}
}

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.0.5" />
<PackageReference Include="AWSSDK.S3" Version="4.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.5" />
@@ -17,6 +17,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.54.0-preview" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.54.0-preview" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Scalar.AspNetCore" Version="2.4.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />

View File

@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace Foxel.Models.Request.User;
public class CreateUserRequest
{
[Required(ErrorMessage = "用户名不能为空")]
public string UserName { get; set; }
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email { get; set; }
[Required(ErrorMessage = "密码不能为空")]
[MinLength(6, ErrorMessage = "密码长度不能少于6个字符")]
public string Password { get; set; }
[Required(ErrorMessage = "角色不能为空")]
public string Role { get; set; } = "User";
}
public class UpdateUserRequest
{
[Required]
public int Id { get; set; }
[Required(ErrorMessage = "用户名不能为空")]
public string UserName { get; set; }
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email { get; set; }
[Required(ErrorMessage = "角色不能为空")]
public string Role { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace Foxel.Models.Response.User;
public class UserResponse
{
public int Id { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
public string Role { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -4,7 +4,7 @@ using Microsoft.Extensions.VectorData;
public class PictureVector
{
[VectorStoreKey] public int Id { get; set; }
[VectorStoreKey] public ulong Id { get; set; }
[VectorStoreData] public string? Name { get; set; }
[VectorStoreVector(Dimensions: 1024, DistanceFunction = DistanceFunction.CosineSimilarity)]

View File

@@ -16,7 +16,8 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddApplicationAuthentication();
builder.Services.AddApplicationAuthorization();
builder.Services.AddApplicationCors();
builder.Services.AddSingleton<VectorDbService>();
builder.Services.AddVectorDbServices();
builder.Services.AddHostedService<VectorDbInitializer>();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;

View File

@@ -33,12 +33,14 @@ public class AuthService(IDbContextFactory<MyDbContext> dbContextFactory, IConfi
Email = request.Email,
PasswordHash = HashPassword(request.Password),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
UpdatedAt = DateTime.UtcNow,
RoleId = 2,
Role = null,
};
var userCount = await context.Users.CountAsync();
if (userCount == 0)
{
var role = await context.Roles.FirstOrDefaultAsync(r => r.Id == 1);
var role = await context.Roles.FirstOrDefaultAsync(r => r.Name == "Administrator");
user.RoleId = 1;
user.Role = role;
}

View File

@@ -5,6 +5,7 @@ using Foxel.Services.AI;
using Foxel.Services.Attributes;
using Foxel.Services.Configuration;
using Foxel.Services.Storage;
using Foxel.Services.VectorDB;
using Foxel.Utils;
using Microsoft.EntityFrameworkCore;
@@ -32,7 +33,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
_activeTasks = new ConcurrentDictionary<Guid, PictureProcessingTask>();
_pictureStatus = new ConcurrentDictionary<int, PictureProcessingStatus>();
_processingTasks = new List<Task>();
_maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 4).Result;
_maxConcurrentTasks = configuration.GetValueAsync("BackgroundTasks:MaxConcurrentTasks", 10).Result;
_signal = new SemaphoreSlim(_maxConcurrentTasks);
var options = new BoundedChannelOptions(10000)
{
@@ -304,10 +305,10 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
picture.Embedding = embedding;
if (picture.UserId.HasValue && embedding.Length > 0)
{
var vectorDbService = scope.ServiceProvider.GetRequiredService<VectorDB.VectorDbService>();
var pictureVector = new Foxel.Models.Vector.PictureVector
var vectorDbService = scope.ServiceProvider.GetRequiredService<IVectorDbService>();
var pictureVector = new Models.Vector.PictureVector
{
Id = picture.Id,
Id = (ulong)picture.Id,
Name = picture.Name,
Embedding = embedding
};

View File

@@ -72,6 +72,12 @@ public class DatabaseInitializer(
await EnsureConfigExistsAsync(key, value);
}
// 确保向量数据库配置存在
if (!await configService.ExistsAsync("VectorDb:Type"))
{
await configService.SetConfigAsync("VectorDb:Type", "InMemory", "向量数据库类型");
}
// 初始化管理员角色和用户
await InitializeAdminRoleAndUserAsync();
@@ -128,6 +134,22 @@ public class DatabaseInitializer(
await context.SaveChangesAsync();
}
// 检查并创建用户角色
var userRole = await context.Roles.FirstOrDefaultAsync(r => r.Name == "User");
if (userRole == null)
{
logger.LogInformation("创建用户角色");
userRole = new Role
{
Name = "User",
Description = "普通用户角色",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await context.Roles.AddAsync(userRole);
await context.SaveChangesAsync();
}
logger.LogInformation("请注意,第一个注册的用户将自动成为管理员");
}
}

View File

@@ -0,0 +1,13 @@
using Foxel.Models;
using Foxel.Models.Response.Picture;
namespace Foxel.Services.Management;
public interface IPictureManagementService
{
Task<PaginatedResult<PictureResponse>> GetPicturesAsync(int page = 1, int pageSize = 10);
Task<PictureResponse> GetPictureByIdAsync(int id);
Task<bool> DeletePictureAsync(int id);
Task<BatchDeleteResult> BatchDeletePicturesAsync(List<int> ids);
Task<PaginatedResult<PictureResponse>> GetPicturesByUserIdAsync(int userId, int page = 1, int pageSize = 10);
}

View File

@@ -0,0 +1,21 @@
using Foxel.Models;
using Foxel.Models.Response.User;
namespace Foxel.Services.Management;
public interface IUserManagementService
{
Task<PaginatedResult<UserResponse>> GetUsersAsync(int page = 1, int pageSize = 10);
Task<UserResponse> GetUserByIdAsync(int id);
Task<UserResponse> CreateUserAsync(string userName, string email, string password, string role);
Task<UserResponse> UpdateUserAsync(int id, string userName, string email, string role);
Task<bool> DeleteUserAsync(int id);
Task<BatchDeleteResult> BatchDeleteUsersAsync(List<int> ids);
}
public class BatchDeleteResult
{
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public List<int> FailedIds { get; set; } = new();
}

View File

@@ -0,0 +1,218 @@
using Foxel.Models;
using Foxel.Models.Response.Picture;
using Foxel.Services.Configuration;
using Foxel.Services.Storage;
using Microsoft.EntityFrameworkCore;
namespace Foxel.Services.Management;
public class PictureManagementService(
IDbContextFactory<MyDbContext> contextFactory,
IStorageService storageService) : IPictureManagementService
{
public async Task<PaginatedResult<PictureResponse>> GetPicturesAsync(int page = 1, int pageSize = 10)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 10;
await using var dbContext = await contextFactory.CreateDbContextAsync();
// 构建查询
var query = dbContext.Pictures
.Include(p => p.User)
.OrderByDescending(p => p.CreatedAt);
// 获取总数和分页数据
var totalCount = await query.CountAsync();
var pictures = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// 转换为响应模型
var pictureResponses = pictures.Select(picture => new PictureResponse
{
Id = picture.Id,
Name = picture.Name,
Path = storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result,
ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result,
Description = picture.Description,
CreatedAt = picture.CreatedAt,
TakenAt = picture.TakenAt,
ExifInfo = picture.ExifInfo,
UserId = picture.UserId,
Username = picture.User?.UserName,
Tags = picture.Tags?.Select(t => t.Name).ToList(),
AlbumId = picture.AlbumId,
AlbumName = picture.Album?.Name,
Permission = picture.Permission,
FavoriteCount = picture.Favorites?.Count ?? 0,
}).ToList();
return new PaginatedResult<PictureResponse>
{
Data = pictureResponses,
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
}
public async Task<PictureResponse> GetPictureByIdAsync(int id)
{
await using var dbContext = await contextFactory.CreateDbContextAsync();
var picture = await dbContext.Pictures
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == id);
if (picture == null)
throw new KeyNotFoundException($"找不到ID为{id}的图片");
return new PictureResponse
{
Id = picture.Id,
Name = picture.Name,
Path = picture.Path,
ThumbnailPath = picture.ThumbnailPath,
Description = picture.Description,
CreatedAt = picture.CreatedAt,
TakenAt = picture.TakenAt,
ExifInfo = picture.ExifInfo,
UserId = picture.UserId,
Username = picture.User?.UserName,
Tags = picture.Tags?.Select(t => t.Name).ToList(),
AlbumId = picture.AlbumId,
AlbumName = picture.Album?.Name,
Permission = picture.Permission,
ProcessingStatus = picture.ProcessingStatus,
ProcessingError = picture.ProcessingError,
ProcessingProgress = picture.ProcessingProgress,
FavoriteCount = picture.Favorites?.Count ?? 0,
IsFavorited = false
};
}
public async Task<bool> DeletePictureAsync(int id)
{
await using var dbContext = await contextFactory.CreateDbContextAsync();
var picture = await dbContext.Pictures.FindAsync(id);
if (picture == null)
throw new KeyNotFoundException($"找不到ID为{id}的图片");
// 保存文件路径信息用于后续删除
var filePath = picture.Path;
var thumbnailPath = picture.ThumbnailPath;
var storageType = picture.StorageType;
// 删除数据库记录
dbContext.Pictures.Remove(picture);
await dbContext.SaveChangesAsync();
// 删除物理文件
try
{
if (!string.IsNullOrEmpty(filePath))
{
await storageService.ExecuteAsync(storageType,
provider => provider.DeleteAsync(filePath));
}
if (!string.IsNullOrEmpty(thumbnailPath))
{
await storageService.ExecuteAsync(storageType,
provider => provider.DeleteAsync(thumbnailPath));
}
}
catch (Exception ex)
{
Console.WriteLine($"删除图片文件时出错:{ex.Message}");
}
return true;
}
public async Task<BatchDeleteResult> BatchDeletePicturesAsync(List<int> ids)
{
var result = new BatchDeleteResult();
foreach (var id in ids)
{
try
{
var success = await DeletePictureAsync(id);
if (success)
{
result.SuccessCount++;
}
else
{
result.FailedCount++;
result.FailedIds.Add(id);
}
}
catch
{
result.FailedCount++;
result.FailedIds.Add(id);
}
}
return result;
}
public async Task<PaginatedResult<PictureResponse>> GetPicturesByUserIdAsync(int userId, int page = 1, int pageSize = 10)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 10;
await using var dbContext = await contextFactory.CreateDbContextAsync();
// 构建查询
var query = dbContext.Pictures
.Include(p => p.User)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.CreatedAt);
// 获取总数和分页数据
var totalCount = await query.CountAsync();
var pictures = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// 转换为响应模型
var pictureResponses = pictures.Select(picture => new PictureResponse
{
Id = picture.Id,
Name = picture.Name,
Path = picture.Path,
ThumbnailPath = picture.ThumbnailPath,
Description = picture.Description,
CreatedAt = picture.CreatedAt,
TakenAt = picture.TakenAt,
ExifInfo = picture.ExifInfo,
UserId = picture.UserId,
Username = picture.User?.UserName,
Tags = picture.Tags?.Select(t => t.Name).ToList(),
AlbumId = picture.AlbumId,
AlbumName = picture.Album?.Name,
Permission = picture.Permission,
ProcessingStatus = picture.ProcessingStatus,
ProcessingError = picture.ProcessingError,
ProcessingProgress = picture.ProcessingProgress,
FavoriteCount = picture.Favorites?.Count ?? 0,
IsFavorited = false
}).ToList();
return new PaginatedResult<PictureResponse>
{
Data = pictureResponses,
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
}
}

View File

@@ -0,0 +1,226 @@
using System.Security.Cryptography;
using System.Text;
using Foxel.Models;
using Foxel.Models.Response.User;
using Microsoft.EntityFrameworkCore;
namespace Foxel.Services.Management;
public class UserManagementService(
IDbContextFactory<MyDbContext> contextFactory) : IUserManagementService
{
public async Task<PaginatedResult<UserResponse>> GetUsersAsync(int page = 1, int pageSize = 10)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 10;
await using var dbContext = await contextFactory.CreateDbContextAsync();
// 构建查询
var query = dbContext.Users
.Include(u => u.Role)
.OrderByDescending(u => u.CreatedAt);
// 获取总数和分页数据
var totalCount = await query.CountAsync();
var users = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// 转换为响应模型
var userResponses = users.Select(user => new UserResponse
{
Id = user.Id,
UserName = user.UserName,
Email = user.Email,
Role = user.Role?.Name ?? "User",
CreatedAt = user.CreatedAt,
}).ToList();
return new PaginatedResult<UserResponse>
{
Data = userResponses,
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
}
public async Task<UserResponse> GetUserByIdAsync(int id)
{
await using var dbContext = await contextFactory.CreateDbContextAsync();
var user = await dbContext.Users
.Include(u => u.Role)
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
throw new KeyNotFoundException($"找不到ID为{id}的用户");
return new UserResponse
{
Id = user.Id,
UserName = user.UserName,
Email = user.Email,
Role = user.Role?.Name ?? "User",
CreatedAt = user.CreatedAt,
};
}
public async Task<UserResponse> CreateUserAsync(string userName, string email, string password, string roleName)
{
if (string.IsNullOrWhiteSpace(userName))
throw new ArgumentException("用户名不能为空", nameof(userName));
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("邮箱不能为空", nameof(email));
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("密码不能为空", nameof(password));
await using var dbContext = await contextFactory.CreateDbContextAsync();
// 检查角色是否存在
var role = await dbContext.Roles.FirstOrDefaultAsync(r => r.Name == roleName);
if (role == null)
throw new ArgumentException($"角色 '{roleName}' 不存在", nameof(roleName));
// 检查用户名是否已存在
if (await dbContext.Users.AnyAsync(u => u.UserName == userName))
throw new ArgumentException($"用户名 '{userName}' 已被使用", nameof(userName));
// 检查邮箱是否已存在
if (await dbContext.Users.AnyAsync(u => u.Email == email))
throw new ArgumentException($"邮箱 '{email}' 已被使用", nameof(email));
// 生成密码哈希
string passwordHash = HashPassword(password);
// 创建新用户
var user = new Models.DataBase.User
{
UserName = userName,
Email = email,
PasswordHash = passwordHash,
RoleId = role.Id,
Role = role,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
// 添加用户
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync();
return new UserResponse
{
Id = user.Id,
UserName = user.UserName,
Email = user.Email,
Role = roleName,
CreatedAt = user.CreatedAt,
};
}
public async Task<UserResponse> UpdateUserAsync(int id, string userName, string email, string roleName)
{
await using var dbContext = await contextFactory.CreateDbContextAsync();
var user = await dbContext.Users
.Include(u => u.Role)
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
throw new KeyNotFoundException($"找不到ID为{id}的用户");
if (string.IsNullOrWhiteSpace(userName))
throw new ArgumentException("用户名不能为空", nameof(userName));
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("邮箱不能为空", nameof(email));
// 检查角色是否存在
var role = await dbContext.Roles.FirstOrDefaultAsync(r => r.Name == roleName);
if (role == null)
throw new ArgumentException($"角色 '{roleName}' 不存在", nameof(roleName));
// 检查邮箱是否已被其他用户使用
var existingUserByEmail = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == email && u.Id != id);
if (existingUserByEmail != null)
throw new ArgumentException($"邮箱 '{email}' 已被使用", nameof(email));
// 更新用户信息
user.UserName = userName;
user.Email = email;
user.RoleId = role.Id;
user.Role = role;
user.UpdatedAt = DateTime.UtcNow;
// 保存用户更改
await dbContext.SaveChangesAsync();
return new UserResponse
{
Id = user.Id,
UserName = user.UserName,
Email = user.Email,
Role = role.Name,
CreatedAt = user.CreatedAt,
};
}
public async Task<bool> DeleteUserAsync(int id)
{
await using var dbContext = await contextFactory.CreateDbContextAsync();
var user = await dbContext.Users.FindAsync(id);
if (user == null)
throw new KeyNotFoundException($"找不到ID为{id}的用户");
// 删除用户
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync();
return true;
}
public async Task<BatchDeleteResult> BatchDeleteUsersAsync(List<int> ids)
{
var result = new BatchDeleteResult();
foreach (var id in ids)
{
try
{
var success = await DeleteUserAsync(id);
if (success)
{
result.SuccessCount++;
}
else
{
result.FailedCount++;
result.FailedIds.Add(id);
}
}
catch
{
result.FailedCount++;
result.FailedIds.Add(id);
}
}
return result;
}
// 辅助方法:生成密码哈希
private string HashPassword(string password)
{
using var sha256 = SHA256.Create();
var hashedBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password));
return Convert.ToBase64String(hashedBytes);
}
}

View File

@@ -19,7 +19,7 @@ public class PictureService(
IAiService embeddingService,
IConfigService configuration,
IBackgroundTaskQueue backgroundTaskQueue,
VectorDbService vectorDbService,
IVectorDbService vectorDbService,
IStorageService storageService)
: IPictureService
{
@@ -102,10 +102,10 @@ public class PictureService(
var picturesData = await dbContext.Pictures
.Include(p => p.Tags)
.Include(p => p.User)
.Where(p => ids.Contains(p.Id))
.Where(p => ids.Contains((ulong)p.Id))
.ToListAsync();
var picturesOrdered = ids
.Select(id => picturesData.FirstOrDefault(p => p.Id == id))
.Select(id => picturesData.FirstOrDefault(p => p.Id == (int)id))
.Where(p => p != null)
.ToList();
var paginatedResults = picturesOrdered

View File

@@ -0,0 +1,18 @@
using Foxel.Models.Vector;
namespace Foxel.Services.VectorDB;
public interface IVectorDbService
{
Task BuildUserPictureVectorsAsync();
Task<List<PictureVector>> SearchAsync(ReadOnlyMemory<float> query, int? userId, int topK = 10);
Task AddPictureToUserCollectionAsync(int userId, PictureVector pictureVector);
Task RemovePictureFromUserCollectionAsync(int userId, int pictureId);
Task ClearVectorsAsync();
}
public enum VectorDbType
{
InMemory,
Qdrant
}

View File

@@ -5,19 +5,18 @@ using Microsoft.SemanticKernel.Connectors.InMemory;
namespace Foxel.Services.VectorDB;
public class VectorDbService
public class InMemoryVectorDbService : IVectorDbService
{
private readonly VectorStore _vectorStore;
private readonly IDbContextFactory<MyDbContext> _contextFactory;
public VectorDbService(IDbContextFactory<MyDbContext> contextFactory)
public InMemoryVectorDbService(IDbContextFactory<MyDbContext> contextFactory)
{
_vectorStore = new InMemoryVectorStore();
_contextFactory = contextFactory;
_ = InitData();
}
private async Task InitData()
public async Task BuildUserPictureVectorsAsync()
{
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var userPictures = dbContext.Pictures
@@ -30,12 +29,12 @@ public class VectorDbService
{
int userId = group.Key;
var collectionName = $"picture_{userId}";
var collection = _vectorStore.GetCollection<int, PictureVector>(collectionName);
var collection = _vectorStore.GetCollection<ulong, PictureVector>(collectionName);
await collection.EnsureCollectionExistsAsync();
var picVectors = group.Select(p => new PictureVector
{
Id = p.Id,
Id = (ulong)p.Id,
Name = p.Name,
Embedding = p.Embedding
}).ToList();
@@ -50,7 +49,7 @@ public class VectorDbService
public async Task<List<PictureVector>> SearchAsync(ReadOnlyMemory<float> query, int? userId, int topK = 10)
{
var collectionName = $"picture_{userId}";
var collection = _vectorStore.GetCollection<int, PictureVector>(collectionName);
var collection = _vectorStore.GetCollection<ulong, PictureVector>(collectionName);
var results = collection.SearchAsync(query, topK);
var res = new List<PictureVector>();
await foreach (var record in results)
@@ -64,7 +63,7 @@ public class VectorDbService
public async Task AddPictureToUserCollectionAsync(int userId, PictureVector pictureVector)
{
var collectionName = $"picture_{userId}";
var collection = _vectorStore.GetCollection<int, PictureVector>(collectionName);
var collection = _vectorStore.GetCollection<ulong, PictureVector>(collectionName);
await collection.EnsureCollectionExistsAsync();
await collection.UpsertAsync(pictureVector);
}
@@ -72,8 +71,18 @@ public class VectorDbService
public async Task RemovePictureFromUserCollectionAsync(int userId, int pictureId)
{
var collectionName = $"picture_{userId}";
var collection = _vectorStore.GetCollection<int, PictureVector>(collectionName);
var collection = _vectorStore.GetCollection<ulong, PictureVector>(collectionName);
await collection.EnsureCollectionExistsAsync();
await collection.DeleteAsync(pictureId);
await collection.DeleteAsync((ulong)pictureId);
}
public async Task ClearVectorsAsync()
{
var collections = _vectorStore.ListCollectionNamesAsync();
await foreach (var name in collections)
{
var collection = _vectorStore.GetCollection<ulong, PictureVector>(name);
await collection.EnsureCollectionDeletedAsync();
}
}
}

View File

@@ -0,0 +1,112 @@
using Foxel.Models.Vector;
using Foxel.Services.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.Qdrant;
using Qdrant.Client;
namespace Foxel.Services.VectorDB;
public class QdrantVectorDbService : IVectorDbService
{
private readonly IDbContextFactory<MyDbContext> _contextFactory;
private readonly IConfigService _configService;
private VectorStore? _vectorStore;
private string? _currentHost;
private string? _currentApiKey;
public QdrantVectorDbService(IDbContextFactory<MyDbContext> contextFactory, IConfigService configService)
{
_contextFactory = contextFactory;
_configService = configService;
}
private VectorStore GetVectorStore()
{
string host = _configService["VectorDb:QdrantHost"] ??
"b63da3b8-c126-4546-95ab-176f907fb1ef.eu-central-1-0.aws.cloud.qdrant.io";
string apiKey = _configService["VectorDb:QdrantApiKey"] ??
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.QzQN4cyo5mldCi9ohe0Aqap4fpTMuSEMGkXtkgBTNQI";
if (_vectorStore == null || _currentHost != host || _currentApiKey != apiKey)
{
var qdrantClient = new QdrantClient(host, https: true, apiKey: apiKey);
_vectorStore = new QdrantVectorStore(qdrantClient, true);
_currentHost = host;
_currentApiKey = apiKey;
}
return _vectorStore;
}
public async Task BuildUserPictureVectorsAsync()
{
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var userPictures = dbContext.Pictures
.Where(p => p.UserId != null && p.Embedding != null)
.Select(p => new { p.Id, p.Name, p.Embedding, p.UserId })
.GroupBy(p => p.UserId!.Value)
.ToList();
foreach (var group in userPictures)
{
int userId = group.Key;
var collectionName = $"picture_{userId}";
var collection = GetVectorStore().GetCollection<ulong, PictureVector>(collectionName);
await collection.EnsureCollectionExistsAsync();
var picVectors = group.Select(p => new PictureVector
{
Id = (ulong)p.Id,
Name = p.Name,
Embedding = p.Embedding
}).ToList();
foreach (var picVector in picVectors)
{
await collection.UpsertAsync(picVector);
}
}
}
public async Task<List<PictureVector>> SearchAsync(ReadOnlyMemory<float> query, int? userId, int topK = 10)
{
var collectionName = $"picture_{userId}";
var collection = GetVectorStore().GetCollection<ulong, PictureVector>(collectionName);
var results = collection.SearchAsync(query, topK);
var res = new List<PictureVector>();
await foreach (var record in results)
{
res.Add(record.Record);
}
return res;
}
public async Task AddPictureToUserCollectionAsync(int userId, PictureVector pictureVector)
{
var collectionName = $"picture_{userId}";
var collection = GetVectorStore().GetCollection<ulong, PictureVector>(collectionName);
await collection.EnsureCollectionExistsAsync();
await collection.UpsertAsync(pictureVector);
}
public async Task RemovePictureFromUserCollectionAsync(int userId, int pictureId)
{
var collectionName = $"picture_{userId}";
var collection = GetVectorStore().GetCollection<ulong, PictureVector>(collectionName);
await collection.EnsureCollectionExistsAsync();
await collection.DeleteAsync((ulong)pictureId);
}
public async Task ClearVectorsAsync()
{
var collections = GetVectorStore().ListCollectionNamesAsync();
await foreach (var name in collections)
{
var collection = GetVectorStore().GetCollection<ulong, PictureVector>(name);
await collection.EnsureCollectionDeletedAsync();
}
}
}

View File

@@ -0,0 +1,30 @@
using Foxel.Services.Configuration;
namespace Foxel.Services.VectorDB;
public class VectorDbInitializer : IHostedService
{
private readonly IVectorDbService _vectorDbService;
private readonly IConfigService _configService;
private const string VectorDbTypeConfigKey = "VectorDb:Type";
public VectorDbInitializer(IVectorDbService vectorDbService, IConfigService configService)
{
_vectorDbService = vectorDbService;
_configService = configService;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var dbTypeStr = await _configService.GetValueAsync(VectorDbTypeConfigKey) ?? "InMemory";
if (string.Equals(dbTypeStr, "InMemory", StringComparison.OrdinalIgnoreCase))
{
await _vectorDbService.BuildUserPictureVectorsAsync();
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,95 @@
using Foxel.Models.Vector;
using Foxel.Services.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Foxel.Services.VectorDB;
public class VectorDbManager(IServiceProvider serviceProvider, IConfigService configService)
: IVectorDbService
{
private IVectorDbService? _currentService;
private readonly Lock _lock = new();
private const string VectorDbTypeConfigKey = "VectorDb:Type";
private IVectorDbService GetCurrentService()
{
if (_currentService == null)
{
lock (_lock)
{
if (_currentService == null)
{
var dbTypeStr = configService[VectorDbTypeConfigKey] ?? "InMemory";
if (Enum.TryParse<VectorDbType>(dbTypeStr, true, out var dbType))
{
_currentService = CreateVectorDbService(dbType);
}
else
{
_currentService = CreateVectorDbService(VectorDbType.InMemory);
}
}
}
}
return _currentService;
}
public async Task SwitchVectorDbAsync(VectorDbType type)
{
IVectorDbService oldService = null;
lock (_lock)
{
var currentType = GetCurrentVectorDbType();
if (currentType == type && _currentService != null)
{
return;
}
oldService = _currentService;
_currentService = CreateVectorDbService(type);
}
await configService.SetConfigAsync(VectorDbTypeConfigKey, type.ToString(), "向量数据库类型");
if (type == VectorDbType.InMemory)
{
await BuildUserPictureVectorsAsync();
}
}
public VectorDbType GetCurrentVectorDbType()
{
var currentService = GetCurrentService();
if (currentService is InMemoryVectorDbService)
return VectorDbType.InMemory;
if (currentService is QdrantVectorDbService)
return VectorDbType.Qdrant;
return VectorDbType.InMemory;
}
private IVectorDbService CreateVectorDbService(VectorDbType type)
{
var dbContextFactory = serviceProvider.GetRequiredService<IDbContextFactory<MyDbContext>>();
return type switch
{
VectorDbType.InMemory => new InMemoryVectorDbService(dbContextFactory),
VectorDbType.Qdrant => new QdrantVectorDbService(dbContextFactory, configService),
_ => new InMemoryVectorDbService(dbContextFactory)
};
}
public Task BuildUserPictureVectorsAsync() => GetCurrentService().BuildUserPictureVectorsAsync();
public Task<List<PictureVector>> SearchAsync(ReadOnlyMemory<float> query, int? userId, int topK = 10)
=> GetCurrentService().SearchAsync(query, userId, topK);
public Task AddPictureToUserCollectionAsync(int userId, PictureVector pictureVector)
=> GetCurrentService().AddPictureToUserCollectionAsync(userId, pictureVector);
public Task RemovePictureFromUserCollectionAsync(int userId, int pictureId)
=> GetCurrentService().RemovePictureFromUserCollectionAsync(userId, pictureId);
public Task ClearVectorsAsync() => GetCurrentService().ClearVectorsAsync();
}

View File

@@ -4,6 +4,7 @@
"": {
"name": "foxel",
"dependencies": {
"@ant-design/charts": "^2.3.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@types/md5": "^2.3.5",
"antd": "^5.24.9",
@@ -31,6 +32,10 @@
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@ant-design/charts": ["@ant-design/charts@2.3.0", "", { "dependencies": { "@ant-design/graphs": "^2.1.0", "@ant-design/plots": "^2.4.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.4", "react-dom": ">=16.8.4" } }, "sha512-uX/Re6C7t+JleAYKyWu2jv08gAxq2QcZVeGjfgNCV3VC/ODBmqRvu8H0m3Lrla2aHKjCyksirTZI7y2BOKfA1w=="],
"@ant-design/charts-util": ["@ant-design/charts-util@0.0.1-alpha.7", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.4", "react-dom": ">=16.8.4" } }, "sha512-Yh0o6EdO6SvdSnStFZMbnUzjyymkVzV+TQ9ymVW9hlVgO/fUkUII3JYSdV+UVcFnYwUF0YiDKuSTLCZNAzg2bQ=="],
"@ant-design/colors": ["@ant-design/colors@7.2.0", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A=="],
"@ant-design/cssinjs": ["@ant-design/cssinjs@1.23.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w=="],
@@ -39,14 +44,84 @@
"@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
"@ant-design/graphs": ["@ant-design/graphs@2.1.0", "", { "dependencies": { "@ant-design/charts-util": "0.0.1-alpha.7", "@antv/g6": "^5.0.44", "@antv/g6-extension-react": "^0.2.0", "@antv/graphin": "^3.0.4", "lodash": "^4.17.21", "styled-components": "^6.1.15" }, "peerDependencies": { "react": ">=16.8.4", "react-dom": ">=16.8.4" } }, "sha512-JavZyJVDRyO5wjReqz3CRYhml5MMpOe+fT4ucebdkfOfWYTlOG+W9vxtNSITJmCGHUVphQkQo9r1CPkZysDT0g=="],
"@ant-design/icons": ["@ant-design/icons@5.6.1", "", { "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", "@babel/runtime": "^7.24.8", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg=="],
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="],
"@ant-design/plots": ["@ant-design/plots@2.4.0", "", { "dependencies": { "@ant-design/charts-util": "0.0.1", "@antv/event-emitter": "^0.1.3", "@antv/g": "^6.1.7", "@antv/g2": "^5.2.7", "@antv/g2-extension-plot": "^0.2.1", "lodash": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.4", "react-dom": ">=16.8.4" } }, "sha512-5JxX6gDp9VyQizkQsCBKjGqlHpgKhfV6XTRNqKnrJMYet0FBNO0srDsa/rmQoZZLxMRvE8eZhCXnM7DhRUWUdA=="],
"@ant-design/react-slick": ["@ant-design/react-slick@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.10.4", "classnames": "^2.2.5", "json2mq": "^0.2.0", "resize-observer-polyfill": "^1.5.1", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": ">=16.9.0" } }, "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA=="],
"@ant-design/v5-patch-for-react-19": ["@ant-design/v5-patch-for-react-19@1.0.3", "", { "peerDependencies": { "antd": ">=5.22.6", "react": ">=19.0.0", "react-dom": ">=19.0.0" } }, "sha512-iWfZuSUl5kuhqLUw7jJXUQFMMkM7XpW7apmKzQBQHU0cpifYW4A79xIBt9YVO5IBajKpPG5UKP87Ft7Yrw1p/w=="],
"@antv/algorithm": ["@antv/algorithm@0.1.26", "", { "dependencies": { "@antv/util": "^2.0.13", "tslib": "^2.0.0" } }, "sha512-DVhcFSQ8YQnMNW34Mk8BSsfc61iC1sAnmcfYoXTAshYHuU50p/6b7x3QYaGctDNKWGvi1ub7mPcSY0bK+aN0qg=="],
"@antv/component": ["@antv/component@2.1.3", "", { "dependencies": { "@antv/g": "^6.1.11", "@antv/scale": "^0.4.16", "@antv/util": "^3.3.10", "svg-path-parser": "^1.1.0" } }, "sha512-TDePMyrx6rvOeUizcKwrNfSb4vL/hcIsIY01dTD1nXKpR3eDf/q558wN4zGH2NmgX/4TOes7UgSH4iCrpB0eMg=="],
"@antv/coord": ["@antv/coord@0.4.7", "", { "dependencies": { "@antv/scale": "^0.4.12", "@antv/util": "^2.0.13", "gl-matrix": "^3.4.3" } }, "sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA=="],
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
"@antv/expr": ["@antv/expr@1.0.2", "", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
"@antv/g": ["@antv/g@6.1.25", "", { "dependencies": { "@antv/g-camera-api": "2.0.38", "@antv/g-dom-mutation-observer-api": "2.0.35", "@antv/g-lite": "2.2.19", "@antv/g-web-animations-api": "2.1.25", "@babel/runtime": "^7.25.6" } }, "sha512-qkXztWRVYQDl/x3tlA9Oww5DwaBCDDYXq6Wai9jfO8TZeIV3T8Dbw5eG/M115doyHX2vIVRkrE6+xiFe5weIHQ=="],
"@antv/g-camera-api": ["@antv/g-camera-api@2.0.38", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "gl-matrix": "^3.4.3", "tslib": "^2.5.3" } }, "sha512-BgFkUMcTO06Oz37Z+hVqxATwdWFE5DfBgMKlFaMwKKF/8n+7eNhlif1KBfcf2rEfGijS0FD0ZGKCr9uJ06+GIg=="],
"@antv/g-canvas": ["@antv/g-canvas@2.0.43", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/g-plugin-canvas-path-generator": "2.1.19", "@antv/g-plugin-canvas-picker": "2.1.22", "@antv/g-plugin-canvas-renderer": "2.2.22", "@antv/g-plugin-dom-interaction": "2.1.24", "@antv/g-plugin-html-renderer": "2.1.24", "@antv/g-plugin-image-loader": "2.1.22", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "tslib": "^2.5.3" } }, "sha512-iAMX+b1eEhwddhm7qiQAtNsnwJQDQsiE5/ELgzni0UHF28zMVTlc5FdDMXKLPuaYs9S/dw/MXBtqctRj2IjA1g=="],
"@antv/g-dom-mutation-observer-api": ["@antv/g-dom-mutation-observer-api@2.0.35", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@babel/runtime": "^7.25.6" } }, "sha512-bAl3ViXDHvLEbGvGZwZBg4gpoNjUTwVQ3XTmRAkymkFGkUy+KV0ZwFdqEegP25TQGPl85er/hB6MCu6Yt58AJA=="],
"@antv/g-lite": ["@antv/g-lite@2.2.19", "", { "dependencies": { "@antv/g-math": "3.0.1", "@antv/util": "^3.3.5", "@antv/vendor": "^1.0.3", "@babel/runtime": "^7.25.6", "eventemitter3": "^5.0.1", "gl-matrix": "^3.4.3", "rbush": "^3.0.1", "tslib": "^2.5.3" } }, "sha512-QfxZsbLGTSGL18NgSOAVQURXC3xMXbmmS125EF7/vCzW2Lw2nF5I8k0KW4N09ty+/FtVpSESJX652g2phIvd5g=="],
"@antv/g-math": ["@antv/g-math@3.0.1", "", { "dependencies": { "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "gl-matrix": "^3.4.3", "tslib": "^2.5.3" } }, "sha512-FvkDBNRpj+HsLINunrL2PW0OlG368MlpHuihbxleuajGim5kra8tgISwCLmAf8Yz2b1CgZ9PvpohqiLzHS7HLg=="],
"@antv/g-plugin-canvas-path-generator": ["@antv/g-plugin-canvas-path-generator@2.1.19", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/g-math": "3.0.1", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "tslib": "^2.5.3" } }, "sha512-+tc97NLvVYEFQnrLffmyxPpVXwUuTPbXBGy3aUTBYKd3YXhFBIKJYpQR39jsX2skgUvLh/67ZtA9QeUt6U41oQ=="],
"@antv/g-plugin-canvas-picker": ["@antv/g-plugin-canvas-picker@2.1.22", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/g-math": "3.0.1", "@antv/g-plugin-canvas-path-generator": "2.1.19", "@antv/g-plugin-canvas-renderer": "2.2.22", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "gl-matrix": "^3.4.3", "tslib": "^2.5.3" } }, "sha512-Pm/N+YTFOlXtjPran3wfN7Iuv0i2YglXrByxvBY8IQ3IzmB68+yZr+yPNRqjgbIM0yh45vIxz3SB/7VwYDPXMA=="],
"@antv/g-plugin-canvas-renderer": ["@antv/g-plugin-canvas-renderer@2.2.22", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/g-math": "3.0.1", "@antv/g-plugin-canvas-path-generator": "2.1.19", "@antv/g-plugin-image-loader": "2.1.22", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "gl-matrix": "^3.4.3", "tslib": "^2.5.3" } }, "sha512-+KyorG9lmwx5mogYZ6DP6BaiE9bnGUHojUxg+pTq032dx3SkCkLBl+CB5pZz1C2LEiQ+NkYtP3PlEjgSeeR/9A=="],
"@antv/g-plugin-dom-interaction": ["@antv/g-plugin-dom-interaction@2.1.24", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@babel/runtime": "^7.25.6", "tslib": "^2.5.3" } }, "sha512-1IrsUp2k+4oi2brVNstgxoisdwcdwqSNdEYJBDtVP1Bv5KZabKSs9lxlkxVR0DTb8BJtWBi80gmKQFIJ8znofQ=="],
"@antv/g-plugin-dragndrop": ["@antv/g-plugin-dragndrop@2.0.35", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "tslib": "^2.5.3" } }, "sha512-1ZG+j91uEQAiFN0UqRkYCx3G8WWlKYoCXgTTx6m4YFJESJiab5M1C4OAi7zXclt1maOR154x3L/j3sRmBHFA+A=="],
"@antv/g-plugin-html-renderer": ["@antv/g-plugin-html-renderer@2.1.24", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "gl-matrix": "^3.4.3", "tslib": "^2.5.3" } }, "sha512-UPEitSu5F42kRgqy8Cr34aC6O4+0cCnC+avv0ZMXUFOf7AMhMnjQLlHHo+GDfM/0r6m//0ZCsqHpv8vB0A+sUA=="],
"@antv/g-plugin-image-loader": ["@antv/g-plugin-image-loader@2.1.22", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "gl-matrix": "^3.4.3", "tslib": "^2.5.3" } }, "sha512-moA+EnV8Gnofj5Kk6btQ6DrufPYiGMvGWXrkBqHNrowfIUD+bFfHPnLm4gV52hHKYEvwnIP2XL+ayHRvzVyffw=="],
"@antv/g-plugin-svg-picker": ["@antv/g-plugin-svg-picker@2.0.38", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/g-plugin-svg-renderer": "2.2.20", "@babel/runtime": "^7.25.6", "tslib": "^2.5.3" } }, "sha512-9XuT3VRFtUrdhMYmib7uB/sjXG9orQ7yGzIwYp+mCI734mnmJApOrB+J3UcSt3s+1PAIcABQkHT1MRxFII2w7w=="],
"@antv/g-plugin-svg-renderer": ["@antv/g-plugin-svg-renderer@2.2.20", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "gl-matrix": "^3.4.3", "tslib": "^2.5.3" } }, "sha512-HjLyQMcMm/kRVhwkmdEkWGGZAHUhIuyztOzO0dzWucfGqXsusNZvKHpiWUMl3DBm6ID6qziYCRw5IIpqlsh3Jw=="],
"@antv/g-svg": ["@antv/g-svg@2.0.38", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/g-plugin-dom-interaction": "2.1.24", "@antv/g-plugin-svg-picker": "2.0.38", "@antv/g-plugin-svg-renderer": "2.2.20", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "tslib": "^2.5.3" } }, "sha512-S11RB+4Yh3nel+wHChcbB4TlaJFyKl4gP9sUUgUzwgWiAFNxiHU4fM3+sb3f4AQyToIZZd1sqH0TscQ3psX5Yg=="],
"@antv/g-web-animations-api": ["@antv/g-web-animations-api@2.1.25", "", { "dependencies": { "@antv/g-lite": "2.2.19", "@antv/util": "^3.3.5", "@babel/runtime": "^7.25.6", "tslib": "^2.5.3" } }, "sha512-xljNU+mDsdaDr+DwP77te2ZkNLcLiwuwppwXuRRpv/wVxUue726c/QbfYj/wMwJoBcOEtl/5hjAks/+gdvr3ag=="],
"@antv/g2": ["@antv/g2@5.3.3", "", { "dependencies": { "@antv/component": "^2.1.2", "@antv/coord": "^0.4.7", "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/g": "^6.1.23", "@antv/g-canvas": "^2.0.42", "@antv/g-plugin-dragndrop": "^2.0.34", "@antv/scale": "^0.4.16", "@antv/util": "^3.3.10", "@antv/vendor": "^1.0.8", "flru": "^1.0.2", "pdfast": "^0.2.0" } }, "sha512-K+Pf1ZRslGn2IHQzA+2NrukeaNqrpOZB76zytkmt5bhGOhZgSWSfc9ubxi0OAlrBY+Yc6DfYcLiHziuASYoG5w=="],
"@antv/g2-extension-plot": ["@antv/g2-extension-plot@0.2.2", "", { "dependencies": { "@antv/g2": "^5.1.8", "@antv/util": "^3.3.5", "@antv/vendor": "^1.0.10" } }, "sha512-KJXCXO7as+h0hDqirGXf1omrNuYzQmY3VmBmp7lIvkepbQ7sz3pPwy895r1FWETGF3vTk5UeFcAF5yzzBHWgbw=="],
"@antv/g6": ["@antv/g6@5.0.48", "", { "dependencies": { "@antv/algorithm": "^0.1.26", "@antv/component": "^2.1.3", "@antv/event-emitter": "^0.1.3", "@antv/g": "^6.1.24", "@antv/g-canvas": "^2.0.43", "@antv/g-plugin-dragndrop": "^2.0.35", "@antv/graphlib": "^2.0.4", "@antv/hierarchy": "^0.6.14", "@antv/layout": "1.2.14-beta.9", "@antv/util": "^3.3.10", "bubblesets-js": "^2.3.4" } }, "sha512-ngACp0NTJE1Dg03myB6Tqj0iVALiwEl83sul+xFqwASxjmw9dv3UeGa6tFGDp+4QvzXSh5GM8SlbTMTlfgEMnQ=="],
"@antv/g6-extension-react": ["@antv/g6-extension-react@0.2.3", "", { "dependencies": { "@antv/g": "^6.1.24", "@antv/g-svg": "^2.0.38" }, "peerDependencies": { "@antv/g6": "^5.0.47", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-CUEeMSqC6B1oZN3Sfq8hN3rWRCt4JhEF32Sqa/RXv7sRnJzLU9o98XqN2DjFgsQ8CpKN/MkRiR73/9zkJBGOMg=="],
"@antv/graphin": ["@antv/graphin@3.0.5", "", { "dependencies": { "@antv/g6": "^5.0.28" }, "peerDependencies": { "react": "^18.0.0 || ^19.1.0", "react-dom": "^18.0.0 || ^19.1.0" } }, "sha512-V/j8R8Ty44wUqxVIYLdpPuIO8WWCTIVq1eBJg5YRunL5t5o5qAFpC/qkQxslbBMWyKdIH0oWBnvHA74riGi7cw=="],
"@antv/graphlib": ["@antv/graphlib@2.0.4", "", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
"@antv/hierarchy": ["@antv/hierarchy@0.6.14", "", {}, "sha512-V3uknf7bhynOqQDw2sg+9r9DwZ9pc6k/EcqyTFdfXB1+ydr7urisP0MipIuimucvQKN+Qkd+d6w601r1UIroqQ=="],
"@antv/layout": ["@antv/layout@1.2.14-beta.9", "", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "@naoak/workerize-transferable": "^0.1.0", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.5.0" } }, "sha512-wPlwBFMtq2lWZFc89/7Lzb8fjHnyKVZZ9zBb2h+zZIP0YWmVmHRE8+dqCiPKOyOGUXEdDtn813f1g107dCHZlg=="],
"@antv/scale": ["@antv/scale@0.4.16", "", { "dependencies": { "@antv/util": "^3.3.7", "color-string": "^1.5.5", "fecha": "^4.2.1" } }, "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw=="],
"@antv/util": ["@antv/util@3.3.10", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-basGML3DFA3O87INnzvDStjzS+n0JLEhRnRsDzP9keiXz8gT1z/fTdmJAZFOzMMWxy+HKbi7NbSt0+8vz/OsBQ=="],
"@antv/vendor": ["@antv/vendor@1.0.11", "", { "dependencies": { "@types/d3-array": "^3.2.1", "@types/d3-color": "^3.1.3", "@types/d3-dispatch": "^3.0.6", "@types/d3-dsv": "^3.0.7", "@types/d3-ease": "^3.0.2", "@types/d3-fetch": "^3.0.7", "@types/d3-force": "^3.0.10", "@types/d3-format": "^3.0.4", "@types/d3-geo": "^3.1.0", "@types/d3-hierarchy": "^3.1.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.0", "@types/d3-quadtree": "^3.0.6", "@types/d3-random": "^3.0.3", "@types/d3-scale": "^4.0.9", "@types/d3-scale-chromatic": "^3.1.0", "@types/d3-shape": "^3.1.7", "@types/d3-time": "^3.0.4", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-dispatch": "^3.0.1", "d3-dsv": "^3.0.1", "d3-ease": "^3.0.1", "d3-fetch": "^3.0.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-format": "^3.1.0", "d3-geo": "^3.1.1", "d3-geo-projection": "^4.0.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-quadtree": "^3.0.1", "d3-random": "^3.0.1", "d3-regression": "^1.3.10", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.1.0", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-timer": "^3.0.1" } }, "sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.27.2", "", {}, "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ=="],
@@ -87,6 +162,10 @@
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.2.2", "", { "dependencies": { "@emotion/memoize": "^0.8.1" } }, "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw=="],
"@emotion/memoize": ["@emotion/memoize@0.8.1", "", {}, "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="],
"@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
@@ -171,10 +250,14 @@
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.6", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@naoak/workerize-transferable": ["@naoak/workerize-transferable@0.1.0", "", { "peerDependencies": { "workerize-loader": "*" } }, "sha512-fDLfuP71IPNP5+zSfxFb52OHgtjZvauRJWbVnpzQ7G7BjcbLjTny0OW1d3ZO806XKpLWNKmeeW3MhE0sy8iwYQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -247,16 +330,64 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.6", "", {}, "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="],
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
"@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/md5": ["@types/md5@2.3.5", "", {}, "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw=="],
"@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="],
"@types/react": ["@types/react@19.1.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="],
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
"@types/stylis": ["@types/stylis@4.2.5", "", {}, "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.32.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/type-utils": "8.32.1", "@typescript-eslint/utils": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.32.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/types": "8.32.1", "@typescript-eslint/typescript-estree": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg=="],
@@ -275,12 +406,50 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.4.1", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w=="],
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"antd": ["antd@5.25.1", "", { "dependencies": { "@ant-design/colors": "^7.2.0", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.0.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.2.6", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.2.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.7", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.50.4", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.9.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ=="],
@@ -289,26 +458,42 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="],
"bubblesets-js": ["bubblesets-js@2.3.4", "", {}, "sha512-DyMjHmpkS2+xcFNtyN00apJYL3ESdp9fTrkDr5+9Qg/GPqFmcWgGsK1akZnttE1XFxJ/VMy4DNNGMGYtmFp1Sg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001718", "", {}, "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="],
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -323,8 +508,64 @@
"crypt": ["crypt@0.0.2", "", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="],
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-geo-projection": ["d3-geo-projection@4.0.0", "", { "dependencies": { "commander": "7", "d3-array": "1 - 3", "d3-geo": "1.12.0 - 3" }, "bin": { "geo2svg": "bin/geo2svg.js", "geograticule": "bin/geograticule.js", "geoproject": "bin/geoproject.js", "geoquantize": "bin/geoquantize.js", "geostitch": "bin/geostitch.js" } }, "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg=="],
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-octree": ["d3-octree@1.1.0", "", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
"d3-regression": ["d3-regression@1.3.10", "", {}, "sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"dagre": ["dagre@0.8.5", "", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
@@ -333,6 +574,12 @@
"electron-to-chromium": ["electron-to-chromium@1.5.155", "", {}, "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng=="],
"emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -359,6 +606,10 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -367,10 +618,14 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="],
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -381,24 +636,42 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"flru": ["flru@1.0.2", "", {}, "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"gl-matrix": ["gl-matrix@3.4.3", "", {}, "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"globals": ["globals@16.1.0", "", {}, "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-any-array": ["is-any-array@2.0.1", "", {}, "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ=="],
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -409,6 +682,8 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -417,6 +692,8 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -429,26 +706,48 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"loader-runner": ["loader-runner@4.3.0", "", {}, "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg=="],
"loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ml-array-max": ["ml-array-max@1.2.4", "", { "dependencies": { "is-any-array": "^2.0.0" } }, "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ=="],
"ml-array-min": ["ml-array-min@1.2.3", "", { "dependencies": { "is-any-array": "^2.0.0" } }, "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q=="],
"ml-array-rescale": ["ml-array-rescale@1.3.7", "", { "dependencies": { "is-any-array": "^2.0.0", "ml-array-max": "^1.2.4", "ml-array-min": "^1.2.3" } }, "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ=="],
"ml-matrix": ["ml-matrix@6.12.1", "", { "dependencies": { "is-any-array": "^2.0.1", "ml-array-rescale": "^1.3.7" } }, "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -463,18 +762,28 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pdfast": ["pdfast@0.2.0", "", {}, "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"rbush": ["rbush@3.0.1", "", { "dependencies": { "quickselect": "^2.0.0" } }, "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w=="],
"rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="],
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
@@ -553,6 +862,8 @@
"react-router": ["react-router@7.6.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -563,28 +874,56 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"schema-utils": ["schema-utils@4.3.2", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ=="],
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
"shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"styled-components": ["styled-components@6.1.18", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw=="],
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svg-path-parser": ["svg-path-parser@1.1.0", "", {}, "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A=="],
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
"terser": ["terser@5.39.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg=="],
"terser-webpack-plugin": ["terser-webpack-plugin@5.3.14", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw=="],
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
"tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
@@ -595,12 +934,16 @@
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-eslint": ["typescript-eslint@8.32.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.32.1", "@typescript-eslint/parser": "8.32.1", "@typescript-eslint/utils": "8.32.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -609,14 +952,28 @@
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="],
"webpack": ["webpack@5.99.9", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.14.0", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.2", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg=="],
"webpack-sources": ["webpack-sources@3.3.0", "", {}, "sha512-77R0RDmJfj9dyv5p3bM5pOHa+X8/ZkO9c7kpDstigkC4nIDobadsfSGCwB4bKhMVxqAok8tajaoR8rirM7+VFQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"workerize-loader": ["workerize-loader@2.0.2", "", { "dependencies": { "loader-utils": "^2.0.0" }, "peerDependencies": { "webpack": "*" } }, "sha512-HoZ6XY4sHWxA2w0WpzgBwUiR3dv1oo7bS+oCwIpb6n54MclQ/7KXdXsVIChTCygyuHtVuGBO1+i3HzTt699UJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@ant-design/plots/@ant-design/charts-util": ["@ant-design/charts-util@0.0.1", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.4", "react-dom": ">=16.8.4" } }, "sha512-zz9aCD8z90gzLm3XK17jyFdVtmpLrFApvexzIl5n9+TrxvIgrmOIqemlvx6QvzkmmXcOA6VIEJGzqQBSBAq55A=="],
"@antv/algorithm/@antv/util": ["@antv/util@2.0.17", "", { "dependencies": { "csstype": "^3.0.8", "tslib": "^2.0.3" } }, "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q=="],
"@antv/coord/@antv/util": ["@antv/util@2.0.17", "", { "dependencies": { "csstype": "^3.0.8", "tslib": "^2.0.3" } }, "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q=="],
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -631,10 +988,32 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"styled-components/@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="],
"styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
"styled-components/stylis": ["stylis@4.3.2", "", {}, "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"webpack/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
}
}

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.3.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@types/md5": "^2.3.5",
"antd": "^5.24.9",

View File

@@ -6,9 +6,10 @@ import Register from './pages/register/Index';
import { isAuthenticated } from './api';
import type { JSX } from 'react';
import { ConfigProvider } from 'antd';
import routes from './config/routeConfig';
import { AuthProvider } from './api/AuthContext'; // 导入 AuthProvider
import { getMainRoutes, getAdminRoutes } from './routes';
import { AuthProvider } from './auth/AuthContext';
import AnonymousPage from './pages/anonymous/Index';
import AdminLayout from './layouts/AdminLayout';
const PrivateRoute = ({ children }: { children: JSX.Element }) => {
return isAuthenticated() ? children : <Navigate to="/login" />;
@@ -45,9 +46,12 @@ const customTheme = {
};
function App() {
const mainRoutes = getMainRoutes();
const adminRoutes = getAdminRoutes();
return (
<AuthProvider>
<ConfigProvider theme={customTheme}>
<ConfigProvider theme={customTheme}>
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
@@ -59,14 +63,32 @@ function App() {
<MainLayout />
</PrivateRoute>
}>
{routes.map((route) => (
<Route key={route.key} path={route.path} element={route.element} />
{mainRoutes.map((route) => (
<Route
key={route.key}
path={route.path === '/' ? '' : route.path}
element={route.element}
/>
))}
</Route>
<Route path="/admin" element={
<PrivateRoute>
<AdminLayout />
</PrivateRoute>
}>
{adminRoutes.map((route) => (
<Route
key={route.key}
path={route.path}
element={route.element}
/>
))}
</Route>
</Routes>
</Router>
</ConfigProvider>
</AuthProvider>
</AuthProvider>
</ConfigProvider>
);
}

View File

@@ -57,3 +57,30 @@ export {
restoreConfigs
} from './configApi';
// 导出UserManagement API
export {
getUsers,
getUserById,
createUser,
updateUser,
deleteUser,
batchDeleteUsers
} from './userManagementApi';
// 导出PictureManagement API
export {
getManagementPictures,
getManagementPictureById,
deleteManagementPicture,
batchDeleteManagementPictures,
getManagementPicturesByUserId
} from './pictureManagementApi';
// 导出向量数据库 API
export {
getCurrentVectorDb,
switchVectorDb,
clearVectors,
rebuildVectors
} from './vectorDbApi';

View File

@@ -0,0 +1,58 @@
import { fetchApi } from './fetchClient';
import {
type BaseResult,
type PaginatedResult,
type PictureResponse,
type BatchDeleteResult
} from './types';
// 获取图片列表
export const getManagementPictures = async (
page: number = 1,
pageSize: number = 10
): Promise<PaginatedResult<PictureResponse>> => {
const response = await fetchApi(`/management/picture/get_pictures?page=${page}&pageSize=${pageSize}`);
return response as PaginatedResult<PictureResponse>;
};
// 根据ID获取单张图片
export const getManagementPictureById = async (id: number): Promise<BaseResult<PictureResponse>> => {
return fetchApi<PictureResponse>(
`/management/picture/get_picture/${id}`,
{ method: 'GET' }
);
};
// 删除图片
export const deleteManagementPicture = async (id: number): Promise<BaseResult<boolean>> => {
return fetchApi<boolean>(
'/management/picture/delete_picture',
{
method: 'POST',
body: JSON.stringify(id)
}
);
};
// 批量删除图片
export const batchDeleteManagementPictures = async (
ids: number[]
): Promise<BaseResult<BatchDeleteResult>> => {
return fetchApi<BatchDeleteResult>(
'/management/picture/batch_delete_pictures',
{
method: 'POST',
body: JSON.stringify(ids)
}
);
};
// 根据用户ID获取图片
export const getManagementPicturesByUserId = async (
userId: number,
page: number = 1,
pageSize: number = 10
): Promise<PaginatedResult<PictureResponse>> => {
const response = await fetchApi(`/management/picture/get_pictures_by_user/${userId}?page=${page}&pageSize=${pageSize}`);
return response as PaginatedResult<PictureResponse>;
};

View File

@@ -227,3 +227,47 @@ export interface UpdatePictureRequest {
description?: string;
tags?: string[];
}
// 用户管理相关类型
export interface UserResponse {
id: number;
userName: string;
email: string;
role: string;
createdAt: Date;
lastLoginAt?: Date;
}
// 管理员创建用户请求
export interface CreateUserRequest {
userName: string;
email: string;
password: string;
role: string;
}
// 管理员更新用户请求
export interface AdminUpdateUserRequest {
id: number;
userName?: string;
email?: string;
role?: string;
}
// 批量删除结果
export interface BatchDeleteResult {
successCount: number;
failedCount: number;
failedIds?: number[];
}
export type VectorDbType = "InMemory" | "Qdrant";
export const VectorDbType = {
InMemory: "InMemory" as VectorDbType,
Qdrant: "Qdrant" as VectorDbType,
};
export interface VectorDbInfo {
type: string;
}

View File

@@ -0,0 +1,76 @@
import { fetchApi } from './fetchClient';
import {
type BaseResult,
type PaginatedResult,
type UserResponse,
type CreateUserRequest,
type AdminUpdateUserRequest,
type BatchDeleteResult
} from './types';
// 获取用户列表
export const getUsers = async (
page: number = 1,
pageSize: number = 10
): Promise<PaginatedResult<UserResponse>> => {
const response = await fetchApi(`/management/user/get_users?page=${page}&pageSize=${pageSize}`);
return response as PaginatedResult<UserResponse>;
};
// 根据ID获取单个用户
export const getUserById = async (id: number): Promise<BaseResult<UserResponse>> => {
return fetchApi<UserResponse>(
`/management/user/get_user/${id}`,
{ method: 'GET' }
);
};
// 创建用户
export const createUser = async (
userData: CreateUserRequest
): Promise<BaseResult<UserResponse>> => {
return fetchApi<UserResponse>(
'/management/user/create_user',
{
method: 'POST',
body: JSON.stringify(userData)
}
);
};
// 更新用户
export const updateUser = async (
userData: AdminUpdateUserRequest
): Promise<BaseResult<UserResponse>> => {
return fetchApi<UserResponse>(
'/management/user/update_user',
{
method: 'POST',
body: JSON.stringify(userData)
}
);
};
// 删除用户
export const deleteUser = async (id: number): Promise<BaseResult<boolean>> => {
return fetchApi<boolean>(
'/management/user/delete_user',
{
method: 'POST',
body: JSON.stringify(id)
}
);
};
// 批量删除用户
export const batchDeleteUsers = async (
ids: number[]
): Promise<BaseResult<BatchDeleteResult>> => {
return fetchApi<BatchDeleteResult>(
'/management/user/batch_delete_users',
{
method: 'POST',
body: JSON.stringify(ids)
}
);
};

View File

@@ -0,0 +1,61 @@
import { type BaseResult, type VectorDbInfo, VectorDbType } from './types';
import { fetchApi } from './fetchClient';
// 获取当前向量数据库类型
export const getCurrentVectorDb = async (): Promise<BaseResult<VectorDbInfo>> => {
try {
return await fetchApi<VectorDbInfo>('/management/system/vector-db/current');
} catch (error: any) {
return {
success: false,
message: `获取当前向量数据库失败: ${error.message}`,
code: 500
};
}
};
// 切换向量数据库类型
export const switchVectorDb = async (type: VectorDbType): Promise<BaseResult<boolean>> => {
try {
return await fetchApi<boolean>('/management/system/vector-db/switch', {
method: 'POST',
body: JSON.stringify({ type }),
});
} catch (error: any) {
return {
success: false,
message: `切换向量数据库失败: ${error.message}`,
code: 500
};
}
};
// 清空向量数据库
export const clearVectors = async (): Promise<BaseResult<boolean>> => {
try {
return await fetchApi<boolean>('/management/system/vector-db/clear', {
method: 'DELETE'
});
} catch (error: any) {
return {
success: false,
message: `清空向量数据库失败: ${error.message}`,
code: 500
};
}
};
// 重建向量数据库
export const rebuildVectors = async (): Promise<BaseResult<boolean>> => {
try {
return await fetchApi<boolean>('/management/system/vector-db/rebuild', {
method: 'POST'
});
} catch (error: any) {
return {
success: false,
message: `重建向量数据库失败: ${error.message}`,
code: 500
};
}
};

View File

@@ -1,7 +1,7 @@
import React, { createContext, useState, useEffect, useContext, useCallback } from 'react';
import { getCurrentUser, isAuthenticated, clearAuthData, getStoredUser } from './index';
import type { UserProfile } from './types';
import { UserRole } from './types';
import { getCurrentUser, isAuthenticated, clearAuthData, getStoredUser } from '../api/index';
import type { UserProfile } from '../api/types';
import { UserRole } from '../api/types';
interface AuthContextType {
user: UserProfile | null;
@@ -27,7 +27,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [user, setUser] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
const refreshUser = useCallback(async () => {
setLoading(true);
setAuthError(null);

View File

@@ -10,7 +10,7 @@ import ImageViewer from './ImageViewer';
import ShareImageDialog from './ShareImageDialog';
import EditImageDialog from './EditImageDialog';
import './ImageGrid.css';
import { useAuth } from '../../api/AuthContext';
import { useAuth } from '../../auth/AuthContext';
const { Text } = Typography;

View File

@@ -0,0 +1,208 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Outlet, useNavigate, useLocation, matchPath, Navigate } from 'react-router';
import { Layout, theme, message } from 'antd';
import { clearAuthData, isAuthenticated } from '../api';
import useIsMobile from '../hooks/useIsMobile';
import { useAuth } from '../auth/AuthContext';
import Sidebar from './components/Sidebar';
import Header from './components/Header';
import Footer from './components/Footer';
import { UserRole } from '../api/types';
import { getAdminRoutes, type RouteConfig } from '../routes';
const { Content } = Layout;
function AdminLayout() {
const { refreshUser, hasRole, user, loading } = useAuth();
const isMobile = useIsMobile();
const [collapsed, setCollapsed] = useState(isMobile);
const [currentRouteData, setCurrentRouteData] = useState<{
routeInfo: RouteConfig | undefined;
params: Record<string, string>;
title?: string;
}>({
routeInfo: undefined,
params: {}
});
const navigate = useNavigate();
const location = useLocation();
const routes = useMemo(() => getAdminRoutes(), []);
const headerRouteData = useMemo(() => ({
routeInfo: currentRouteData.routeInfo,
params: currentRouteData.params,
title: (currentRouteData.routeInfo?.label || '')
}), [currentRouteData]);
const {
token: { colorBgContainer },
} = theme.useToken();
const findCurrentRoute = useCallback(() => {
const pathname = location.pathname;
const adminPath = pathname.replace(/^\/admin\/?/, '');
if (adminPath === '') {
const defaultRoute = routes.find(route => route.path === '');
if (defaultRoute) {
return {
routeInfo: defaultRoute,
params: {}
};
}
}
// 查找精确匹配的路由
for (const route of routes) {
const match = matchPath(
{ path: route.path, end: true },
adminPath
);
if (match) {
return {
routeInfo: route,
params: Object.fromEntries(
Object.entries(match.params || {}).filter(
([, value]) => value !== undefined
)
) as Record<string, string>
};
}
}
// 查找包含参数的路由
for (const route of routes) {
if (route.path.includes(':')) {
const basePath = route.path.split('/:')[0];
if (adminPath.startsWith(basePath)) {
const match = matchPath(
{ path: route.path, end: false },
adminPath
);
if (match) {
return {
routeInfo: route,
params: Object.fromEntries(
Object.entries(match.params || {}).filter(
([, value]) => value !== undefined
)
) as Record<string, string>
};
}
}
}
}
return {
routeInfo: undefined,
params: {}
};
}, [location.pathname, routes]);
useEffect(() => {
if (!isAuthenticated()) {
navigate('/login');
return;
}
if (!user) {
refreshUser();
}
}, [navigate, refreshUser, user]);
useEffect(() => {
if (!loading && user && !hasRole(UserRole.Administrator)) {
message.error('您没有权限访问管理后台');
navigate('/');
}
}, [user, hasRole, navigate, loading]);
useEffect(() => {
const routeData = findCurrentRoute();
setCurrentRouteData(routeData);
}, [location.pathname, findCurrentRoute]);
useEffect(() => {
setCollapsed(isMobile);
}, [isMobile]);
// 退出登录处理
const handleLogout = () => {
clearAuthData();
navigate('/login');
};
const toggleCollapsed = () => {
setCollapsed(!collapsed);
};
// 加载状态
if (loading) {
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
...
</div>;
}
// 权限检查
if (user && !hasRole(UserRole.Administrator)) {
return <Navigate to="/" replace />;
}
return (
<Layout style={{
height: '100vh',
background: '#f0f2f5',
fontWeight: 400
}}>
{/* 侧边栏组件 */}
<Sidebar
collapsed={collapsed}
isMobile={isMobile}
onClose={toggleCollapsed}
area="admin"
/>
<Layout>
{/* 顶部导航栏组件 */}
<Header
collapsed={collapsed}
toggleCollapsed={toggleCollapsed}
onLogout={handleLogout}
currentRouteData={headerRouteData}
isMobile={isMobile}
/>
{/* 主要内容区 */}
<Content style={{
margin: isMobile ? '10px' : '20px',
background: '#f0f2f5',
position: 'relative',
borderRadius: isMobile ? 10 : 20,
overflowY: 'auto'
}}>
<div style={{
padding: isMobile ? '15px' : '25px',
minHeight: '100%',
background: colorBgContainer,
boxShadow: '0 6px 30px rgba(0,0,0,0.03)',
border: '1px solid #f0f0f0',
position: 'relative',
overflow: 'hidden'
}}>
{/* 渲染子路由组件 */}
<Outlet context={{
isMobile,
isAdminPanel: true
}} />
</div>
</Content>
{/* 页脚组件 */}
<Footer isMobile={isMobile} />
</Layout>
</Layout>
);
}
export default AdminLayout;

View File

@@ -3,11 +3,11 @@ import {Outlet, useNavigate, useLocation, matchPath} from 'react-router';
import {Layout, theme} from 'antd';
import {clearAuthData, isAuthenticated} from '../api';
import useIsMobile from '../hooks/useIsMobile';
import {useAuth} from '../api/AuthContext';
import {useAuth} from '../auth/AuthContext';
import Sidebar from './components/Sidebar';
import Header from './components/Header';
import Footer from './components/Footer';
import routes, {type RouteConfig} from '../config/routeConfig';
import {getMainRoutes, type RouteConfig} from '../routes';
const {Content} = Layout;
@@ -26,6 +26,7 @@ function MainLayout() {
const navigate = useNavigate();
const location = useLocation();
const routes = getMainRoutes();
const {
token: {colorBgContainer},
@@ -45,7 +46,6 @@ function MainLayout() {
if (match) {
return {
routeInfo: route,
// 确保params是Record<string, string>类型
params: Object.fromEntries(
Object.entries(match.params || {}).filter(
([, value]) => value !== undefined
@@ -70,7 +70,6 @@ function MainLayout() {
if (match) {
return {
routeInfo: route,
// 确保params是Record<string, string>类型
params: Object.fromEntries(
Object.entries(match.params || {}).filter(
([, value]) => value !== undefined
@@ -122,11 +121,12 @@ function MainLayout() {
background: '#fcfcfc',
fontWeight: 400
}}>
{/* 侧边栏组件 - 添加onClose回调 */}
{/* 侧边栏组件 */}
<Sidebar
collapsed={collapsed}
isMobile={isMobile}
onClose={toggleCollapsed}
area="main"
/>
<Layout>

View File

@@ -1,192 +1,286 @@
import { Layout, Button, Dropdown, Breadcrumb, Input } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
import React, { useRef, useState } from 'react';
import { Layout, Button, Dropdown, Space, theme, Breadcrumb, Input } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
LogoutOutlined,
SettingOutlined
DashboardOutlined,
HomeOutlined,
RightOutlined,
SearchOutlined
} from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import routes, { type RouteConfig } from '../../config/routeConfig';
import { Link, useNavigate } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { type RouteConfig } from '../../routes';
import UserAvatar from '../../components/UserAvatar';
import { useAuth } from '../../api/AuthContext';
import { useState } from 'react';
import { UserRole } from '../../api/types';
import SearchDialog from '../../components/search/SearchDialog';
const { Header: AntHeader } = Layout;
const { Search } = Input;
interface HeaderProps {
collapsed: boolean;
toggleCollapsed: () => void;
onLogout: () => void;
currentRouteData?: {
routeInfo: RouteConfig | undefined;
params: Record<string, string>;
title?: string; // 动态标题,用于显示如"相册名称"等动态数据
currentRouteData: {
routeInfo?: RouteConfig;
params?: Record<string, string>;
title?: string;
};
isMobile?: boolean;
}
const Header = ({
collapsed,
toggleCollapsed,
onLogout,
// 面包屑项目类型定义
interface BreadcrumbItem {
title: string;
href?: string;
icon?: React.ReactNode;
}
const Header: React.FC<HeaderProps> = ({
collapsed,
toggleCollapsed,
onLogout,
currentRouteData,
isMobile = false
}: HeaderProps) => {
const navigate = useNavigate();
isMobile = false
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const headerRef = useRef<HTMLDivElement>(null);
const { hasRole } = useAuth();
// 添加搜索对话框状态
const [searchDialogVisible, setSearchDialogVisible] = useState(false);
const [searchText, setSearchText] = useState('');
const {
token: { colorBgContainer },
} = theme.useToken();
// 用户菜单项
const userMenuItems = [
{
key: 'profile',
icon: <UserOutlined/>,
label: '个人资料',
onClick: () => navigate('/settings')
},
{
key: 'settings',
icon: <SettingOutlined/>,
label: '设置',
icon: <UserOutlined />,
label: '个人中心',
onClick: () => navigate('/settings')
},
...(hasRole(UserRole.Administrator) ? [
{
key: 'admin',
icon: <DashboardOutlined />,
label: '后台管理',
onClick: () => navigate('/admin')
}
] : []),
{
key: 'logout',
icon: <LogoutOutlined/>,
icon: <LogoutOutlined />,
label: '退出登录',
onClick: onLogout
}
];
// 生成面包屑
const generateBreadcrumbItems = () => {
const breadcrumbItems = [];
// 添加首页
breadcrumbItems.push({
key: 'home',
title: <Link to="/"></Link>,
});
// 根据路由信息生成面包屑导航
const renderBreadcrumb = () => {
// 如果有传入的标题,直接使用标题作为面包屑
if (currentRouteData.title) {
return (
<Breadcrumb
separator={<RightOutlined style={{ fontSize: 12 }} />}
style={{ margin: 0 }}
items={[
{
title: '首页',
href: '/',
},
{
title: currentRouteData.title
}
]}
/>
);
}
// 确保routeInfo和breadcrumb都存在
if (currentRouteData?.routeInfo && currentRouteData.routeInfo.breadcrumb) {
const { routeInfo, title } = currentRouteData;
const breadcrumb = routeInfo.breadcrumb;
// 如果有父级路由,先添加父级路由的面包屑
if (breadcrumb && breadcrumb.parent) {
const parentRoute = routes.find(r => r.key === breadcrumb.parent);
if (parentRoute && parentRoute.breadcrumb) {
breadcrumbItems.push({
key: parentRoute.key,
title: <Link to={`/${parentRoute.path}`}>{parentRoute.breadcrumb.title}</Link>,
});
}
// 如果没有路由信息,返回首页面包屑
if (!currentRouteData.routeInfo) {
return (
<Breadcrumb
separator={<RightOutlined style={{ fontSize: 12 }} />}
style={{ margin: 0 }}
items={[
{
title: '首页',
href: '/',
}
]}
/>
);
}
// 获取当前路由信息
const { routeInfo, params } = currentRouteData;
const breadcrumb = routeInfo.breadcrumb;
if (!breadcrumb) {
return (
<Breadcrumb
separator={<RightOutlined style={{ fontSize: 12 }} />}
style={{ margin: 0 }}
items={[
{
title: '首页',
href: '/',
},
{
title: routeInfo.label
}
]}
/>
);
}
// 准备面包屑项目
const breadcrumbItems: BreadcrumbItem[] = [
{
title: routeInfo.area === 'admin' ? '管理后台' : '首页',
href: routeInfo.area === 'admin' ? '/admin' : '/',
icon: routeInfo.area === 'admin' ? <DashboardOutlined /> : <HomeOutlined />
}
// 添加当前路由的面包屑
];
// 如果有父级,添加父级面包屑
if (breadcrumb.parent) {
const parentPath = routeInfo.area === 'admin'
? `/admin/${breadcrumb.parent}`
: `/${breadcrumb.parent}`;
breadcrumbItems.push({
key: routeInfo.key,
title: title || breadcrumb?.title,
title: breadcrumb.parent.charAt(0).toUpperCase() + breadcrumb.parent.slice(1),
href: parentPath
});
}
return breadcrumbItems;
};
// 处理搜索框输入
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
};
// 处理搜索操作,仅当点击搜索按钮或按回车时执行
const handleSearch = (value: string) => {
if (value.trim() || !value) { // 允许空搜索打开高级搜索
setSearchDialogVisible(true);
// 获取动态标题
let title = breadcrumb.title;
if (params && Object.keys(params).length > 0) {
// 用参数替换标题中的占位符,如 ":id"
Object.entries(params).forEach(([key, value]) => {
title = title.replace(`:${key}`, value);
});
}
// 添加当前页面面包屑
breadcrumbItems.push({
title: title
});
return (
<Breadcrumb
separator={<RightOutlined style={{ fontSize: 12 }} />}
style={{ margin: 0 }}
items={breadcrumbItems.map(item => ({
title: item.href ? (
<Link to={item.href} style={{ color: '#666', fontSize: isMobile ? 13 : 14 }}>
{item.icon && <span style={{ marginRight: 4 }}>{item.icon}</span>}
{isMobile && !item.icon ? '' : item.title}
</Link>
) : (
<span style={{ fontSize: isMobile ? 14 : 16, fontWeight: 500 }}>
{item.icon && <span style={{ marginRight: 4 }}>{item.icon}</span>}
{item.title}
</span>
),
}))}
/>
);
};
// 处理搜索
const handleSearch = (value: string) => {
setSearchText(value);
setSearchDialogVisible(true);
};
// 关闭搜索对话框
const handleSearchDialogClose = () => {
setSearchDialogVisible(false);
};
return (
<>
<AntHeader style={{
padding: isMobile ? '0 10px' : '0 40px',
background: '#ffffff',
borderBottom: '1px solid #f0f0f0',
<AntHeader
ref={headerRef}
style={{
padding: isMobile ? '0 12px' : '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: isMobile ? 56 : 64,
borderBottom: '1px solid #f0f0f0',
zIndex: 100,
position: 'sticky',
top: 0,
zIndex: 10,
width: '100%',
backdropFilter: 'blur(10px)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}
onClick={toggleCollapsed}
top: 0
}}
>
{/* 左侧区域:折叠按钮和面包屑 */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={toggleCollapsed}
style={{
fontSize: '16px',
width: 36,
height: 36,
marginRight: 12
}}
/>
{renderBreadcrumb()}
</div>
{/* 右侧区域:搜索框和用户菜单 */}
<div style={{ display: 'flex', alignItems: 'center' }}>
{/* 搜索框 */}
<div style={{
marginRight: 16,
display: 'flex',
alignItems: 'center',
height: '100%'
}}>
<Input.Search
placeholder="搜索图片..."
onSearch={handleSearch}
onChange={(e) => setSearchText(e.target.value)}
style={{
fontSize: 18,
width: 46,
height: 46,
borderRadius: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
width: isMobile ? 150 : 220,
borderRadius: 4
}}
size={isMobile ? "middle" : "large"}
allowClear
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
/>
{/* 面包屑导航 */}
{!isMobile && (
<Breadcrumb
items={generateBreadcrumbItems()}
style={{ marginLeft: 16 }}
/>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 25 }}>
{/* 搜索框 - 修复交互问题 */}
{!isMobile && (
<Search
placeholder="搜索图片..."
allowClear
value={searchText}
onChange={handleSearchInputChange}
onSearch={handleSearch}
style={{
width: 300,
borderRadius: 100
}}
size="middle"
/>
)}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<UserAvatar
size={46}
{/* 用户菜单 */}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<UserAvatar
size={isMobile ? 36 : 46}
email={user?.email}
text={user?.userName}
/>
</Dropdown>
</div>
</AntHeader>
{/* 搜索对话框 - 传递搜索文本 */}
<SearchDialog
</Space>
</Dropdown>
</div>
{/* 搜索对话框 */}
<SearchDialog
visible={searchDialogVisible}
onClose={handleSearchDialogClose}
initialSearchText={searchText}
onClose={() => {
setSearchDialogVisible(false);
// 可选:关闭对话框后清空搜索框
// setSearchText('');
}}
/>
</>
</AntHeader>
);
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Layout, Menu, type MenuProps } from 'antd';
import { useLocation, useNavigate } from 'react-router';
import routes from '../../config/routeConfig';
import { getMainRoutes, getAdminRoutes } from '../../routes';
import logo from '/logo.png';
const { Sider } = Layout;
@@ -10,20 +10,22 @@ interface SidebarProps {
collapsed: boolean;
isMobile?: boolean;
onClose?: () => void;
area: 'main' | 'admin';
}
// 定义菜单项类型
type MenuItem = Required<MenuProps>['items'][number];
const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose }) => {
const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose, area }) => {
const location = useLocation();
const navigate = useNavigate();
// 菜单项样式
// 获取对应区域的路由
const routes = area === 'main' ? getMainRoutes() : getAdminRoutes();
// 样式配置
const menuItemStyle = { fontSize: 15 };
const iconStyle = { fontSize: 18 };
// 分组标题样式
const groupTitleStyle = {
fontSize: 12,
color: '#8c8c8c',
@@ -73,6 +75,30 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose
// 获取当前选中的菜单项
const getSelectedKey = () => {
const pathname = location.pathname;
// 管理后台路径处理
if (area === 'admin') {
// 提取 /admin/ 后面的部分
const adminPath = pathname.replace(/^\/admin\/?/, '');
// 如果是管理后台首页
if (adminPath === '') {
const defaultRoute = routes.find(route => route.path === '');
return defaultRoute ? defaultRoute.path : '';
}
const matchedRoute = routes.find(route => {
if (route.path.includes(':')) {
const basePath = route.path.split(':')[0].replace(/\/$/, '');
return adminPath.startsWith(basePath);
}
return adminPath === route.path;
});
return matchedRoute ? matchedRoute.path : '';
}
// 主应用路径处理
const matchedRoute = routes.find(route => {
if (route.path.includes(':')) {
const basePath = route.path.split(':')[0].replace(/\/$/, '');
@@ -83,91 +109,132 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose
}
return pathname === '/' + route.path;
});
return matchedRoute ? (matchedRoute.path === '/' ? '/' : matchedRoute.path) : '/';
};
const handleMenuClick = ({ key }: { key: string }) => {
navigate(key);
if (area === 'admin') {
// 处理空路径的特殊情况(首页)
if (key === '') {
navigate('/admin');
} else {
navigate(`/admin/${key}`);
}
} else {
navigate(key);
}
// 在移动设备上点击后关闭侧边栏
if (isMobile && onClose) {
onClose();
}
};
return (
<>
{/* 遮罩层 - 仅在手机模式且侧边栏展开时显示 */}
{isMobile && !collapsed && (
<div
onClick={onClose}
// 根据区域获取不同的Logo和标题
const getLogoAndTitle = () => {
if (area === 'admin') {
return {
logo: logo,
title: 'Foxel 管理后台'
};
}
return {
logo: logo,
title: 'Foxel'
};
};
const { logo: logoSrc, title } = getLogoAndTitle();
// 侧边栏内容
const sidebarContent = (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={isMobile ? 180 : (area === 'admin' ? 220 : 250)}
collapsedWidth={isMobile ? 0 : 80}
style={{
overflow: 'auto',
height: '100vh',
position: isMobile ? 'absolute' : 'relative',
left: 0,
top: 0,
bottom: 0,
zIndex: isMobile ? 1000 : 1,
boxShadow: isMobile && !collapsed ? '0 0 10px rgba(0,0,0,0.2)' : 'none',
backgroundColor: 'white',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Logo区域 */}
<div style={{
height: isMobile ? '56px' : '64px',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 20px',
color: '#001529',
fontWeight: 'bold',
fontSize: '18px',
overflow: 'hidden',
backgroundColor: 'white',
borderBottom: '1px solid #f0f0f0'
}}>
<img
src={logoSrc}
alt="Foxel Logo"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.45)',
zIndex: 999, // 确保在Sider(1000)之下
height: collapsed ? '30px' : '32px',
marginRight: collapsed ? '0' : '12px',
transition: 'all 0.2s'
}}
/>
)}
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={isMobile ? 180 : 250}
collapsedWidth={isMobile ? 0 : 80}
{!collapsed && <span>{title}</span>}
</div>
{/* 侧边栏菜单 */}
<Menu
theme="light"
mode="inline"
selectedKeys={[getSelectedKey()]}
items={generateMenuItems()}
onClick={handleMenuClick}
style={{
overflow: 'auto',
height: '100vh',
position: isMobile ? 'absolute' : 'relative',
left: 0,
top: 0,
bottom: 0,
zIndex: isMobile ? 1000 : 1,
boxShadow: isMobile && !collapsed ? '0 0 10px rgba(0,0,0,0.2)' : 'none',
backgroundColor: 'white',
display: 'flex',
flexDirection: 'column',
borderRight: 'none',
flex: 1
}}
>
{/* Logo区域 */}
<div style={{
height: isMobile ? '56px' : '64px',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 20px',
color: '#001529',
fontWeight: 'bold',
fontSize: '18px',
overflow: 'hidden',
backgroundColor: 'white',
borderBottom: '1px solid #f0f0f0'
}}>
<img
src={logo}
alt="Foxel Logo"
/>
</Sider>
);
// 移动设备上使用Drawer组件
if (isMobile) {
return (
<>
{/* 遮罩层 - 仅在手机模式且侧边栏展开时显示 */}
{!collapsed && (
<div
onClick={onClose}
style={{
height: collapsed ? '30px' : '32px',
marginRight: collapsed ? '0' : '12px',
transition: 'all 0.2s'
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.45)',
zIndex: 999,
}}
/>
{!collapsed && <span>Foxel</span>}
</div>
)}
{sidebarContent}
</>
);
}
{/* 侧边栏菜单 */}
<Menu
theme="light"
mode="inline"
defaultSelectedKeys={[getSelectedKey()]}
items={generateMenuItems()}
onClick={handleMenuClick}
style={{
borderRight: 'none',
flex: 1
}}
/>
</Sider>
</>
);
return sidebarContent;
};
export default Sidebar;

View File

@@ -0,0 +1,360 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Row, Col, Card, Statistic, Table, Button, Spin, Typography, Space, Tag, message } from 'antd';
import {
UserOutlined,
PictureOutlined,
EyeOutlined,
ClockCircleOutlined,
ArrowUpOutlined,
InfoCircleOutlined
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useOutletContext } from 'react-router';
import { useNavigate } from 'react-router';
import { getUsers, getManagementPictures } from '../../../api';
import type { UserResponse, PictureResponse } from '../../../api/types';
const { Title, Text } = Typography;
interface DashboardStats {
totalUsers: number;
totalAlbums: number;
totalPhotos: number;
storageUsagePercentage: number;
newUsersToday: number;
newPhotosToday: number;
softwareVersion: string;
systemVersion: string;
cpuArchitecture: string;
}
const AdminDashboard: React.FC = () => {
const { isMobile } = useOutletContext<{ isMobile: boolean; isAdminPanel?: boolean }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats>({
totalUsers: 0,
totalAlbums: 0,
totalPhotos: 0,
storageUsagePercentage: 0,
newUsersToday: 0,
newPhotosToday: 0,
softwareVersion: 'N/A',
systemVersion: 'N/A',
cpuArchitecture: 'N/A'
});
const [recentUsers, setRecentUsers] = useState<UserResponse[]>([]);
const [recentPhotos, setRecentPhotos] = useState<PictureResponse[]>([]);
// 获取最近用户数据
const fetchRecentUsers = async () => {
try {
const response = await getUsers(1, 5); // 获取最近5个用户
if (response.success && response.data) {
setRecentUsers(response.data);
// 更新用户总数统计
setStats(prev => ({
...prev,
totalUsers: response.totalCount || 0
}));
}
} catch (error) {
console.error('Error fetching recent users:', error);
message.error('获取最近用户数据失败');
}
};
// 获取最近图片数据
const fetchRecentPhotos = async () => {
try {
const response = await getManagementPictures(1, 5); // 获取最近5张图片
if (response.success && response.data) {
setRecentPhotos(response.data);
// 更新图片总数统计
setStats(prev => ({
...prev,
totalPhotos: response.totalCount || 0
}));
}
} catch (error) {
console.error('Error fetching recent photos:', error);
message.error('获取最近图片数据失败');
}
};
// 计算今日新增数据
const calculateTodayStats = (users: UserResponse[], photos: PictureResponse[]) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const newUsersToday = users.filter(user => {
const userDate = new Date(user.createdAt);
userDate.setHours(0, 0, 0, 0);
return userDate.getTime() === today.getTime();
}).length;
const newPhotosToday = photos.filter(photo => {
const photoDate = new Date(photo.createdAt);
photoDate.setHours(0, 0, 0, 0);
return photoDate.getTime() === today.getTime();
}).length;
setStats(prev => ({
...prev,
newUsersToday,
newPhotosToday
}));
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
await Promise.all([fetchRecentUsers(), fetchRecentPhotos()]);
// 设置其他静态统计数据
setStats(prev => ({
...prev,
totalAlbums: 348, // 相册功能暂未实现,使用模拟数据
storageUsagePercentage: 68,
softwareVersion: 'Foxel Dev 尝鲜版',
systemVersion: 'Fedora 42',
cpuArchitecture: 'x86_64'
}));
} catch (error) {
console.error('Error loading dashboard data:', error);
} finally {
setLoading(false);
}
};
loadData();
}, []);
// 当用户和图片数据都加载完成后计算今日统计
useEffect(() => {
if (recentUsers.length > 0 && recentPhotos.length > 0) {
calculateTodayStats(recentUsers, recentPhotos);
}
}, [recentUsers, recentPhotos]);
const userColumns = useMemo<ColumnsType<UserResponse>>(() => [
{
title: '用户名',
dataIndex: 'userName',
key: 'userName',
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
responsive: ['md'],
},
{
title: '注册时间',
dataIndex: 'createdAt',
key: 'createdAt',
responsive: ['lg'],
render: (date: Date) => new Date(date).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate('/admin/users')}
>
</Button>
),
},
], [navigate]);
const photoColumns = useMemo<ColumnsType<PictureResponse>>(() => [
{
title: '图片名称',
dataIndex: 'name',
key: 'name',
},
{
title: '上传者',
dataIndex: 'username',
key: 'username',
responsive: ['md'],
},
{
title: '上传时间',
dataIndex: 'createdAt',
key: 'createdAt',
responsive: ['lg'],
render: (date: Date) => new Date(date).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate('/admin/pictures')}
>
</Button>
),
},
], [navigate]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" tip="加载中..." />
</div>
);
}
return (
<div className="admin-dashboard">
<Title level={2}></Title>
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>
使 Foxel
</Text>
<Row gutter={[24, 24]}>
{/* 左侧内容区域 */}
<Col xs={24} lg={18}>
{/* 主要统计卡片 */}
<Row gutter={[16, 16]}>
<Col xs={12} md={8}>
<Card variant="outlined">
<Statistic
title="用户总数"
value={stats.totalUsers}
prefix={<UserOutlined />}
suffix={
<Tag color="green" style={{ marginLeft: 8 }}>
<ArrowUpOutlined /> {stats.newUsersToday}
</Tag>
}
/>
</Card>
</Col>
<Col xs={12} md={8}>
<Card variant="outlined">
<Statistic
title="相册总数"
value={stats.totalAlbums}
prefix={<PictureOutlined />}
/>
</Card>
</Col>
<Col xs={12} md={8}>
<Card variant="outlined">
<Statistic
title="照片总数"
value={stats.totalPhotos}
prefix={<PictureOutlined />}
suffix={
<Tag color="green" style={{ marginLeft: 8 }}>
<ArrowUpOutlined /> {stats.newPhotosToday}
</Tag>
}
/>
</Card>
</Col>
</Row>
{/* 最近活动 */}
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
<Col xs={24} xl={12}>
<Card
title={
<Space>
<UserOutlined />
<span></span>
</Space>
}
extra={<Button type="link" onClick={() => navigate('/admin/users')}></Button>}
variant="outlined"
>
<Table
columns={userColumns}
dataSource={recentUsers}
rowKey="id"
pagination={false}
size={isMobile ? "small" : "middle"}
/>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card
title={
<Space>
<PictureOutlined />
<span></span>
</Space>
}
extra={<Button type="link" onClick={() => navigate('/admin/pictures')}></Button>}
variant="outlined"
>
<Table
columns={photoColumns}
dataSource={recentPhotos}
rowKey="id"
pagination={false}
size={isMobile ? "small" : "middle"}
/>
</Card>
</Col>
</Row>
</Col>
{/* 右侧内容区域 */}
<Col xs={24} lg={6}>
{/* 系统状态 */}
<Card
title={
<Space>
<ClockCircleOutlined />
<span></span>
</Space>
}
variant="outlined"
>
<Row gutter={[16, 24]}>
<Col span={24}>
<Statistic
title="软件版本"
value={stats.softwareVersion}
prefix={<InfoCircleOutlined />}
valueStyle={{ fontSize: '1em' }}
/>
</Col>
<Col span={24}>
<Statistic
title="操作系统"
value={stats.systemVersion}
prefix={<InfoCircleOutlined />}
valueStyle={{ fontSize: '1em' }}
/>
</Col>
<Col span={24}>
<Statistic
title="CPU架构"
value={stats.cpuArchitecture}
prefix={<InfoCircleOutlined />}
valueStyle={{ fontSize: '1em' }}
/>
</Col>
</Row>
</Card>
</Col>
</Row>
</div>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,307 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Button, Card, Input, Space, Modal,
message, Typography, Popconfirm, Row, Col, Image, Select
} from 'antd';
import {
PictureOutlined, DeleteOutlined,
SearchOutlined, ExclamationCircleOutlined, ReloadOutlined,
FileImageOutlined, UserOutlined
} from '@ant-design/icons';
import {
getManagementPictures, deleteManagementPicture, batchDeleteManagementPictures,
getUsers
} from '../../../api';
import type { PictureResponse, UserResponse } from '../../../api/types';
import { useOutletContext } from 'react-router';
import type { Breakpoint } from 'antd';
const { Title, Text } = Typography;
const { Option } = Select;
const { confirm } = Modal;
const PictureManagement: React.FC = () => {
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
// 状态管理
const [pictures, setPictures] = useState<PictureResponse[]>([]);
const [users, setUsers] = useState<UserResponse[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [selectedUserId, setSelectedUserId] = useState<number | undefined>();
// 加载用户列表
const fetchUsers = useCallback(async () => {
try {
const response = await getUsers(1, 1000); // 获取所有用户用于筛选
if (response.success && response.data) {
setUsers(response.data || []);
}
} catch (error) {
console.error('Error fetching users:', error);
}
}, []);
// 加载图片数据
const fetchPictures = useCallback(async (page = currentPage, size = pageSize) => {
setLoading(true);
try {
const response = await getManagementPictures(page, size);
if (response.success && response.data) {
setPictures(response.data || []);
setTotal(response.totalCount || 0);
} else {
message.error(response.message || '获取图片列表失败');
}
} catch (error) {
console.error('Error fetching pictures:', error);
message.error('获取图片列表失败,请检查网络连接');
} finally {
setLoading(false);
}
}, [currentPage, pageSize]);
// 初始加载
useEffect(() => {
fetchUsers();
fetchPictures();
}, [fetchUsers, fetchPictures]);
// 处理页面变化
const handlePageChange = (page: number, size?: number) => {
setCurrentPage(page);
if (size) setPageSize(size);
fetchPictures(page, size || pageSize);
};
// 处理搜索
const handleSearch = () => {
setCurrentPage(1);
fetchPictures(1, pageSize);
};
// 处理删除图片
const handleDelete = async (id: number) => {
try {
const response = await deleteManagementPicture(id);
if (response.success) {
message.success('图片删除成功');
fetchPictures();
} else {
message.error(response.message || '删除图片失败');
}
} catch (error) {
console.error('Error deleting picture:', error);
message.error('删除图片失败,请检查网络连接');
}
};
// 批量删除图片
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请选择要删除的图片');
return;
}
confirm({
title: `确定要删除 ${selectedRowKeys.length} 张图片吗?`,
icon: <ExclamationCircleOutlined />,
content: '此操作不可逆,所选图片将被永久删除',
okText: '确认',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const response = await batchDeleteManagementPictures(selectedRowKeys as number[]);
if (response.success && response.data) {
message.success(`成功删除 ${response.data.successCount} 张图片`);
if (response.data.failedCount > 0) {
message.warning(`${response.data.failedCount} 张图片删除失败`);
}
setSelectedRowKeys([]);
fetchPictures();
} else {
message.error(response.message || '批量删除图片失败');
}
} catch (error) {
console.error('Error batch deleting pictures:', error);
message.error('批量删除图片失败,请检查网络连接');
}
}
});
};
// 处理用户筛选
const handleUserFilter = (userId: number | undefined) => {
setSelectedUserId(userId);
// 这里应该根据用户ID筛选图片但目前先简单刷新
setCurrentPage(1);
fetchPictures(1, pageSize);
};
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
responsive: ['md' as Breakpoint],
},
{
title: '缩略图',
dataIndex: 'thumbnailPath',
key: 'thumbnail',
render: (thumbnailPath: string, record: PictureResponse) => (
<Image
width={50}
height={50}
src={thumbnailPath || record.path}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
),
},
{
title: '图片名称',
dataIndex: 'name',
key: 'name',
render: (text: string) => (
<Space>
<FileImageOutlined />
{text}
</Space>
),
},
{
title: '用户',
dataIndex: 'username',
key: 'username',
responsive: ['lg' as Breakpoint],
render: (username: string) => (
<Space>
<UserOutlined />
{username}
</Space>
),
},
{
title: '上传时间',
dataIndex: 'createdAt',
key: 'createdAt',
responsive: ['lg' as Breakpoint],
render: (date: Date) => new Date(date).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_: any, record: PictureResponse) => (
<Space size="small">
<Popconfirm
title="确定要删除此图片吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
>
{isMobile ? '' : '删除'}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div className="picture-management">
<Row gutter={[16, 16]} align="middle" justify="space-between">
<Col>
<Space align="center">
<PictureOutlined style={{ fontSize: 24 }} />
<Title level={2} style={{ margin: 0 }}></Title>
</Space>
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
</Text>
</Col>
</Row>
<Card style={{ marginTop: 16 }}>
<Row gutter={[16, 16]} justify="space-between" style={{ marginBottom: 16 }}>
<Col xs={24} sm={14} md={16}>
<Space wrap>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchPictures()}
>
</Button>
<Select
style={{ width: 150 }}
placeholder="筛选用户"
allowClear
value={selectedUserId}
onChange={handleUserFilter}
>
{users.map(user => (
<Option key={user.id} value={user.id}>
{user.userName}
</Option>
))}
</Select>
</Space>
</Col>
<Col xs={24} sm={10} md={8}>
<Input.Search
placeholder="搜索图片名称"
allowClear
enterButton={<SearchOutlined />}
onSearch={handleSearch}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
/>
</Col>
</Row>
<Table
rowKey="id"
columns={columns}
dataSource={pictures}
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
onChange: handlePageChange,
showTotal: (total) => `${total} 条记录`,
}}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
size={isMobile ? "small" : "middle"}
scroll={{ x: 'max-content' }}
/>
</Card>
</div>
);
};
export default PictureManagement;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Row, Col, Form, Input, Button, Space, Tooltip } from 'antd';
import { LockOutlined, QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons';
interface ConfigFormItemProps {
groupName: string;
itemKey: string;
description: string;
isSecret: boolean;
currentValue: string | undefined;
formInstance: any;
isMobile: boolean;
onSave: (formInstance: any, groupName: string, key: string) => Promise<void>;
}
const ConfigFormItem: React.FC<ConfigFormItemProps> = ({
groupName,
itemKey,
description,
isSecret,
currentValue,
formInstance,
isMobile,
onSave,
}) => {
return (
<Row key={itemKey} gutter={isMobile ? [8, 8] : [16, 16]} align="top" style={{ marginBottom: isMobile ? 8 : 16 }}>
<Col xs={24} sm={isMobile ? 24 : 16} md={isMobile ? 24 : 17} lg={isMobile ? 24 : 18}>
<Form.Item
name={itemKey}
label={
<Space align="center">
<span style={{ fontWeight: 500 }}>{itemKey}</span>
{isSecret && <LockOutlined style={{ color: '#faad14' }} />}
{description && (
<Tooltip title={description}>
<QuestionCircleOutlined style={{ cursor: 'help', color: '#aaa' }} />
</Tooltip>
)}
</Space>
}
initialValue={isSecret ? '' : currentValue}
rules={isSecret ? [] : []}
style={{ marginBottom: isMobile ? 8 : 16 }}
help={isSecret && currentValue ?
<span style={{ fontSize: '12px', color: '#999' }}></span> :
(isSecret ? <span style={{ fontSize: '12px', color: '#999' }}></span> : null)}
>
{isSecret ? (
<Input.Password
placeholder={currentValue ? '******(输入新值以更新)' : '请输入新值'}
style={{ maxWidth: 400 }}
/>
) : (
<Input placeholder={`请输入 ${itemKey}`} style={{ maxWidth: 400 }} />
)}
</Form.Item>
</Col>
<Col xs={24} sm={isMobile ? 24 : 8} md={isMobile ? 24 : 7} lg={isMobile ? 24 : 6}
style={{ textAlign: isMobile ? 'left' : 'right', paddingTop: isMobile ? 0 : '30px' }}>
<Button
icon={<SaveOutlined />}
type="primary"
ghost
onClick={() => onSave(formInstance, groupName, itemKey)}
size="middle"
style={{ width: isMobile ? '100%' : 'auto', marginBottom: isMobile ? 16 : 0 }}
>
</Button>
</Col>
</Row>
);
};
export default ConfigFormItem;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Card, Space, Typography } from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
const { Paragraph } = Typography;
interface ConfigSectionProps {
title: string;
icon?: React.ReactNode;
description?: string;
children: React.ReactNode;
isMobile: boolean;
}
const ConfigSection: React.FC<ConfigSectionProps> = ({ title, icon, description, children, isMobile }) => {
return (
<Card
size="small"
title={
<Space>
{icon}
<span>{title}</span>
</Space>
}
style={{ marginBottom: isMobile ? 16 : 24 }}
bodyStyle={{ padding: isMobile ? '16px 12px' : '20px 16px' }}
bordered={true}
>
{description && (
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
<InfoCircleOutlined style={{ marginRight: 8 }} />
{description}
</Paragraph>
)}
{children}
</Card>
);
};
export default ConfigSection;

View File

@@ -0,0 +1,563 @@
import React from 'react';
import { Tabs, Form, Input, Button, Select, Space, Divider, Typography } from 'antd';
import {
ApiOutlined, RocketOutlined, PictureOutlined, SaveOutlined,
SafetyCertificateOutlined, LockOutlined, GlobalOutlined, SettingOutlined,
CloudServerOutlined, DatabaseOutlined, UploadOutlined} from '@ant-design/icons';
import ConfigFormItem from './ConfigFormItem';
import ConfigSection from './ConfigSection';
import VectorDbConfig from './VectorDbConfig';
const { TabPane } = Tabs;
const { Option } = Select;
const { Title, Paragraph } = Typography;
interface ConfigStructure {
[key: string]: {
[key: string]: string;
};
}
interface ConfigTabsProps {
configs: ConfigStructure;
secretFields: Record<string, string[]>;
isMobile: boolean;
activeKey: string;
onTabChange: (key: string) => void;
storageType: string;
onStorageTypeChange: (type: string) => void;
formsMap: Record<string, any>;
allDescriptions: Record<string, Record<string, string>>;
onSaveSingleConfig: (formInstance: any, groupName: string, key: string) => Promise<void>;
onSaveAllForGroup: (formInstance: any, groupName: string, itemKeys: string[]) => Promise<void>;
onBaseSaveConfig: (group: string, key: string, value: string) => Promise<boolean>;
setConfigs: React.Dispatch<React.SetStateAction<ConfigStructure>>;
storageOptions: Array<{ value: string; label: string; icon: React.ReactNode; }>;
imageFormatOptions: Array<{ value: string; label: string; description: string; }>;
imageQualityOptions: Array<{ value: string; label: string; description: string; }>;
}
const ConfigTabs: React.FC<ConfigTabsProps> = ({
configs,
secretFields,
isMobile,
activeKey,
onTabChange,
storageType,
onStorageTypeChange,
formsMap,
allDescriptions,
onSaveSingleConfig,
onSaveAllForGroup,
onBaseSaveConfig,
setConfigs,
storageOptions,
imageFormatOptions,
imageQualityOptions,
}) => {
const renderConfigFormItems = (formInstance: any, groupName: string, itemKeys: string[]) => {
return itemKeys.map(key => {
const isSecret = secretFields[groupName]?.includes(key);
const description = allDescriptions[groupName]?.[key] || '';
const currentValue = configs[groupName]?.[key];
return (
<ConfigFormItem
key={key}
groupName={groupName}
itemKey={key}
description={description}
isSecret={isSecret}
currentValue={currentValue}
formInstance={formInstance}
isMobile={isMobile}
onSave={onSaveSingleConfig}
/>
);
});
};
const tabItems = [
{
key: 'AI',
label: 'AI 设置',
icon: <ApiOutlined />,
children: (
<Tabs defaultActiveKey="basic" type="card" size={isMobile ? "small" : "middle"}>
<TabPane tab="基础配置" key="basic">
<ConfigSection
title="AI 服务配置"
icon={<RocketOutlined />}
description="配置AI服务的基本参数包括API端点、密钥和模型选择"
isMobile={isMobile}
>
<Form form={formsMap.AI} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.AI, "AI", ['ApiEndpoint', 'ApiKey', 'Model', 'EmbeddingModel'])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.AI, "AI", ['ApiEndpoint', 'ApiKey', 'Model', 'EmbeddingModel'])}
style={{ width: isMobile ? '100%' : '240px' }}
>
</Button>
</Form.Item>
</Form>
</ConfigSection>
</TabPane>
<TabPane tab="提示词设置" key="prompts">
<ConfigSection
title="图片分析提示词"
icon={<PictureOutlined />}
description={allDescriptions.AI?.ImageAnalysisPrompt}
isMobile={isMobile}
>
<Input.TextArea
rows={8}
value={configs.AI?.ImageAnalysisPrompt || ""}
onChange={(e) => {
const newConfigs = { ...configs };
if (!newConfigs.AI) newConfigs.AI = {};
newConfigs.AI.ImageAnalysisPrompt = e.target.value;
setConfigs(newConfigs);
}}
style={{ marginBottom: 16 }}
/>
<div style={{ textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onBaseSaveConfig('AI', 'ImageAnalysisPrompt', configs.AI?.ImageAnalysisPrompt || '')}
style={{ width: isMobile ? '100%' : '240px' }}
>
</Button>
</div>
</ConfigSection>
<ConfigSection
title="标签生成提示词"
icon={<PictureOutlined />}
description={allDescriptions.AI?.TagGenerationPrompt}
isMobile={isMobile}
>
<Input.TextArea
rows={8}
value={configs.AI?.TagGenerationPrompt || ""}
onChange={(e) => {
const newConfigs = { ...configs };
if (!newConfigs.AI) newConfigs.AI = {};
newConfigs.AI.TagGenerationPrompt = e.target.value;
setConfigs(newConfigs);
}}
style={{ marginBottom: 16 }}
/>
<div style={{ textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onBaseSaveConfig('AI', 'TagGenerationPrompt', configs.AI?.TagGenerationPrompt || '')}
style={{ width: isMobile ? '100%' : '240px' }}
>
</Button>
</div>
</ConfigSection>
<ConfigSection
title="标签匹配提示词"
icon={<PictureOutlined />}
description={allDescriptions.AI?.TagMatchingPrompt}
isMobile={isMobile}
>
<Input.TextArea
rows={8}
value={configs.AI?.TagMatchingPrompt || ""}
onChange={(e) => {
const newConfigs = { ...configs };
if (!newConfigs.AI) newConfigs.AI = {};
newConfigs.AI.TagMatchingPrompt = e.target.value;
setConfigs(newConfigs);
}}
style={{ marginBottom: 16 }}
/>
<div style={{ textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onBaseSaveConfig('AI', 'TagMatchingPrompt', configs.AI?.TagMatchingPrompt || '')}
style={{ width: isMobile ? '100%' : '240px' }}
>
</Button>
</div>
</ConfigSection>
</TabPane>
</Tabs>
)
},
{
key: 'Authorization',
label: '授权配置',
icon: <SafetyCertificateOutlined />,
children: (
<Tabs defaultActiveKey="jwt" type="card" size={isMobile ? "small" : "middle"}>
<TabPane tab="JWT 设置" key="jwt">
<ConfigSection
title="JWT 安全配置"
icon={<LockOutlined />}
description="JSON Web Token (JWT) 的安全设置,用于管理用户身份验证和授权"
isMobile={isMobile}
>
<Form form={formsMap.Jwt} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.Jwt, "Jwt", ['SecretKey', 'Issuer', 'Audience'])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.Jwt, "Jwt", ['SecretKey', 'Issuer', 'Audience'])}
style={{ width: isMobile ? '100%' : '240px' }}
>
JWT
</Button>
</Form.Item>
</Form>
</ConfigSection>
</TabPane>
<TabPane tab="GitHub认证" key="github">
<ConfigSection
title="GitHub OAuth 配置"
icon={<GlobalOutlined />}
description="GitHub OAuth 应用配置,用于实现第三方登录功能"
isMobile={isMobile}
>
<Form form={formsMap.Authentication} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.Authentication, "Authentication", ["GitHubClientId", "GitHubClientSecret", "GitHubCallbackUrl"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.Authentication, "Authentication", ["GitHubClientId", "GitHubClientSecret", "GitHubCallbackUrl"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
GitHub
</Button>
</Form.Item>
</Form>
</ConfigSection>
</TabPane>
</Tabs>
)
},
{
key: 'AppSettings',
label: '应用设置',
icon: <SettingOutlined />,
children: (
<ConfigSection
title="应用基础设置"
icon={<SettingOutlined />}
description="应用程序的基本配置参数"
isMobile={isMobile}
>
<Form form={formsMap.AppSettings} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.AppSettings, "AppSettings", ['ServerUrl'])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.AppSettings, "AppSettings", ['ServerUrl'])}
style={{ width: isMobile ? '100%' : '240px' }}
>
</Button>
</Form.Item>
</Form>
</ConfigSection>
)
},
{
key: 'Storage',
label: '存储设置',
icon: <CloudServerOutlined />,
children: (
<>
<ConfigSection
title="存储类型配置"
icon={<DatabaseOutlined />}
description="配置系统默认使用的文件存储方式"
isMobile={isMobile}
>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
gap: isMobile ? 12 : 16,
marginBottom: 0
}}>
<div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={configs.Storage?.DefaultStorage || 'Local'}
onChange={(value) => onBaseSaveConfig('Storage', 'DefaultStorage', value)}
style={{ width: '100%' }}
size="large"
placeholder="选择登录用户的默认存储方式"
optionLabelProp="label"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{allDescriptions.Storage?.DefaultStorage}
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={configs.Storage?.AnonymousDefaultStorage || 'Local'}
onChange={(value) => onBaseSaveConfig('Storage', 'AnonymousDefaultStorage', value)}
style={{ width: '100%' }}
size="large"
placeholder="选择匿名用户的默认存储方式"
optionLabelProp="label"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{allDescriptions.Storage?.AnonymousDefaultStorage}
</div>
</div>
</div>
</ConfigSection>
<ConfigSection
title="上传设置配置"
icon={<UploadOutlined />}
description="配置文件上传处理方式和图片转换参数"
isMobile={isMobile}
>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
gap: isMobile ? 12 : 16,
marginBottom: 0
}}>
<div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={configs.Upload?.DefaultImageFormat || 'Original'}
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageFormat', value)}
style={{ width: '100%' }}
size="large"
placeholder="选择上传图片的默认处理格式"
optionLabelProp="label"
>
{imageFormatOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div>
<div>{option.label}</div>
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{allDescriptions.Upload?.DefaultImageFormat}
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={configs.Upload?.DefaultImageQuality || '95'}
onChange={(value) => onBaseSaveConfig('Upload', 'DefaultImageQuality', value)}
style={{ width: '100%' }}
size="large"
placeholder="选择图片压缩质量"
optionLabelProp="label"
>
{imageQualityOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div>
<div>{option.label}</div>
<div style={{ fontSize: '12px', color: '#999' }}>{option.description}</div>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
{allDescriptions.Upload?.DefaultImageQuality}
</div>
</div>
</div>
</ConfigSection>
<ConfigSection
title="存储服务配置"
icon={<CloudServerOutlined />}
description="配置各种外部存储服务的连接参数"
isMobile={isMobile}
>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontSize: 14, fontWeight: 500, color: '#666' }}>
</div>
<Select
value={storageType}
onChange={onStorageTypeChange}
style={{ width: isMobile ? '100%' : '300px' }}
size="large"
placeholder="选择需要配置的存储服务类型"
optionLabelProp="label"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value} label={option.label}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{ fontSize: 12, color: '#999', marginTop: 4, marginBottom: 16 }}>
</div>
</div>
<div style={{ border: '1px solid #f0f0f0', borderRadius: 6, padding: isMobile ? 12 : 16, backgroundColor: '#fafafa' }}>
{storageType === 'Local' && (
<div style={{ textAlign: 'center', color: '#999', padding: '30px 0' }}>
<DatabaseOutlined style={{ fontSize: 32, color: '#52c41a', marginBottom: 16 }} />
<Title level={5}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
)}
{storageType === 'Telegram' && (
<Form form={formsMap.TelegramStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.TelegramStorage, "Storage", ["TelegramStorageBotToken", "TelegramStorageChatId", "TelegramProxyAddress", "TelegramProxyPort", "TelegramProxyUsername", "TelegramProxyPassword"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
Telegram
</Button>
</Form.Item>
</Form>
)}
{storageType === 'S3' && (
<Form form={formsMap.S3Storage} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.S3Storage, "Storage", ["S3StorageAccessKey", "S3StorageSecretKey", "S3StorageBucketName", "S3StorageRegion", "S3StorageEndpoint", "S3StorageCdnUrl", "S3StorageUsePathStyleUrls"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
S3
</Button>
</Form.Item>
</Form>
)}
{storageType === 'Cos' && (
<Form form={formsMap.CosStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.CosStorage, "Storage", ["CosStorageSecretId", "CosStorageSecretKey", "CosStorageToken", "CosStorageBucketName", "CosStorageRegion", "CosStorageCdnUrl"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
COS
</Button>
</Form.Item>
</Form>
)}
{storageType === 'WebDAV' && (
<Form form={formsMap.WebDAVStorage} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.WebDAVStorage, "Storage", ["WebDAVServerUrl", "WebDAVUserName", "WebDAVPassword", "WebDAVBasePath", "WebDAVPublicUrl"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
WebDAV
</Button>
</Form.Item>
</Form>
)}
</div>
</ConfigSection>
</>
)
},
{
key: 'VectorDb',
label: '向量数据',
icon: <DatabaseOutlined />,
children: (
<VectorDbConfig isMobile={isMobile} />
)
}
];
return (
<Tabs
activeKey={activeKey}
onChange={onTabChange}
size={isMobile ? "small" : "middle"}
tabPosition={isMobile ? "top" : "left"}
style={{
minHeight: isMobile ? 'auto' : 400
}}
items={tabItems.map(item => ({
key: item.key,
label: (
<Space>
{item.icon}
<span>{item.label}</span>
</Space>
),
children: item.children
}))}
/>
);
};
export default ConfigTabs;

View File

@@ -0,0 +1,615 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Card, message, Spin, Button, Upload, Modal, Space, Tooltip, Form, Typography, notification } from 'antd';
import {
CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined,
DownloadOutlined, UploadOutlined, QuestionCircleOutlined,
SettingOutlined,
CheckCircleOutlined
} from '@ant-design/icons';
import { getAllConfigs, setConfig, backupConfigs, restoreConfigs } from '../../../api';
import useIsMobile from '../../../hooks/useIsMobile.ts';
import ConfigTabs from './ConfigTabs';
const { Text, Paragraph } = Typography;
interface ConfigStructure {
[key: string]: {
[key: string]: string;
};
}
const allDescriptions: Record<string, Record<string, string>> = {
AI: {
ApiEndpoint: 'AI 服务的API端点地址',
ApiKey: 'AI 服务的API密钥',
Model: 'AI 模型名称',
EmbeddingModel: '嵌入向量模型名称',
ImageAnalysisPrompt: '用于分析图片内容并提取描述的提示词。请确保提示词包含返回JSON格式的指示并且要求返回标题(title)和描述(description)字段。',
TagGenerationPrompt: '用于从图片内容生成标签的提示词。请确保提示词包含返回JSON格式的指示并且要求返回tags数组字段。',
TagMatchingPrompt: '用于将描述内容与已有标签进行匹配的提示词。请确保提示词包含{\'{tagsText}\'}和{\'{description}\'}占位符,系统将会用实际的标签列表和描述内容替换这些占位符。'
},
Jwt: {
SecretKey: 'JWT 加密密钥',
Issuer: 'JWT 签发者',
Audience: 'JWT 接收者',
},
Authentication: {
GitHubClientId: 'GitHub OAuth 应用客户端ID',
GitHubClientSecret: 'GitHub OAuth 应用客户端密钥',
GitHubCallbackUrl: 'GitHub OAuth 认证回调地址'
},
AppSettings: {
ServerUrl: '服务器URL'
},
Storage: {
DefaultStorage: '已登录用户上传文件时的默认存储位置',
AnonymousDefaultStorage: '未登录用户上传文件时的默认存储位置',
TelegramStorageBotToken: 'Telegram 机器人令牌',
TelegramStorageChatId: 'Telegram 聊天ID',
TelegramProxyAddress: '代理服务器地址 (例如: 127.0.0.1)',
TelegramProxyPort: '代理服务器端口 (例如: 1080)',
TelegramProxyUsername: '代理用户名 (可选)',
TelegramProxyPassword: '代理密码 (可选)',
S3StorageAccessKey: 'S3访问密钥',
S3StorageSecretKey: 'S3私有密钥',
S3StorageBucketName: 'S3存储桶名称',
S3StorageRegion: 'S3区域 (例如:us-east-1)',
S3StorageEndpoint: 'S3端点URL (可选,默认为AWS S3)',
S3StorageCdnUrl: 'CDN URL (可选,用于加速文件访问)',
S3StorageUsePathStyleUrls: '使用路径形式URLs (true/false,兼容非AWS服务)',
CosStorageSecretId: '腾讯云COS密钥ID',
CosStorageSecretKey: '腾讯云COS私有密钥',
CosStorageToken: '腾讯云COS临时令牌(可选)',
CosStorageBucketName: 'COS存储桶名称',
CosStorageRegion: 'COS区域 (例如:ap-shanghai)',
CosStorageCdnUrl: 'CDN URL (可选,用于加速文件访问)',
WebDAVServerUrl: 'WebDAV 服务器 URL (例如: https://dav.example.com)',
WebDAVUserName: 'WebDAV 用户名',
WebDAVPassword: 'WebDAV 密码',
WebDAVBasePath: 'WebDAV 基础路径 (例如: files/upload)',
WebDAVPublicUrl: 'WebDAV 公共访问 URL (可选,用于文件访问)',
},
Upload: {
DefaultImageFormat: '上传图片时的默认处理格式,选择合适的格式可以优化存储和显示',
DefaultImageQuality: '适用于JPEG和WebP格式的图片质量设置越高图片质量越好但文件越大'
}
};
const System: React.FC = () => {
const isMobile = useIsMobile();
const [loading, setLoading] = useState(true);
const [configs, setConfigs] = useState<ConfigStructure>({});
const [activeKey, setActiveKey] = useState('AI');
const [storageType, setStorageType] = useState('Telegram');
const [backupLoading, setBackupLoading] = useState(false);
const [restoreLoading, setRestoreLoading] = useState(false);
const [restoreModalVisible, setRestoreModalVisible] = useState(false);
const [restoreConfig, setRestoreConfig] = useState<Record<string, string> | null>(null);
const [secretFields, setSecretFields] = useState<Record<string, string[]>>({});
const [, setSavingFields] = useState<Set<string>>(new Set()); // 保留用于 baseSaveConfig
const debounceTimerRef = useRef<number | null>(null);
const [aiForm] = Form.useForm();
const [jwtForm] = Form.useForm();
const [authForm] = Form.useForm();
const [appSettingsForm] = Form.useForm();
const [telegramForm] = Form.useForm();
const [s3Form] = Form.useForm();
const [cosForm] = Form.useForm();
const [webDAVForm] = Form.useForm();
const [uploadForm] = Form.useForm();
const formsMap: Record<string, any> = {
AI: aiForm,
Jwt: jwtForm,
Authentication: authForm,
AppSettings: appSettingsForm,
TelegramStorage: telegramForm,
S3Storage: s3Form,
CosStorage: cosForm,
WebDAVStorage: webDAVForm,
Upload: uploadForm,
};
// 获取所有配置项
const fetchConfigs = async () => {
setLoading(true);
try {
const response = await getAllConfigs();
if (response.success && response.data) {
const configGroups: ConfigStructure = {};
const secretFieldsMap: Record<string, string[]> = {};
response.data.forEach(config => {
const [group, key] = config.key.split(':');
if (!configGroups[group]) {
configGroups[group] = {};
}
configGroups[group][key] = config.value;
if (config.isSecret) {
if (!secretFieldsMap[group]) {
secretFieldsMap[group] = [];
}
secretFieldsMap[group].push(key);
}
});
setConfigs(configGroups);
setSecretFields(secretFieldsMap);
if (configGroups.Storage?.DefaultStorage) {
setStorageType(configGroups.Storage.DefaultStorage);
}
// 更高效地更新表单值
Object.keys(configGroups).forEach(group => {
let formInstanceKey = group;
if (group === "Storage") {
} else if (group.endsWith("Storage") && formsMap[group]) {
formInstanceKey = group;
}
const formInstance = formsMap[formInstanceKey] || (group === "Storage" ? formsMap[`${configGroups.Storage.DefaultStorage}Storage`] : null);
if (formInstance) {
const initialGroupValues: Record<string, string> = {};
Object.keys(configGroups[group]).forEach(key => {
if (!secretFieldsMap[group]?.includes(key)) {
initialGroupValues[key] = configGroups[group][key];
} else {
initialGroupValues[key] = '';
}
});
formInstance.setFieldsValue(initialGroupValues);
}
});
} else {
message.error('获取配置失败: ' + response.message);
}
} catch (error) {
message.error('获取配置出错');
console.error(error);
} finally {
setLoading(false);
}
};
// 自定义防抖函数实现
const debounce = useCallback((fn: Function, delay: number) => {
return (...args: any[]) => {
// 清除之前的定时器
if (debounceTimerRef.current) {
window.clearTimeout(debounceTimerRef.current);
}
// 设置新的定时器
debounceTimerRef.current = window.setTimeout(() => {
fn(...args);
debounceTimerRef.current = null;
}, delay);
};
}, []);
// 保存配置项 (Core API call) - 添加防抖功能和更好的状态管理
const baseSaveConfig = async (group: string, key: string, value: string) => {
const configKey = `${group}:${key}`;
setSavingFields(prev => new Set(prev).add(configKey));
try {
const response = await setConfig({
key: configKey,
value: value,
description: `${group} ${key} setting`
});
if (response.success) {
notification.success({
message: '保存成功',
description: `${key} 配置已更新`,
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
placement: 'bottomRight',
duration: 3,
});
setConfigs(prev => {
const newConfigs = { ...prev };
if (!newConfigs[group]) newConfigs[group] = {};
newConfigs[group][key] = value;
return newConfigs;
});
return true;
} else {
notification.error({
message: '保存失败',
description: `${key}: ${response.message}`,
placement: 'bottomRight',
duration: 4,
});
return false;
}
} catch (error) {
notification.error({
message: '系统错误',
description: `保存 ${key} 配置时发生错误`,
placement: 'bottomRight',
duration: 4,
});
console.error(error);
return false;
} finally {
setSavingFields(prev => {
const newSet = new Set(prev);
newSet.delete(configKey);
return newSet;
});
}
};
const handleSaveSingleConfig = async (formInstance: any, groupName: string, key: string) => {
try {
await formInstance.validateFields([key]);
const value = formInstance.getFieldValue(key);
const isSecret = secretFields[groupName]?.includes(key);
if (isSecret && (value === '' || value === undefined)) {
message.info(`未输入 ${key} 的新值,不作更改。`);
return;
}
// 使用自定义防抖函数包装保存操作
debounce((g: string, k: string, v: string) => {
baseSaveConfig(g, k, v);
}, 300)(groupName, key, value);
if (isSecret) {
formInstance.setFieldsValue({ [key]: '' });
}
} catch (errorInfo) {
console.error(`保存配置 ${groupName}:${key} 失败:`, errorInfo);
}
};
const handleSaveAllForGroup = async (formInstance: any, groupName: string, itemKeys: string[]) => {
try {
await formInstance.validateFields(itemKeys);
const values = formInstance.getFieldsValue(itemKeys);
let changesMade = false;
let successCount = 0;
let totalToSave = 0;
// 计算需要保存的总数
for (const key of itemKeys) {
const value = values[key];
const isSecret = secretFields[groupName]?.includes(key);
if (!(isSecret && (value === '' || value === undefined)) &&
(isSecret || configs[groupName]?.[key] !== value)) {
totalToSave++;
}
}
if (totalToSave === 0) {
message.info(`${groupName} 中没有需要更新的配置。`);
return;
}
// 显示批量保存开始通知
notification.open({
key: `saving-${groupName}`,
message: `正在保存 ${groupName} 配置`,
description: `正在处理 ${totalToSave} 项配置...`,
icon: <Spin size="small" />,
duration: 0,
});
for (const key of itemKeys) {
const value = values[key];
const isSecret = secretFields[groupName]?.includes(key);
if (isSecret && (value === '' || value === undefined)) {
continue;
}
if (!isSecret && configs[groupName]?.[key] === value) {
continue;
}
const success = await baseSaveConfig(groupName, key, value);
if (success) {
changesMade = true;
successCount++;
if (isSecret) {
formInstance.setFieldsValue({ [key]: '' });
}
}
}
// 更新或关闭批量保存通知
if (changesMade) {
notification.success({
key: `saving-${groupName}`,
message: `${groupName} 配置已更新`,
description: `成功保存了 ${successCount} 项配置`,
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
duration: 3,
});
} else {
notification.destroy(`saving-${groupName}`);
message.info(`${groupName} 中没有配置被更改。`);
}
} catch (errorInfo) {
notification.destroy(`saving-${groupName}`);
console.error(`保存 ${groupName} 所有配置失败:`, errorInfo);
}
};
// 备份配置
const handleBackupConfigs = async () => {
setBackupLoading(true);
try {
const response = await backupConfigs();
if (response.success && response.data) {
const configData = JSON.stringify(response.data, null, 2);
const blob = new Blob([configData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
a.download = `foxel-config-backup-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
notification.success({
message: '备份成功',
description: '配置备份文件已下载到您的设备',
placement: 'bottomRight',
duration: 3,
});
} else {
notification.error({
message: '备份失败',
description: response.message || '无法生成备份文件',
placement: 'bottomRight',
});
}
} catch (error) {
notification.error({
message: '系统错误',
description: '备份配置时发生错误',
placement: 'bottomRight',
});
console.error(error);
} finally {
setBackupLoading(false);
}
};
// 上传配置文件
const handleFileUpload = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const config = JSON.parse(content);
setRestoreConfig(config);
setRestoreModalVisible(true);
} catch (error) {
notification.error({
message: '文件格式错误',
description: '无法解析上传的配置文件请确认是有效的JSON格式',
placement: 'bottomRight',
});
}
};
reader.readAsText(file);
return false; // 阻止自动上传
};
// 确认恢复配置
const handleRestoreConfigs = async () => {
if (!restoreConfig) return;
setRestoreLoading(true);
try {
const response = await restoreConfigs(restoreConfig);
if (response.success) {
notification.success({
message: '恢复成功',
description: '配置已成功恢复页面将在3秒后刷新',
placement: 'bottomRight',
duration: 3,
});
setRestoreModalVisible(false);
// 重新加载配置
setTimeout(() => {
fetchConfigs();
}, 3000);
} else {
notification.error({
message: '恢复失败',
description: response.message || '无法应用配置',
placement: 'bottomRight',
});
}
} catch (error) {
notification.error({
message: '系统错误',
description: '恢复配置时发生错误',
placement: 'bottomRight',
});
console.error(error);
} finally {
setRestoreLoading(false);
}
};
// 存储类型选项
const storageOptions = [
{ value: 'Local', label: '本地存储', icon: <DatabaseOutlined style={{ color: '#52c41a' }} /> },
{ value: 'Telegram', label: 'Telegram 频道', icon: <CloudOutlined style={{ color: '#0088cc' }} /> },
{ value: 'S3', label: '亚马逊 S3', icon: <CloudServerOutlined style={{ color: '#ff9900' }} /> },
{ value: 'Cos', label: '腾讯云 COS', icon: <CloudServerOutlined style={{ color: '#00a4ff' }} /> },
{ value: 'WebDAV', label: 'WebDAV 存储', icon: <GlobalOutlined style={{ color: '#1890ff' }} /> },
];
// 上传格式选项
const imageFormatOptions = [
{ value: 'Original', label: '保持原始格式', description: '不改变原始图片格式' },
{ value: 'Jpeg', label: '转换为JPEG', description: '适合照片,文件较小但有损压缩' },
{ value: 'Png', label: '转换为PNG', description: '适合图形,无损但文件较大' },
{ value: 'Webp', label: '转换为WebP', description: '现代格式,体积小且质量好' },
];
// 图片质量选项
const imageQualityOptions = [
{ value: '100', label: '100% - 最高质量', description: '无损压缩,文件较大' },
{ value: '95', label: '95% - 高质量', description: '几乎无损,推荐用于高质量需求' },
{ value: '90', label: '90% - 优质', description: '良好平衡,推荐一般用途' },
{ value: '85', label: '85% - 良好', description: '适合网页展示,节省空间' },
{ value: '80', label: '80% - 节省空间', description: '明显压缩但质量可接受' },
{ value: '75', label: '75% - 平衡', description: '显著减小文件大小' },
{ value: '70', label: '70% - 压缩', description: '最大压缩,质量较低' },
];
useEffect(() => {
fetchConfigs();
}, []);
return (
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<SettingOutlined />
<span></span>
</Space>
<Space>
<Tooltip title="下载当前所有配置的备份">
<Button
icon={<DownloadOutlined />}
onClick={handleBackupConfigs}
loading={backupLoading}
size={isMobile ? "small" : "middle"}
type="primary"
ghost
>
{isMobile ? '' : '备份配置'}
</Button>
</Tooltip>
<Upload
beforeUpload={handleFileUpload}
showUploadList={false}
accept=".json"
>
<Tooltip title="从备份文件恢复配置">
<Button
icon={<UploadOutlined />}
size={isMobile ? "small" : "middle"}
type={isMobile ? "primary" : "default"}
ghost={isMobile}
>
{isMobile ? '' : '恢复配置'}
</Button>
</Tooltip>
</Upload>
</Space>
</div>
}
className="system-config-card"
bodyStyle={{
padding: isMobile ? '12px 8px' : '24px',
transition: 'all 0.3s'
}}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<Spin size="large" tip="加载系统配置中..." />
</div>
) : (
<ConfigTabs
configs={configs}
secretFields={secretFields}
isMobile={isMobile}
activeKey={activeKey}
onTabChange={setActiveKey}
storageType={storageType}
onStorageTypeChange={setStorageType}
formsMap={formsMap}
allDescriptions={allDescriptions}
onSaveSingleConfig={handleSaveSingleConfig}
onSaveAllForGroup={handleSaveAllForGroup}
onBaseSaveConfig={baseSaveConfig}
setConfigs={setConfigs}
storageOptions={storageOptions}
imageFormatOptions={imageFormatOptions}
imageQualityOptions={imageQualityOptions}
/>
)}
{/* 恢复配置确认对话框 */}
<Modal
title={
<Space>
<UploadOutlined />
<span></span>
<Tooltip title="恢复配置将覆盖当前所有配置设置,请确认备份文件正确无误">
<QuestionCircleOutlined style={{ cursor: 'help' }} />
</Tooltip>
</Space>
}
open={restoreModalVisible}
onCancel={() => setRestoreModalVisible(false)}
footer={[
<Button key="cancel" onClick={() => setRestoreModalVisible(false)}>
</Button>,
<Button
key="submit"
type="primary"
danger
loading={restoreLoading}
onClick={handleRestoreConfigs}
>
</Button>
]}
width={500}
maskClosable={false}
>
<div style={{ padding: '16px 0' }}>
<Paragraph>
<Text strong></Text>
</Paragraph>
<Paragraph type="danger" style={{ fontWeight: 'bold' }}>
</Paragraph>
{restoreConfig && (
<div style={{
background: '#f6f6f6',
padding: '10px 16px',
borderRadius: 4,
marginTop: 16
}}>
<Paragraph> <Text strong>{Object.keys(restoreConfig).length}</Text> </Paragraph>
<Paragraph type="secondary" style={{ fontSize: 12, margin: 0 }}>
使
</Paragraph>
</div>
)}
</div>
</Modal>
</Card>
);
};
export default System;

View File

@@ -0,0 +1,396 @@
import React, { useState, useEffect } from 'react';
import { Card, Radio, Button, message, Spin, Space, Typography, notification, Form, Input, Modal } from 'antd';
import { DatabaseOutlined, SyncOutlined, CheckCircleOutlined, InfoCircleOutlined, SaveOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { getCurrentVectorDb, switchVectorDb, setConfig, clearVectors, rebuildVectors } from '../../../api';
import { VectorDbType } from '../../../api/types';
const { Title, Paragraph } = Typography;
interface VectorDbConfigProps {
isMobile: boolean;
}
const VectorDbConfig: React.FC<VectorDbConfigProps> = ({ isMobile }) => {
const [loading, setLoading] = useState(true);
const [switching, setSwitching] = useState(false);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [currentType, setCurrentType] = useState<string>('');
const [selectedType, setSelectedType] = useState<VectorDbType>(VectorDbType.Qdrant);
const [qdrantConfig, setQdrantConfig] = useState({
host: '',
apiKey: ''
});
const [form] = Form.useForm();
const fetchCurrentVectorDb = async () => {
setLoading(true);
try {
const response = await getCurrentVectorDb();
if (response.success && response.data) {
setCurrentType(response.data.type);
setSelectedType(response.data.type as VectorDbType);
// 安全地访问配置值
if (response.data && response.data.type) {
// 使用可选链和类型断言来安全地访问配置
const config = (response.data as any).config;
if (config) {
setQdrantConfig({
host: config.QdrantHost || '',
apiKey: config.QdrantApiKey || ''
});
form.setFieldsValue({
QdrantHost: config.QdrantHost || '',
QdrantApiKey: '' // 不显示API密钥的值
});
}
}
} else {
message.error('获取当前向量数据库失败: ' + response.message);
}
} catch (error) {
console.error('获取当前向量数据库出错:', error);
message.error('获取当前向量数据库出错');
} finally {
setLoading(false);
}
};
const handleSwitchVectorDb = async () => {
if (selectedType === currentType) {
message.info('当前已经是该向量数据库类型');
return;
}
setSwitching(true);
try {
const response = await switchVectorDb(selectedType);
if (response.success) {
notification.success({
message: '切换成功',
description: `已成功切换到 ${selectedType} 向量数据库`,
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
});
setCurrentType(selectedType);
} else {
notification.error({
message: '切换失败',
description: response.message,
});
}
} catch (error) {
console.error('切换向量数据库出错:', error);
notification.error({
message: '切换出错',
description: '切换向量数据库时发生错误',
});
} finally {
setSwitching(false);
}
};
const saveQdrantConfig = async () => {
try {
await form.validateFields();
const values = form.getFieldsValue();
setSaving(true);
const saveHost = async () => {
if (values.QdrantHost && values.QdrantHost !== qdrantConfig.host) {
const response = await setConfig({
key: 'VectorDb:QdrantHost',
value: values.QdrantHost,
description: 'Qdrant服务器地址'
});
if (!response.success) {
throw new Error('保存Qdrant主机地址失败');
}
return true;
}
return false;
};
const saveApiKey = async () => {
if (values.QdrantApiKey) {
const response = await setConfig({
key: 'VectorDb:QdrantApiKey',
value: values.QdrantApiKey,
description: 'Qdrant API密钥'
});
if (!response.success) {
throw new Error('保存Qdrant API密钥失败');
}
return true;
}
return false;
};
const hostSaved = await saveHost();
const apiKeySaved = await saveApiKey();
if (hostSaved || apiKeySaved) {
notification.success({
message: 'Qdrant配置已保存',
description: '向量数据库配置更新成功',
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
});
// 更新本地状态
if (hostSaved) {
setQdrantConfig(prev => ({...prev, host: values.QdrantHost}));
}
if (apiKeySaved) {
setQdrantConfig(prev => ({...prev, apiKey: values.QdrantApiKey}));
form.setFieldsValue({QdrantApiKey: ''});
}
} else {
message.info('没有配置被更改');
}
} catch (error) {
notification.error({
message: '保存失败',
description: error instanceof Error ? error.message : '保存Qdrant配置时发生错误',
});
} finally {
setSaving(false);
}
};
const handleClearVectors = () => {
Modal.confirm({
title: '确认清空向量数据库',
content: '此操作将清空所有向量数据,不可恢复。确定要继续吗?',
okText: '确认清空',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
setClearing(true);
try {
const response = await clearVectors();
if (response.success) {
notification.success({
message: '清空成功',
description: '已成功清空向量数据库',
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
});
} else {
notification.error({
message: '清空失败',
description: response.message,
});
}
} catch (error) {
console.error('清空向量数据库出错:', error);
notification.error({
message: '操作出错',
description: '清空向量数据库时发生错误',
});
} finally {
setClearing(false);
}
}
});
};
const handleRebuildVectors = () => {
Modal.confirm({
title: '确认重建向量数据库',
content: '此操作将重新构建所有向量数据,可能需要较长时间。确定要继续吗?',
okText: '确认重建',
okType: 'primary',
cancelText: '取消',
onOk: async () => {
setRebuilding(true);
try {
const response = await rebuildVectors();
if (response.success) {
notification.success({
message: '重建已开始',
description: '向量数据库重建过程已开始,请耐心等待完成',
icon: <SyncOutlined spin style={{ color: '#1890ff' }} />,
duration: 5,
});
} else {
notification.error({
message: '重建失败',
description: response.message,
});
}
} catch (error) {
console.error('重建向量数据库出错:', error);
notification.error({
message: '操作出错',
description: '重建向量数据库时发生错误',
});
} finally {
setRebuilding(false);
}
}
});
};
useEffect(() => {
fetchCurrentVectorDb();
}, []);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" tip="加载向量数据库配置..." />
</div>
);
}
return (
<Card
title={
<Space>
<DatabaseOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 16 }}
bodyStyle={{ padding: isMobile ? '16px 12px' : '20px 16px' }}
>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
<InfoCircleOutlined style={{ marginRight: 8 }} />
</Paragraph>
<div style={{ marginBottom: 24 }}>
<Title level={5}>: {currentType}</Title>
</div>
<div style={{ marginBottom: 24 }}>
<Radio.Group
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
optionType="button"
buttonStyle="solid"
size="large"
style={{ marginBottom: 16 }}
>
<Radio.Button value={VectorDbType.InMemory}>
<Space>
<DatabaseOutlined />
<span>InMemory</span>
</Space>
</Radio.Button>
<Radio.Button value={VectorDbType.Qdrant}>
<Space>
<DatabaseOutlined />
<span>Qdrant</span>
</Space>
</Radio.Button>
</Radio.Group>
<div style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<SyncOutlined />}
loading={switching}
onClick={handleSwitchVectorDb}
disabled={selectedType === currentType}
>
{selectedType}
</Button>
{selectedType === currentType && (
<span style={{ marginLeft: 8, color: '#52c41a' }}>
<CheckCircleOutlined />
</span>
)}
</div>
</div>
{selectedType === VectorDbType.Qdrant && (
<div style={{ marginBottom: 24, border: '1px solid #f0f0f0', borderRadius: 6, padding: 16, backgroundColor: '#fafafa' }}>
<Title level={5}>Qdrant </Title>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Qdrant服务器的连接信息
</Paragraph>
<Form
form={form}
layout="vertical"
initialValues={{
QdrantHost: qdrantConfig.host,
QdrantApiKey: ''
}}
>
<Form.Item
name="QdrantHost"
label="Qdrant 主机地址"
rules={[{ required: true, message: '请输入Qdrant服务器地址' }]}
tooltip="Qdrant服务器的完整URL例如https://example.qdrant.io"
>
<Input placeholder="例如: your-instance.qdrant.io" />
</Form.Item>
<Form.Item
name="QdrantApiKey"
label="Qdrant API密钥"
tooltip="访问Qdrant服务器所需的API密钥"
help={qdrantConfig.apiKey ? "当前已设置API密钥。如需修改请输入新值。" : ""}
>
<Input.Password placeholder="输入新的API密钥" />
</Form.Item>
<Form.Item>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={saveQdrantConfig}
loading={saving}
>
Qdrant配置
</Button>
</Form.Item>
</Form>
</div>
)}
<div style={{ marginBottom: 24 }}>
<Title level={5}></Title>
<Space size="middle" style={{ marginTop: 12 }}>
<Button
danger
icon={<DeleteOutlined />}
loading={clearing}
onClick={handleClearVectors}
>
</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={rebuilding}
onClick={handleRebuildVectors}
>
</Button>
</Space>
<Paragraph type="secondary" style={{ marginTop: 8 }}>
<InfoCircleOutlined style={{ marginRight: 8 }} />
</Paragraph>
</div>
<div style={{ background: '#f6f6f6', padding: 16, borderRadius: 4 }}>
<Title level={5}></Title>
<ul style={{ paddingLeft: 20 }}>
<li>
<b>InMemory</b>:
</li>
<li>
<b>Qdrant</b>:
</li>
</ul>
</div>
</Card>
);
};
export default VectorDbConfig;

View File

@@ -0,0 +1,412 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Button, Card, Input, Space, Modal, Form,
message, Tag, Typography, Popconfirm, Row, Col, Select
} from 'antd';
import {
UserOutlined, DeleteOutlined, EditOutlined,
SearchOutlined, ExclamationCircleOutlined, ReloadOutlined,
UserAddOutlined, UserDeleteOutlined, TeamOutlined
} from '@ant-design/icons';
import {
getUsers, deleteUser, createUser, updateUser, batchDeleteUsers, UserRole
} from '../../../api';
import type { UserResponse, CreateUserRequest, AdminUpdateUserRequest } from '../../../api/types';
import { useOutletContext } from 'react-router';
import type { Breakpoint } from 'antd';
const { Title, Text } = Typography;
const { Option } = Select;
const { confirm } = Modal;
const UserManagement: React.FC = () => {
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
// 状态管理
const [users, setUsers] = useState<UserResponse[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// 模态框状态
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalTitle, setModalTitle] = useState('');
const [editingUser, setEditingUser] = useState<UserResponse | null>(null);
const [form] = Form.useForm();
// 加载用户数据
const fetchUsers = useCallback(async (page = currentPage, size = pageSize) => {
setLoading(true);
try {
const response = await getUsers(page, size);
if (response.success && response.data) {
setUsers(response.data || []);
setTotal(response.totalCount || 0);
} else {
message.error(response.message || '获取用户列表失败');
}
} catch (error) {
console.error('Error fetching users:', error);
message.error('获取用户列表失败,请检查网络连接');
} finally {
setLoading(false);
}
}, [currentPage, pageSize]);
// 初始加载
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
// 处理页面变化
const handlePageChange = (page: number, size?: number) => {
setCurrentPage(page);
if (size) setPageSize(size);
fetchUsers(page, size || pageSize);
};
// 处理搜索
const handleSearch = () => {
// 这里应该向后端发送搜索请求但目前API不支持搜索所以仅前端过滤
// 实际项目中应该添加后端搜索支持
setCurrentPage(1);
fetchUsers(1, pageSize);
};
// 打开创建用户模态框
const showCreateModal = () => {
setModalTitle('创建新用户');
setEditingUser(null);
form.resetFields();
setIsModalVisible(true);
};
// 打开编辑用户模态框
const showEditModal = (user: UserResponse) => {
setModalTitle('编辑用户');
setEditingUser(user);
form.setFieldsValue({
userName: user.userName,
email: user.email,
role: user.role,
});
setIsModalVisible(true);
};
// 处理模态框确认
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingUser) {
// 更新用户
const updateData: AdminUpdateUserRequest = {
id: editingUser.id,
userName: values.userName,
email: values.email,
role: values.role,
};
const response = await updateUser(updateData);
if (response.success) {
message.success('用户更新成功');
fetchUsers();
} else {
message.error(response.message || '更新用户失败');
}
} else {
// 创建用户
const createData: CreateUserRequest = {
userName: values.userName,
email: values.email,
password: values.password,
role: values.role,
};
const response = await createUser(createData);
if (response.success) {
message.success('用户创建成功');
fetchUsers();
} else {
message.error(response.message || '创建用户失败');
}
}
setIsModalVisible(false);
} catch (error) {
console.error('Form validation failed:', error);
}
};
// 处理删除用户
const handleDelete = async (id: number) => {
try {
const response = await deleteUser(id);
if (response.success) {
message.success('用户删除成功');
fetchUsers();
} else {
message.error(response.message || '删除用户失败');
}
} catch (error) {
console.error('Error deleting user:', error);
message.error('删除用户失败,请检查网络连接');
}
};
// 批量删除用户
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请选择要删除的用户');
return;
}
confirm({
title: `确定要删除 ${selectedRowKeys.length} 名用户吗?`,
icon: <ExclamationCircleOutlined />,
content: '此操作不可逆,所选用户的所有数据将被删除',
okText: '确认',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
const response = await batchDeleteUsers(selectedRowKeys as number[]);
if (response.success && response.data) {
message.success(`成功删除 ${response.data.successCount} 名用户`);
if (response.data.failedCount > 0) {
message.warning(`${response.data.failedCount} 名用户删除失败`);
}
setSelectedRowKeys([]);
fetchUsers();
} else {
message.error(response.message || '批量删除用户失败');
}
} catch (error) {
console.error('Error batch deleting users:', error);
message.error('批量删除用户失败,请检查网络连接');
}
}
});
};
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
responsive: ['md' as Breakpoint],
},
{
title: '用户名',
dataIndex: 'userName',
key: 'userName',
render: (text: string) => (
<Space>
<UserOutlined />
{text}
</Space>
),
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
responsive: ['lg' as Breakpoint],
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: string) => {
let color = 'blue';
if (role === 'Administrator') {
color = 'red';
} else {
color = 'green';
}
return <Tag color={color}>{role || '用户'}</Tag>;
},
},
{
title: '注册时间',
dataIndex: 'createdAt',
key: 'createdAt',
responsive: ['lg' as Breakpoint],
render: (date: Date) => new Date(date).toLocaleString(),
},
{
title: '操作',
key: 'action',
render: (_: any, record: UserResponse) => (
<Space size="small">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => showEditModal(record)}
>
{isMobile ? '' : '编辑'}
</Button>
<Popconfirm
title="确定要删除此用户吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
>
{isMobile ? '' : '删除'}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div className="user-management">
<Row gutter={[16, 16]} align="middle" justify="space-between">
<Col>
<Space align="center">
<TeamOutlined style={{ fontSize: 24 }} />
<Title level={2} style={{ margin: 0 }}></Title>
</Space>
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
</Text>
</Col>
</Row>
<Card style={{ marginTop: 16 }}>
<Row gutter={[16, 16]} justify="space-between" style={{ marginBottom: 16 }}>
<Col xs={24} sm={14} md={16}>
<Space wrap>
<Button
type="primary"
icon={<UserAddOutlined />}
onClick={showCreateModal}
>
</Button>
<Button
danger
icon={<UserDeleteOutlined />}
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchUsers()}
>
</Button>
</Space>
</Col>
<Col xs={24} sm={10} md={8}>
<Input.Search
placeholder="搜索用户名或邮箱"
allowClear
enterButton={<SearchOutlined />}
onSearch={handleSearch}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
/>
</Col>
</Row>
<Table
rowKey="id"
columns={columns}
dataSource={users}
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
onChange: handlePageChange,
showTotal: (total) => `${total} 条记录`,
}}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
size={isMobile ? "small" : "middle"}
scroll={{ x: 'max-content' }}
/>
</Card>
{/* 创建/编辑用户模态框 */}
<Modal
title={modalTitle}
open={isModalVisible}
onOk={handleModalOk}
onCancel={() => setIsModalVisible(false)}
okText={editingUser ? "更新" : "创建"}
cancelText="取消"
width={600}
>
<Form
form={form}
layout="vertical"
initialValues={{ role: UserRole.User }}
>
<Form.Item
name="userName"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input placeholder="邮箱地址" />
</Form.Item>
{!editingUser && (
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度至少为6个字符' }
]}
>
<Input.Password placeholder="密码" />
</Form.Item>
)}
<Form.Item
name="role"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select placeholder="选择用户角色">
<Option value={UserRole.Administrator}></Option>
<Option value={UserRole.User}></Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserManagement;

View File

@@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react';
import {
Card, Row, Col, Typography, Button, Space, Descriptions,
Avatar, Spin, Tabs, Statistic, message, Tag, Divider,
Result
} from 'antd';
import {
UserOutlined, ArrowLeftOutlined, EditOutlined,
PictureOutlined, FileImageOutlined, HeartOutlined
} from '@ant-design/icons';
import { useParams, useNavigate } from 'react-router';
import { getUserById } from '../../../api';
import type { UserResponse } from '../../../api/types';
const { Title, Text } = Typography;
const { TabPane } = Tabs;
const UserDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [user, setUser] = useState<UserResponse | null>(null);
const [loading, setLoading] = useState(true);
// 加载用户数据
useEffect(() => {
const fetchUser = async () => {
if (!id) return;
try {
setLoading(true);
const response = await getUserById(parseInt(id));
if (response.success && response.data) {
setUser(response.data);
} else {
message.error(response.message || '获取用户信息失败');
}
} catch (error) {
console.error('Error fetching user:', error);
message.error('获取用户信息失败,请检查网络连接');
} finally {
setLoading(false);
}
};
fetchUser();
}, [id]);
// 返回上一页
const handleBack = () => {
navigate('/admin/users');
};
// 跳转到编辑页面
const handleEdit = () => {
navigate(`/admin/users/edit/${id}`);
};
// 模拟数据 - 实际项目中应该从API获取
const userStats = {
totalPhotos: 125,
totalAlbums: 14,
totalFavorites: 48,
diskUsage: '1.2 GB',
lastLogin: '2023-10-25 14:32',
accountAge: '268 天',
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
<Spin size="large" tip="加载用户信息..." />
</div>
);
}
if (!user) {
return (
<Card>
<Result
status="404"
title="用户不存在"
subTitle="找不到请求的用户信息"
extra={
<Button type="primary" onClick={handleBack}>
</Button>
}
/>
</Card>
);
}
return (
<div className="user-detail">
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>
</Button>
<Title level={2} style={{ margin: 0 }}></Title>
</Space>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={8}>
<Card>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Avatar size={100} icon={<UserOutlined />} />
<Title level={3} style={{ marginTop: 16, marginBottom: 0 }}>
{user.userName}
</Title>
<Text type="secondary">{user.email}</Text>
<div style={{ margin: '16px 0' }}>
<Tag color={user.role === 'Administrator' ? 'red' : 'blue'}>
{user.role || '访客'}
</Tag>
</div>
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>
</Button>
</div>
<Divider />
<Descriptions title="账户信息" column={1}>
<Descriptions.Item label="用户ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="注册时间">
{new Date(user.createdAt).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="最近登录">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '未登录'}
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col xs={24} lg={16}>
<Card>
<Tabs defaultActiveKey="1">
<TabPane
tab={<span><PictureOutlined /></span>}
key="1"
>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8}>
<Statistic
title="照片数量"
value={userStats.totalPhotos}
prefix={<FileImageOutlined />}
/>
</Col>
<Col xs={12} sm={8}>
<Statistic
title="相册数量"
value={userStats.totalAlbums}
prefix={<PictureOutlined />}
/>
</Col>
<Col xs={12} sm={8}>
<Statistic
title="收藏数量"
value={userStats.totalFavorites}
prefix={<HeartOutlined />}
/>
</Col>
<Col xs={12} sm={8}>
<Statistic
title="存储使用"
value={userStats.diskUsage}
/>
</Col>
<Col xs={12} sm={8}>
<Statistic
title="最近登录"
value={userStats.lastLogin}
/>
</Col>
<Col xs={12} sm={8}>
<Statistic
title="账户年龄"
value={userStats.accountAge}
/>
</Col>
</Row>
</TabPane>
<TabPane
tab={<span><FileImageOutlined /></span>}
key="2"
>
<div style={{ padding: '20px 0', textAlign: 'center' }}>
<Text type="secondary"></Text>
</div>
</TabPane>
<TabPane
tab={<span><PictureOutlined /></span>}
key="3"
>
<div style={{ padding: '20px 0', textAlign: 'center' }}>
<Text type="secondary"></Text>
</div>
</TabPane>
</Tabs>
</Card>
</Col>
</Row>
</div>
);
};
export default UserDetail;

View File

@@ -1,132 +0,0 @@
import React from 'react';
import { Form, Input, Button, Space, Row, Col, Tooltip } from 'antd';
import { SaveOutlined, QuestionCircleOutlined, LockOutlined } from '@ant-design/icons';
interface ConfigGroupProps {
groupName: string;
configs: {
[key: string]: string;
};
onSave: (group: string, key: string, value: string) => Promise<void>;
descriptions: {
[key: string]: string;
};
secretFields?: string[];
isMobile?: boolean;
}
const ConfigGroup: React.FC<ConfigGroupProps> = ({
groupName,
configs,
onSave,
descriptions,
secretFields = [],
isMobile = false
}) => {
const [form] = Form.useForm();
// 保存单个配置项
const handleSaveSingle = async (key: string) => {
try {
const value = form.getFieldValue(key);
await onSave(groupName, key, value);
} catch (error) {
console.error('保存配置失败:', error);
}
};
// 保存所有配置项
const handleSaveAll = async () => {
try {
const values = form.getFieldsValue();
for (const key in values) {
await onSave(groupName, key, values[key]);
}
} catch (error) {
console.error('保存所有配置失败:', error);
}
};
const isSecretField = (key: string): boolean => {
return secretFields.includes(key);
};
return (
<Form
form={form}
layout="vertical"
initialValues={configs}
size={isMobile ? "middle" : "large"}
>
{Object.keys(configs).map(key => {
const isSecret = isSecretField(key);
return (
<Row key={key} gutter={isMobile ? [8, 8] : [16, 16]} align="middle">
<Col xs={24} lg={16}>
<Form.Item
name={key}
label={
<Space>
{key}
{isSecret && <LockOutlined style={{ color: '#faad14' }} />}
{descriptions[key] && (
<Tooltip title={descriptions[key]}>
<QuestionCircleOutlined />
</Tooltip>
)}
</Space>
}
extra={isSecret &&
<div style={{ fontSize: '12px', color: '#faad14', marginTop: '4px' }}>
</div>
}
>
{isSecret ? (
<Input.Password
placeholder={configs[key] === '' ? '请输入新值' : '******(已设置,输入新值以更新)'}
/>
) : (
<Input placeholder={`请输入${key}`} />
)}
</Form.Item>
</Col>
<Col xs={24} lg={8} style={{
textAlign: isMobile ? 'left' : 'right',
marginTop: isMobile ? -10 : 0,
marginBottom: isMobile ? 10 : 0
}}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => handleSaveSingle(key)}
style={{
marginBottom: isMobile ? 16 : 24,
width: isMobile ? '100%' : 'auto'
}}
size={isMobile ? "middle" : "large"}
>
</Button>
</Col>
</Row>
);
})}
<Form.Item>
<Button
type="primary"
onClick={handleSaveAll}
style={{ marginTop: isMobile ? 8 : 16 }}
block
size={isMobile ? "middle" : "large"}
>
</Button>
</Form.Item>
</Form>
);
};
export default ConfigGroup;

View File

@@ -1,8 +1,7 @@
import { Tabs, Layout, Menu, Space } from 'antd';
import { useAuth } from '../../api/AuthContext';
import { useAuth } from '../../auth/AuthContext.tsx';
import { UserRole } from '../../api/types';
import { useState, type SetStateAction } from 'react';
import SystemConfig from './SystemConfig.tsx';
import UserProfile from './UserProfile.tsx';
import useIsMobile from '../../hooks/useIsMobile';
import {
@@ -29,13 +28,6 @@ function Settings() {
<UserProfile />
</div>
);
case 'system':
return (
<div className="settings-content">
<SystemConfig />
</div>
);
case 'appearance':
return (
<div className="settings-content">
@@ -74,11 +66,6 @@ function Settings() {
icon: <UserOutlined />,
label: '个人资料',
},
hasRole(UserRole.Administrator) ? {
key: 'system',
icon: <SettingOutlined />,
label: '系统配置',
} : null,
{
key: 'appearance',
icon: <BgColorsOutlined />,
@@ -108,8 +95,7 @@ function Settings() {
break;
}
};
// 手机版使用Tabs作为顶部导航
if (isMobile) {
return (
<div style={{ padding: 0 }}>
@@ -118,8 +104,8 @@ function Settings() {
onChange={(key) => handleMenuChange(key)}
centered
size="large"
tabBarStyle={{
marginBottom: 16,
tabBarStyle={{
marginBottom: 16,
fontWeight: 500,
backgroundColor: '#f5f5f5',
padding: '8px 0',
@@ -127,14 +113,14 @@ function Settings() {
}}
>
{menuItems.map((item) => (
<TabPane
<TabPane
tab={
<Space size={4}>
{item?.icon}
<span>{item?.label}</span>
</Space>
}
key={item?.key || ''}
}
key={item?.key || ''}
>
<div style={{ padding: '0 4px' }}>
{renderContent()}

View File

@@ -1,802 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Tabs, Card, message, Spin, Select, Button, Upload, Modal, Space, Tooltip, Input } from 'antd';
import { CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined, DownloadOutlined, UploadOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { getAllConfigs, setConfig, backupConfigs, restoreConfigs } from '../../api';
import ConfigGroup from './ConfigGroup.tsx';
import useIsMobile from '../../hooks/useIsMobile';
const { TabPane } = Tabs;
const { Option } = Select;
interface ConfigStructure {
[key: string]: {
[key: string]: string;
};
}
const SystemConfig: React.FC = () => {
const isMobile = useIsMobile();
const [loading, setLoading] = useState(true);
const [configs, setConfigs] = useState<ConfigStructure>({});
const [activeKey, setActiveKey] = useState('AI');
const [storageType, setStorageType] = useState('Telegram');
const [backupLoading, setBackupLoading] = useState(false);
const [restoreLoading, setRestoreLoading] = useState(false);
const [restoreModalVisible, setRestoreModalVisible] = useState(false);
const [restoreConfig, setRestoreConfig] = useState<Record<string, string> | null>(null);
const [secretFields, setSecretFields] = useState<Record<string, string[]>>({}); // 新增状态管理私密字段
// 获取所有配置项
const fetchConfigs = async () => {
setLoading(true);
try {
const response = await getAllConfigs();
if (response.success && response.data) {
const configGroups: ConfigStructure = {};
const secretFieldsMap: Record<string, string[]> = {}; // 记录每个组的私密字段
response.data.forEach(config => {
const [group, key] = config.key.split(':');
if (!configGroups[group]) {
configGroups[group] = {};
secretFieldsMap[group] = [];
}
configGroups[group][key] = config.value;
// 记录私密字段
if (config.isSecret) {
if (!secretFieldsMap[group]) {
secretFieldsMap[group] = [];
}
secretFieldsMap[group].push(key);
}
});
setConfigs(configGroups);
setSecretFields(secretFieldsMap);
// 设置初始存储类型
if (configGroups.Storage?.DefaultStorage) {
setStorageType(configGroups.Storage.DefaultStorage);
}
} else {
message.error('获取配置失败: ' + response.message);
}
} catch (error) {
message.error('获取配置出错');
console.error(error);
} finally {
setLoading(false);
}
};
// 保存配置项
const handleSaveConfig = async (group: string, key: string, value: string) => {
try {
const configKey = `${group}:${key}`;
const response = await setConfig({
key: configKey,
value: value,
description: `${group} ${key} setting`
});
if (response.success) {
message.success(`保存 ${key} 配置成功`);
// 更新本地状态
setConfigs(prev => ({
...prev,
[group]: {
...prev[group],
[key]: value
}
}));
} else {
message.error(`保存失败: ${response.message}`);
}
} catch (error) {
message.error('保存配置出错');
console.error(error);
}
};
// 备份配置
const handleBackupConfigs = async () => {
setBackupLoading(true);
try {
const response = await backupConfigs();
if (response.success && response.data) {
const configData = JSON.stringify(response.data, null, 2);
const blob = new Blob([configData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
a.download = `foxel-config-backup-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
message.success('配置备份已下载');
} else {
message.error('备份配置失败: ' + response.message);
}
} catch (error) {
message.error('备份配置出错');
console.error(error);
} finally {
setBackupLoading(false);
}
};
// 上传配置文件
const handleFileUpload = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const config = JSON.parse(content);
setRestoreConfig(config);
setRestoreModalVisible(true);
} catch (error) {
message.error('无效的配置文件格式');
}
};
reader.readAsText(file);
return false; // 阻止自动上传
};
// 确认恢复配置
const handleRestoreConfigs = async () => {
if (!restoreConfig) return;
setRestoreLoading(true);
try {
const response = await restoreConfigs(restoreConfig);
if (response.success) {
message.success('配置恢复成功将在3秒后刷新页面');
setRestoreModalVisible(false);
// 重新加载配置
setTimeout(() => {
fetchConfigs();
// 可选:刷新页面以确保所有配置生效
// window.location.reload();
}, 3000);
} else {
message.error('恢复配置失败: ' + response.message);
}
} catch (error) {
message.error('恢复配置出错');
console.error(error);
} finally {
setRestoreLoading(false);
}
};
// 存储类型选项
const storageOptions = [
{ value: 'Local', label: '本地存储', icon: <DatabaseOutlined style={{ color: '#52c41a' }} /> },
{ value: 'Telegram', label: 'Telegram 频道', icon: <CloudOutlined style={{ color: '#0088cc' }} /> },
{ value: 'S3', label: '亚马逊 S3', icon: <CloudServerOutlined style={{ color: '#ff9900' }} /> },
{ value: 'Cos', label: '腾讯云 COS', icon: <CloudServerOutlined style={{ color: '#00a4ff' }} /> },
{ value: 'WebDAV', label: 'WebDAV 存储', icon: <GlobalOutlined style={{ color: '#1890ff' }} /> },
];
useEffect(() => {
fetchConfigs();
}, []);
return (
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span></span>
<Space>
<Tooltip title="下载当前所有配置的备份">
<Button
icon={<DownloadOutlined />}
onClick={handleBackupConfigs}
loading={backupLoading}
size={isMobile ? "small" : "middle"}
>
{isMobile ? '' : '备份配置'}
</Button>
</Tooltip>
<Upload
beforeUpload={handleFileUpload}
showUploadList={false}
accept=".json"
>
<Tooltip title="从备份文件恢复配置">
<Button
icon={<UploadOutlined />}
size={isMobile ? "small" : "middle"}
>
{isMobile ? '' : '恢复配置'}
</Button>
</Tooltip>
</Upload>
</Space>
</div>
}
className="system-config-card"
bodyStyle={{
padding: isMobile ? '12px 8px' : '24px'
}}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin tip="加载配置中..." />
</div>
) : (
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
size={isMobile ? "small" : "middle"}
tabPosition={isMobile ? "top" : "left"}
style={{
minHeight: isMobile ? 'auto' : 400
}}
>
<TabPane tab="AI 设置" key="AI">
<Tabs defaultActiveKey="basic" type="card" size={isMobile ? "small" : "middle"}>
<TabPane tab="基础配置" key="basic">
<ConfigGroup
groupName="AI"
configs={{
ApiEndpoint: configs.AI?.ApiEndpoint || '',
ApiKey: configs.AI?.ApiKey || '',
Model: configs.AI?.Model || '',
EmbeddingModel: configs.AI?.EmbeddingModel || ''
}}
onSave={handleSaveConfig}
descriptions={{
ApiEndpoint: 'AI 服务的API端点地址',
ApiKey: 'AI 服务的API密钥',
Model: 'AI 模型名称',
EmbeddingModel: '嵌入向量模型名称'
}}
secretFields={secretFields.AI || []}
isMobile={isMobile}
/>
</TabPane>
<TabPane tab="提示词设置" key="prompts">
<Card
size="small"
title="图片分析提示词"
style={{ marginBottom: isMobile ? 16 : 24 }}
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
>
<Input.TextArea
rows={8}
value={configs.AI?.ImageAnalysisPrompt ||
"请详细分析这张图片,并提供全面的描述,以便用于向量嵌入和基于文本的图像搜索。描述需要包含:主体对象、场景环境、色彩特点、构图布局、风格特征、情绪氛围、细节特征等关键元素。请提供一个简短有力的标题,然后提供详细描述。\n\n请以JSON格式返回格式如下\n{\"title\": \"简短概括图片的核心内容\", \"description\": \"全面详细的描述,包含上述所有元素,使用丰富精确的词汇,避免笼统表达\"}\n\n请确保返回有效的JSON格式。"}
onChange={(e) => {
const newConfigs = { ...configs };
if (!newConfigs.AI) newConfigs.AI = {};
newConfigs.AI.ImageAnalysisPrompt = e.target.value;
setConfigs(newConfigs);
}}
/>
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Button
type="primary"
onClick={() => handleSaveConfig('AI', 'ImageAnalysisPrompt', configs.AI?.ImageAnalysisPrompt || '')}
>
</Button>
</div>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 8
}}>
JSON格式的指示(title)(description)
</div>
</Card>
<Card
size="small"
title="标签生成提示词"
style={{ marginBottom: isMobile ? 16 : 24 }}
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
>
<Input.TextArea
rows={8}
value={configs.AI?.TagGenerationPrompt ||
"请为图片生成5个最相关的标签每个标签应该是简短且描述性的词语或短语。\n\n请以JSON格式返回格式如下\n{\"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]}\n\n请确保返回有效的JSON格式。"}
onChange={(e) => {
const newConfigs = { ...configs };
if (!newConfigs.AI) newConfigs.AI = {};
newConfigs.AI.TagGenerationPrompt = e.target.value;
setConfigs(newConfigs);
}}
/>
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Button
type="primary"
onClick={() => handleSaveConfig('AI', 'TagGenerationPrompt', configs.AI?.TagGenerationPrompt || '')}
>
</Button>
</div>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 8
}}>
JSON格式的指示tags数组字段
</div>
</Card>
<Card
size="small"
title="标签匹配提示词"
style={{ marginBottom: isMobile ? 16 : 24 }}
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
>
<Input.TextArea
rows={8}
value={configs.AI?.TagMatchingPrompt ||
"以下是一组标签:[{tagsText}]。\n\n请从这些标签中严格选择与下面描述内容高度相关的标签最多选择5个。只选择确实匹配的标签如果找不到完全匹配或高度相关的标签宁可返回空数组也不要选择不太相关的标签。\n\n描述内容{description}\n\n请以JSON格式返回格式如下\n{\"tags\": [\"标签1\", \"标签2\", \"标签3\"]}\n\n请确保返回有效的JSON格式前面不要加```,并且只包含确实匹配的标签名称。"}
onChange={(e) => {
const newConfigs = { ...configs };
if (!newConfigs.AI) newConfigs.AI = {};
newConfigs.AI.TagMatchingPrompt = e.target.value;
setConfigs(newConfigs);
}}
/>
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Button
type="primary"
onClick={() => handleSaveConfig('AI', 'TagMatchingPrompt', configs.AI?.TagMatchingPrompt || '')}
>
</Button>
</div>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 8
}}>
{'{'+'tagsText'+'}'}{'{'+'description'+'}'}
</div>
</Card>
</TabPane>
</Tabs>
</TabPane>
<TabPane tab="授权配置" key="Authorization">
<Tabs defaultActiveKey="jwt" type="card" size={isMobile ? "small" : "middle"}>
<TabPane tab="JWT 设置" key="jwt">
<ConfigGroup
groupName="Jwt"
configs={{
SecretKey: configs.Jwt?.SecretKey || '',
Issuer: configs.Jwt?.Issuer || '',
Audience: configs.Jwt?.Audience || '',
}}
onSave={handleSaveConfig}
descriptions={{
SecretKey: 'JWT 加密密钥',
Issuer: 'JWT 签发者',
Audience: 'JWT 接收者',
}}
secretFields={secretFields.Jwt || []}
isMobile={isMobile}
/>
</TabPane>
<TabPane tab="GitHub认证" key="github">
<ConfigGroup
groupName="Authentication"
configs={{
"GitHubClientId": configs.Authentication?.["GitHubClientId"] || '',
"GitHubClientSecret": configs.Authentication?.["GitHubClientSecret"] || '',
"GitHubCallbackUrl": configs.Authentication?.["GitHubCallbackUrl"] || ''
}}
onSave={(_group, key, value) => handleSaveConfig('Authentication', key, value)}
descriptions={{
"GitHubClientId": 'GitHub OAuth 应用客户端ID',
"GitHubClientSecret": 'GitHub OAuth 应用客户端密钥',
"GitHubCallbackUrl": 'GitHub OAuth 认证回调地址'
}}
secretFields={secretFields.Authentication || []}
isMobile={isMobile}
/>
</TabPane>
</Tabs>
</TabPane>
<TabPane tab="应用设置" key="AppSettings">
<ConfigGroup
groupName="AppSettings"
configs={{
ServerUrl: configs.AppSettings?.ServerUrl || ''
}}
onSave={handleSaveConfig}
descriptions={{
ServerUrl: '服务器URL'
}}
/>
</TabPane>
<TabPane tab="存储设置" key="Storage">
{/* 存储类型配置卡片 */}
<Card
size="small"
title="存储类型配置"
style={{ marginBottom: isMobile ? 16 : 24 }}
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
gap: isMobile ? 12 : 16,
marginBottom: 0
}}>
{/* 登录用户默认存储 */}
<div>
<div style={{
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: '#666'
}}>
</div>
<Select
value={configs.Storage?.DefaultStorage || 'Local'}
onChange={(value) => {
handleSaveConfig('Storage', 'DefaultStorage', value);
}}
style={{ width: '100%' }}
size="large"
placeholder="选择登录用户的默认存储方式"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 4
}}>
</div>
</div>
{/* 匿名用户默认存储 */}
<div>
<div style={{
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: '#666'
}}>
</div>
<Select
value={configs.Storage?.AnonymousDefaultStorage || 'Local'}
onChange={(value) => {
handleSaveConfig('Storage', 'AnonymousDefaultStorage', value);
}}
style={{ width: '100%' }}
size="large"
placeholder="选择匿名用户的默认存储方式"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 4
}}>
</div>
</div>
</div>
</Card>
{/* 上传设置卡片 - 新增 */}
<Card
size="small"
title="上传设置配置"
style={{ marginBottom: isMobile ? 16 : 24 }}
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
>
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fit, minmax(300px, 1fr))',
gap: isMobile ? 12 : 16,
marginBottom: 0
}}>
{/* 图片默认格式 */}
<div>
<div style={{
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: '#666'
}}>
</div>
<Select
value={configs.Upload?.DefaultImageFormat || 'Original'}
onChange={(value) => {
handleSaveConfig('Upload', 'DefaultImageFormat', value);
}}
style={{ width: '100%' }}
size="large"
placeholder="选择上传图片的默认处理格式"
>
<Option value="Original"></Option>
<Option value="Jpeg">JPEG</Option>
<Option value="Png">PNG</Option>
<Option value="Webp">WebP</Option>
</Select>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 4
}}>
</div>
</div>
{/* 图片压缩质量 */}
<div>
<div style={{
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: '#666'
}}>
</div>
<Select
value={configs.Upload?.DefaultImageQuality || '95'}
onChange={(value) => {
handleSaveConfig('Upload', 'DefaultImageQuality', value);
}}
style={{ width: '100%' }}
size="large"
placeholder="选择图片压缩质量"
>
<Option value="100">100% - </Option>
<Option value="95">95% - </Option>
<Option value="90">90% - </Option>
<Option value="85">85% - </Option>
<Option value="80">80% - </Option>
<Option value="75">75% - </Option>
<Option value="70">70% - </Option>
</Select>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 4
}}>
JPEG和WebP格式的图片质量设置
</div>
</div>
</div>
</Card>
{/* 存储服务配置卡片 */}
<Card
size="small"
title="存储服务配置"
style={{ marginBottom: isMobile ? 16 : 24 }}
bodyStyle={{ padding: isMobile ? '12px' : '16px' }}
>
<div style={{ marginBottom: 16 }}>
<div style={{
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: '#666'
}}>
</div>
<Select
value={storageType}
onChange={(value) => {
setStorageType(value);
}}
style={{ width: isMobile ? '100%' : '300px' }}
size="large"
placeholder="选择需要配置的存储服务类型"
>
{storageOptions.map(option => (
<Option key={option.value} value={option.value}>
<div style={{ display: 'flex', alignItems: 'center' }}>
{option.icon}
<span style={{ marginLeft: 8 }}>{option.label}</span>
</div>
</Option>
))}
</Select>
<div style={{
fontSize: 12,
color: '#999',
marginTop: 4
}}>
</div>
</div>
{/* 存储服务具体配置 */}
<div style={{
border: '1px solid #f0f0f0',
borderRadius: 6,
padding: isMobile ? 12 : 16,
backgroundColor: '#fafafa'
}}>
{storageType === 'Local' && (
<div style={{ textAlign: 'center', color: '#999', padding: '20px 0' }}>
</div>
)}
{storageType === 'Telegram' && (
<ConfigGroup
groupName="Storage"
configs={{
"TelegramStorageBotToken": configs.Storage?.TelegramStorageBotToken || '',
"TelegramStorageChatId": configs.Storage?.TelegramStorageChatId || '',
"TelegramProxyAddress": configs.Storage?.TelegramProxyAddress || '',
"TelegramProxyPort": configs.Storage?.TelegramProxyPort || '',
"TelegramProxyUsername": configs.Storage?.TelegramProxyUsername || '',
"TelegramProxyPassword": configs.Storage?.TelegramProxyPassword || ''
}}
onSave={handleSaveConfig}
descriptions={{
"TelegramStorageBotToken": 'Telegram 机器人令牌',
"TelegramStorageChatId": 'Telegram 聊天ID',
"TelegramProxyAddress": '代理服务器地址 (例如: 127.0.0.1)',
"TelegramProxyPort": '代理服务器端口 (例如: 1080)',
"TelegramProxyUsername": '代理用户名 (可选)',
"TelegramProxyPassword": '代理密码 (可选)'
}}
secretFields={secretFields.Storage || []}
isMobile={isMobile}
/>
)}
{storageType === 'S3' && (
<ConfigGroup
groupName="Storage"
configs={{
"S3StorageAccessKey": configs.Storage?.S3StorageAccessKey || '',
"S3StorageSecretKey": configs.Storage?.S3StorageSecretKey || '',
"S3StorageBucketName": configs.Storage?.S3StorageBucketName || '',
"S3StorageRegion": configs.Storage?.S3StorageRegion || '',
"S3StorageEndpoint": configs.Storage?.S3StorageEndpoint || '',
"S3StorageCdnUrl": configs.Storage?.S3StorageCdnUrl || '',
"S3StorageUsePathStyleUrls": configs.Storage?.S3StorageUsePathStyleUrls || 'false'
}}
onSave={handleSaveConfig}
descriptions={{
"S3StorageAccessKey": 'S3访问密钥',
"S3StorageSecretKey": 'S3私有密钥',
"S3StorageBucketName": 'S3存储桶名称',
"S3StorageRegion": 'S3区域 (例如:us-east-1)',
"S3StorageEndpoint": 'S3端点URL (可选,默认为AWS S3)',
"S3StorageCdnUrl": 'CDN URL (可选,用于加速文件访问)',
"S3StorageUsePathStyleUrls": '使用路径形式URLs (true/false,兼容非AWS服务)'
}}
secretFields={secretFields.Storage || []}
isMobile={isMobile}
/>
)}
{storageType === 'Cos' && (
<ConfigGroup
groupName="Storage"
configs={{
"CosStorageSecretId": configs.Storage?.CosStorageSecretId || '',
"CosStorageSecretKey": configs.Storage?.CosStorageSecretKey || '',
"CosStorageToken": configs.Storage?.CosStorageToken || '',
"CosStorageBucketName": configs.Storage?.CosStorageBucketName || '',
"CosStorageRegion": configs.Storage?.CosStorageRegion || '',
"CosStorageCdnUrl": configs.Storage?.CosStorageCdnUrl || '',
}}
onSave={handleSaveConfig}
descriptions={{
"CosStorageSecretId": '腾讯云COS密钥ID',
"CosStorageSecretKey": '腾讯云COS私有密钥',
"CosStorageToken": '腾讯云COS临时令牌(可选)',
"CosStorageBucketName": 'COS存储桶名称',
"CosStorageRegion": 'COS区域 (例如:ap-shanghai)',
"CosStorageCdnUrl": 'CDN URL (可选,用于加速文件访问)',
}}
secretFields={secretFields.Storage || []}
isMobile={isMobile}
/>
)}
{storageType === 'WebDAV' && (
<ConfigGroup
groupName="Storage"
configs={{
"WebDAVServerUrl": configs.Storage?.WebDAVServerUrl || '',
"WebDAVUserName": configs.Storage?.WebDAVUserName || '',
"WebDAVPassword": configs.Storage?.WebDAVPassword || '',
"WebDAVBasePath": configs.Storage?.WebDAVBasePath || '',
"WebDAVPublicUrl": configs.Storage?.WebDAVPublicUrl || '',
}}
onSave={handleSaveConfig}
descriptions={{
"WebDAVServerUrl": 'WebDAV 服务器 URL (例如: https://dav.example.com)',
"WebDAVUserName": 'WebDAV 用户名',
"WebDAVPassword": 'WebDAV 密码',
"WebDAVBasePath": 'WebDAV 基础路径 (例如: files/upload)',
"WebDAVPublicUrl": 'WebDAV 公共访问 URL (可选,用于文件访问)',
}}
secretFields={secretFields.Storage || []}
isMobile={isMobile}
/>
)}
</div>
</Card>
</TabPane>
</Tabs>
)}
{/* 恢复配置确认对话框 */}
<Modal
title={
<div>
<span></span>
<Tooltip title="恢复配置将覆盖当前所有配置设置,请确认备份文件正确无误">
<QuestionCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</div>
}
open={restoreModalVisible}
onCancel={() => setRestoreModalVisible(false)}
footer={[
<>
<Button key="cancel" onClick={() => setRestoreModalVisible(false)}>
</Button>
<Button
key="submit"
type="primary"
loading={restoreLoading}
onClick={handleRestoreConfigs}
>
</Button>
</>
]}
>
<p></p>
<p style={{ color: '#ff4d4f' }}></p>
{restoreConfig && (
<div>
<p> {Object.keys(restoreConfig).length} </p>
</div>
)}
</Modal>
</Card>
);
};
export default SystemConfig;

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Card, Form, Input, Button, message } from 'antd';
import { useAuth } from '../../api/AuthContext';
import { useAuth } from '../../auth/AuthContext';
import UserAvatar from '../../components/UserAvatar';
import useIsMobile from '../../hooks/useIsMobile';
import { updateUserInfo } from '../../api';

View File

@@ -1,12 +1,14 @@
import React from 'react';
import {
PictureOutlined,
FolderOutlined,
HeartOutlined,
CloudUploadOutlined,
SettingOutlined,
CompassOutlined
CompassOutlined,
DashboardOutlined,
UserOutlined
} from '@ant-design/icons';
import React from 'react';
import AllImages from '../pages/allImages/Index';
import Albums from '../pages/albums/Index';
@@ -15,34 +17,37 @@ import Favorites from '../pages/favorites/Index';
import Settings from '../pages/settings/Index';
import BackgroundTasks from '../pages/backgroundTasks/Index';
import PixHub from '../pages/pixHub/Index';
import AdminDashboard from '../pages/admin/dashboard/Index';
import System from '../pages/admin/system/Index';
import UserManagement from '../pages/admin/users/Index';
import PictureManagement from '../pages/admin/pictures/Index';
// 路由配置类型定义
export interface RouteConfig {
path: string;
element: React.ReactNode;
// 以下属性用于菜单配置
key: string;
icon?: React.ReactNode;
label: string;
area: 'main' | 'admin';
hideInMenu?: boolean;
children?: RouteConfig[];
groupLabel?: string; // 分组标题
divider?: boolean; // 是否显示分隔线
// 面包屑相关配置
groupLabel?: string;
divider?: boolean;
breadcrumb?: {
title: string;
parent?: string; // 父级路由的key
parent?: string;
};
}
// 统一的路由和菜单配置
// 统一的路由配置
const routes: RouteConfig[] = [
// 主应用路由
{
path: '/',
key: 'all-images',
icon: <PictureOutlined />,
label: '所有图片',
element: <AllImages />,
area: 'main',
breadcrumb: {
title: '所有图片'
}
@@ -53,6 +58,7 @@ const routes: RouteConfig[] = [
icon: <FolderOutlined />,
label: '相册',
element: <Albums />,
area: 'main',
breadcrumb: {
title: '相册'
}
@@ -62,6 +68,7 @@ const routes: RouteConfig[] = [
key: 'album-detail',
label: '相册详情',
element: <AlbumDetail />,
area: 'main',
hideInMenu: true,
breadcrumb: {
title: '相册详情',
@@ -74,6 +81,7 @@ const routes: RouteConfig[] = [
icon: <HeartOutlined />,
label: '收藏',
element: <Favorites />,
area: 'main',
breadcrumb: {
title: '收藏'
}
@@ -84,6 +92,7 @@ const routes: RouteConfig[] = [
icon: <CompassOutlined />,
label: '图片广场',
element: <PixHub />,
area: 'main',
groupLabel: '社区发现',
breadcrumb: {
title: '图片广场'
@@ -95,6 +104,7 @@ const routes: RouteConfig[] = [
icon: <CloudUploadOutlined />,
label: '任务中心',
element: <BackgroundTasks />,
area: 'main',
groupLabel: '系统功能',
breadcrumb: {
title: '任务中心'
@@ -106,10 +116,73 @@ const routes: RouteConfig[] = [
icon: <SettingOutlined />,
label: '设置',
element: <Settings />,
area: 'main',
breadcrumb: {
title: '设置'
}
},
// 管理后台路由
{
path: '',
key: 'admin-dashboard',
icon: <DashboardOutlined />,
label: '控制面板',
element: <AdminDashboard />,
area: 'admin',
breadcrumb: {
title: '控制面板'
}
},
{
path: 'users',
key: 'admin-user',
icon: <UserOutlined />,
label: '用户管理',
element: <UserManagement />,
area: 'admin',
breadcrumb: {
title: '用户管理'
}
},
{
path: 'pictures',
key: 'admin-picture',
icon: <PictureOutlined />,
label: '图片管理',
element: <PictureManagement />,
area: 'admin',
breadcrumb: {
title: '图片管理'
}
},
{
path: 'system',
key: 'admin-system',
icon: <SettingOutlined />,
label: '系统设置',
element: <System />,
area: 'admin',
breadcrumb: {
title: '系统设置'
}
},
];
let mainRoutesCache: RouteConfig[] | null = null;
export const getMainRoutes = () => {
if (!mainRoutesCache) {
mainRoutesCache = routes.filter(route => route.area === 'main');
}
return mainRoutesCache;
};
let adminRoutesCache: RouteConfig[] | null = null;
export const getAdminRoutes = () => {
if (!adminRoutesCache) {
adminRoutesCache = routes.filter(route => route.area === 'admin');
}
return adminRoutesCache;
};
export default routes;