mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-26 01:31:42 +08:00
feat: Support multiple vector database selection, add InMemory and Qdrant adapters, introduce admin dashboard
This commit is contained in:
98
Api/Management/PictureManagementController.cs
Normal file
98
Api/Management/PictureManagementController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Api/Management/SystemManageMentController.cs
Normal file
90
Api/Management/SystemManageMentController.cs
Normal 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;
|
||||
}
|
||||
124
Api/Management/UserManagementController.cs
Normal file
124
Api/Management/UserManagementController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,6 @@ public static class ApplicationBuilderExtensions
|
||||
public static void UseApplicationOpenApi(this WebApplication app)
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
36
Models/Request/User/UserRequests.cs
Normal file
36
Models/Request/User/UserRequests.cs
Normal 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; }
|
||||
}
|
||||
10
Models/Response/User/UserResponse.cs
Normal file
10
Models/Response/User/UserResponse.cs
Normal 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; }
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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("请注意,第一个注册的用户将自动成为管理员");
|
||||
}
|
||||
}
|
||||
13
Services/Management/IPictureManagementService.cs
Normal file
13
Services/Management/IPictureManagementService.cs
Normal 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);
|
||||
}
|
||||
21
Services/Management/IUserManagementService.cs
Normal file
21
Services/Management/IUserManagementService.cs
Normal 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();
|
||||
}
|
||||
218
Services/Management/PictureManagementService.cs
Normal file
218
Services/Management/PictureManagementService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
226
Services/Management/UserManagementService.cs
Normal file
226
Services/Management/UserManagementService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
18
Services/VectorDb/IVectorDbService.cs
Normal file
18
Services/VectorDb/IVectorDbService.cs
Normal 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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
112
Services/VectorDb/QdrantVectorDbService.cs
Normal file
112
Services/VectorDb/QdrantVectorDbService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Services/VectorDb/VectorDbInitializer.cs
Normal file
30
Services/VectorDb/VectorDbInitializer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
95
Services/VectorDb/VectorDbManager.cs
Normal file
95
Services/VectorDb/VectorDbManager.cs
Normal 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();
|
||||
}
|
||||
379
Web/bun.lock
379
Web/bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
58
Web/src/api/pictureManagementApi.ts
Normal file
58
Web/src/api/pictureManagementApi.ts
Normal 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>;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
76
Web/src/api/userManagementApi.ts
Normal file
76
Web/src/api/userManagementApi.ts
Normal 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)
|
||||
}
|
||||
);
|
||||
};
|
||||
61
Web/src/api/vectorDbApi.ts
Normal file
61
Web/src/api/vectorDbApi.ts
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
208
Web/src/layouts/AdminLayout.tsx
Normal file
208
Web/src/layouts/AdminLayout.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
360
Web/src/pages/admin/dashboard/Index.tsx
Normal file
360
Web/src/pages/admin/dashboard/Index.tsx
Normal 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;
|
||||
307
Web/src/pages/admin/pictures/Index.tsx
Normal file
307
Web/src/pages/admin/pictures/Index.tsx
Normal 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;
|
||||
76
Web/src/pages/admin/system/ConfigFormItem.tsx
Normal file
76
Web/src/pages/admin/system/ConfigFormItem.tsx
Normal 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;
|
||||
40
Web/src/pages/admin/system/ConfigSection.tsx
Normal file
40
Web/src/pages/admin/system/ConfigSection.tsx
Normal 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;
|
||||
563
Web/src/pages/admin/system/ConfigTabs.tsx
Normal file
563
Web/src/pages/admin/system/ConfigTabs.tsx
Normal 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;
|
||||
615
Web/src/pages/admin/system/Index.tsx
Normal file
615
Web/src/pages/admin/system/Index.tsx
Normal 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;
|
||||
396
Web/src/pages/admin/system/VectorDbConfig.tsx
Normal file
396
Web/src/pages/admin/system/VectorDbConfig.tsx
Normal 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;
|
||||
412
Web/src/pages/admin/users/Index.tsx
Normal file
412
Web/src/pages/admin/users/Index.tsx
Normal 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;
|
||||
215
Web/src/pages/admin/users/UserDetail.tsx
Normal file
215
Web/src/pages/admin/users/UserDetail.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user