mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-07 08:36:49 +08:00
feat: Implement face clustering management service and API
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
using Foxel.Controllers;
|
||||||
|
using Foxel.Models;
|
||||||
|
using Foxel.Models.Response.Face;
|
||||||
|
using Foxel.Models.Response.Picture;
|
||||||
|
using Foxel.Services.Management;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Foxel.Api;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/face")]
|
||||||
|
public class FaceController(
|
||||||
|
IFaceManagementService faceManagementService,
|
||||||
|
IFaceClusteringService faceClusteringService,
|
||||||
|
ILogger<FaceController> logger) : BaseApiController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前用户的人脸聚类列表
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("clusters")]
|
||||||
|
public async Task<ActionResult<BaseResult<PaginatedResult<FaceClusterResponse>>>> GetMyFaceClusters(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.GetUserFaceClustersAsync(GetCurrentUserId(), page, pageSize);
|
||||||
|
return Success(result, "获取人脸聚类列表成功");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取用户人脸聚类列表失败: UserId={UserId}", GetCurrentUserId());
|
||||||
|
return Error<PaginatedResult<FaceClusterResponse>>("获取人脸聚类列表失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据聚类获取当前用户的图片
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("clusters/{clusterId}/pictures")]
|
||||||
|
public async Task<ActionResult<BaseResult<PaginatedResult<PictureResponse>>>> GetMyPicturesByCluster(
|
||||||
|
int clusterId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.GetUserPicturesByClusterAsync(
|
||||||
|
GetCurrentUserId(), clusterId, page, pageSize);
|
||||||
|
return Success(result, "获取聚类图片成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return Error<PaginatedResult<PictureResponse>>("找不到指定的人脸聚类或无权访问", 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取用户聚类图片失败: UserId={UserId}, ClusterId={ClusterId}",
|
||||||
|
GetCurrentUserId(), clusterId);
|
||||||
|
return Error<PaginatedResult<PictureResponse>>("获取聚类图片失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新当前用户的人脸聚类信息
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("clusters/{clusterId}")]
|
||||||
|
public async Task<ActionResult<BaseResult<FaceClusterResponse>>> UpdateMyCluster(
|
||||||
|
int clusterId, [FromBody] UpdateClusterRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.UpdateUserClusterAsync(
|
||||||
|
GetCurrentUserId(), clusterId, request.PersonName, request.Description);
|
||||||
|
return Success(result, "更新聚类信息成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return Error<FaceClusterResponse>("找不到指定的人脸聚类或无权访问", 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "更新用户聚类信息失败: UserId={UserId}, ClusterId={ClusterId}",
|
||||||
|
GetCurrentUserId(), clusterId);
|
||||||
|
return Error<FaceClusterResponse>("更新聚类信息失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始当前用户的人脸聚类
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("clusters/analyze")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> StartMyFaceClustering()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await faceClusteringService.ClusterUserFacesAsync(GetCurrentUserId());
|
||||||
|
return Success(true, "人脸聚类任务已开始");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "启动用户人脸聚类失败: UserId={UserId}", GetCurrentUserId());
|
||||||
|
return Error<bool>("启动人脸聚类失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 合并当前用户的聚类
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("clusters/{targetClusterId}/merge")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> MergeMyUserClusters(
|
||||||
|
int targetClusterId, [FromBody] MergeClustersRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.MergeUserClustersAsync(
|
||||||
|
GetCurrentUserId(), request.SourceClusterId, targetClusterId);
|
||||||
|
return Success(result, "合并聚类成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Error<bool>(ex.Message, 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "合并用户聚类失败: UserId={UserId}", GetCurrentUserId());
|
||||||
|
return Error<bool>("合并聚类失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从聚类中移除人脸
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("faces/{faceId}/cluster")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> RemoveFaceFromCluster(int faceId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.RemoveUserFaceFromClusterAsync(GetCurrentUserId(), faceId);
|
||||||
|
return Success(result, "移除人脸成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return Error<bool>("找不到指定的人脸或无权访问", 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "移除用户人脸失败: UserId={UserId}, FaceId={FaceId}",
|
||||||
|
GetCurrentUserId(), faceId);
|
||||||
|
return Error<bool>("移除人脸失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetCurrentUserId()
|
||||||
|
{
|
||||||
|
// 从JWT或Claims中获取当前用户ID
|
||||||
|
var userIdClaim = User.FindFirst("id") ?? User.FindFirst("sub");
|
||||||
|
return int.Parse(userIdClaim?.Value ?? "0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateClusterRequest
|
||||||
|
{
|
||||||
|
public string? PersonName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MergeClustersRequest
|
||||||
|
{
|
||||||
|
public int SourceClusterId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using Foxel.Controllers;
|
||||||
|
using Foxel.Models;
|
||||||
|
using Foxel.Models.Response.Face;
|
||||||
|
using Foxel.Models.Response.Picture;
|
||||||
|
using Foxel.Services.Management;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Foxel.Api.Management;
|
||||||
|
|
||||||
|
[Authorize(Roles = "Administrator")]
|
||||||
|
[Route("api/management/face")]
|
||||||
|
public class FaceManagementController(
|
||||||
|
IFaceManagementService faceManagementService,
|
||||||
|
IFaceClusteringService faceClusteringService,
|
||||||
|
ILogger<FaceManagementController> logger) : BaseApiController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有用户的人脸聚类列表
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("clusters")]
|
||||||
|
public async Task<ActionResult<BaseResult<PaginatedResult<FaceClusterResponse>>>> GetAllFaceClusters(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] int? userId = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = userId.HasValue
|
||||||
|
? await faceManagementService.GetUserFaceClustersAsync(userId.Value, page, pageSize)
|
||||||
|
: await faceManagementService.GetFaceClustersAsync(page, pageSize);
|
||||||
|
return Success(result, "获取人脸聚类列表成功");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "管理员获取人脸聚类列表失败");
|
||||||
|
return Error<PaginatedResult<FaceClusterResponse>>("获取人脸聚类列表失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据聚类获取图片(管理员可查看所有)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("clusters/{clusterId}/pictures")]
|
||||||
|
public async Task<ActionResult<BaseResult<PaginatedResult<PictureResponse>>>> GetPicturesByCluster(
|
||||||
|
int clusterId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.GetPicturesByClusterAsync(clusterId, page, pageSize);
|
||||||
|
return Success(result, "获取聚类图片成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return Error<PaginatedResult<PictureResponse>>("找不到指定的人脸聚类", 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "管理员获取聚类图片失败: ClusterId={ClusterId}", clusterId);
|
||||||
|
return Error<PaginatedResult<PictureResponse>>("获取聚类图片失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新人脸聚类信息(管理员)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("clusters/{clusterId}")]
|
||||||
|
public async Task<ActionResult<BaseResult<FaceClusterResponse>>> UpdateCluster(
|
||||||
|
int clusterId, [FromBody] UpdateClusterRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.UpdateClusterAsync(
|
||||||
|
clusterId, request.PersonName, request.Description);
|
||||||
|
return Success(result, "更新聚类信息成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return Error<FaceClusterResponse>("找不到指定的人脸聚类", 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "管理员更新聚类信息失败: ClusterId={ClusterId}", clusterId);
|
||||||
|
return Error<FaceClusterResponse>("更新聚类信息失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开始全局人脸聚类(管理员)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("clusters/analyze")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> StartGlobalFaceClustering([FromQuery] int? userId = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (userId.HasValue)
|
||||||
|
{
|
||||||
|
await faceClusteringService.ClusterUserFacesAsync(userId.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await faceClusteringService.ClusterFacesAsync();
|
||||||
|
}
|
||||||
|
return Success(true, "人脸聚类任务已开始");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "管理员启动人脸聚类失败");
|
||||||
|
return Error<bool>("启动人脸聚类失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 合并聚类(管理员)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("clusters/{targetClusterId}/merge")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> MergeClusters(
|
||||||
|
int targetClusterId, [FromBody] MergeClustersRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.MergeClustersAsync(
|
||||||
|
request.SourceClusterId, targetClusterId);
|
||||||
|
return Success(result, "合并聚类成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return Error<bool>(ex.Message, 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "管理员合并聚类失败");
|
||||||
|
return Error<bool>("合并聚类失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除聚类(管理员)
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("clusters/{clusterId}")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> DeleteCluster(int clusterId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.DeleteClusterAsync(clusterId);
|
||||||
|
return Success(result, "删除聚类成功");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return Error<bool>("找不到指定的人脸聚类", 404);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "管理员删除聚类失败: ClusterId={ClusterId}", clusterId);
|
||||||
|
return Error<bool>("删除聚类失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从聚类中移除人脸(管理员)
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("faces/{faceId}/cluster")]
|
||||||
|
public async Task<ActionResult<BaseResult<bool>>> RemoveFaceFromCluster(int faceId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.RemoveFaceFromClusterAsync(faceId);
|
||||||
|
return Success(result, "移除人脸成功");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "管理员移除人脸失败: FaceId={FaceId}", faceId);
|
||||||
|
return Error<bool>("移除人脸失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取人脸聚类统计信息
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("statistics")]
|
||||||
|
public async Task<ActionResult<BaseResult<FaceClusterStatistics>>> GetClusterStatistics()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await faceManagementService.GetClusterStatisticsAsync();
|
||||||
|
return Success(result, "获取统计信息成功");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取聚类统计信息失败");
|
||||||
|
return Error<FaceClusterStatistics>("获取统计信息失败", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateClusterRequest
|
||||||
|
{
|
||||||
|
public string? PersonName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MergeClustersRequest
|
||||||
|
{
|
||||||
|
public int SourceClusterId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FaceClusterStatistics
|
||||||
|
{
|
||||||
|
public int TotalClusters { get; set; }
|
||||||
|
public int TotalFaces { get; set; }
|
||||||
|
public int UnclusteredFaces { get; set; }
|
||||||
|
public int NamedClusters { get; set; }
|
||||||
|
public Dictionary<int, int> ClustersByUser { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ using Foxel.Services.Initializer;
|
|||||||
using Foxel.Services.Management;
|
using Foxel.Services.Management;
|
||||||
using Foxel.Services.Media;
|
using Foxel.Services.Media;
|
||||||
using Foxel.Services.Storage;
|
using Foxel.Services.Storage;
|
||||||
using Foxel.Services.Storage.Providers;
|
|
||||||
using Foxel.Services.VectorDB;
|
using Foxel.Services.VectorDB;
|
||||||
using Foxel.Services.Background.Processors;
|
using Foxel.Services.Background.Processors;
|
||||||
using Foxel.Services.Mapping;
|
using Foxel.Services.Mapping;
|
||||||
@@ -42,6 +41,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<VisualRecognitionTaskProcessor>();
|
services.AddSingleton<VisualRecognitionTaskProcessor>();
|
||||||
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
|
||||||
services.AddSingleton<IMappingService, MappingService>();
|
services.AddSingleton<IMappingService, MappingService>();
|
||||||
|
services.AddSingleton<IFaceManagementService, FaceManagementService>();
|
||||||
|
services.AddSingleton<IFaceClusteringService, FaceClusteringService>();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddApplicationDbContext(this IServiceCollection services, IConfiguration configuration)
|
public static void AddApplicationDbContext(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ public class Face : BaseModel
|
|||||||
|
|
||||||
public int PictureId { get; set; }
|
public int PictureId { get; set; }
|
||||||
|
|
||||||
[StringLength(255)]
|
|
||||||
public string? PersonName { get; set; }
|
|
||||||
[ForeignKey("PictureId")]
|
[ForeignKey("PictureId")]
|
||||||
public Picture Picture { get; set; } = null!;
|
public Picture Picture { get; set; } = null!;
|
||||||
|
public int? ClusterId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey("ClusterId")]
|
||||||
|
public FaceCluster? Cluster { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Foxel.Models.DataBase;
|
||||||
|
|
||||||
|
public class FaceCluster : BaseModel
|
||||||
|
{
|
||||||
|
[StringLength(255)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(1024)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户设置的人物名称
|
||||||
|
/// </summary>
|
||||||
|
[StringLength(255)]
|
||||||
|
public string? PersonName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最后更新时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<Face>? Faces { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Foxel.Models.Response.Face;
|
||||||
|
|
||||||
|
public record FaceClusterResponse
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? PersonName { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public int FaceCount { get; set; }
|
||||||
|
public string? ThumbnailPath { get; set; }
|
||||||
|
public DateTime LastUpdatedAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Foxel.Models.DataBase;
|
using Foxel.Models.DataBase;
|
||||||
|
using Foxel.Models.Response.Face;
|
||||||
|
|
||||||
namespace Foxel.Models.Response.Picture;
|
namespace Foxel.Models.Response.Picture;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(opti
|
|||||||
public DbSet<BackgroundTask> BackgroundTasks { get; set; } = null!;
|
public DbSet<BackgroundTask> BackgroundTasks { get; set; } = null!;
|
||||||
public DbSet<StorageMode> StorageModes { get; set; } = null!;
|
public DbSet<StorageMode> StorageModes { get; set; } = null!;
|
||||||
public DbSet<Face> Faces { get; set; } = null!;
|
public DbSet<Face> Faces { get; set; } = null!;
|
||||||
|
public DbSet<FaceCluster> FaceClusters { get; set; }
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using Foxel.Models.DataBase;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Foxel.Services.AI;
|
||||||
|
|
||||||
|
public class FaceClusteringService(
|
||||||
|
IDbContextFactory<MyDbContext> contextFactory,
|
||||||
|
ILogger<FaceClusteringService> logger) : IFaceClusteringService
|
||||||
|
{
|
||||||
|
private const double SIMILARITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
public async Task<List<FaceCluster>> ClusterFacesAsync()
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 获取所有有嵌入向量但未分类的人脸
|
||||||
|
var unclusteredFaces = await dbContext.Faces
|
||||||
|
.Where(f => f.Embedding != null && f.ClusterId == null)
|
||||||
|
.Include(f => f.Picture)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var clusters = new List<FaceCluster>();
|
||||||
|
|
||||||
|
foreach (var face in unclusteredFaces)
|
||||||
|
{
|
||||||
|
var assignedCluster = await FindBestClusterAsync(face, clusters, dbContext);
|
||||||
|
|
||||||
|
if (assignedCluster != null)
|
||||||
|
{
|
||||||
|
// 分配到现有聚类
|
||||||
|
face.ClusterId = assignedCluster.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 创建新聚类
|
||||||
|
var newCluster = new FaceCluster
|
||||||
|
{
|
||||||
|
Name = $"未知人物 {clusters.Count + 1}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.FaceClusters.Add(newCluster);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
face.ClusterId = newCluster.Id;
|
||||||
|
clusters.Add(newCluster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
logger.LogInformation("人脸聚类完成,共处理 {FaceCount} 个人脸,生成 {ClusterCount} 个聚类",
|
||||||
|
unclusteredFaces.Count, clusters.Count);
|
||||||
|
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FaceCluster?> AssignFaceToClusterAsync(int faceId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var face = await dbContext.Faces
|
||||||
|
.Include(f => f.Picture)
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == faceId);
|
||||||
|
|
||||||
|
if (face?.Embedding == null) return null;
|
||||||
|
|
||||||
|
// 获取所有现有聚类的代表人脸
|
||||||
|
var existingClusters = await dbContext.FaceClusters
|
||||||
|
.Include(c => c.Faces.Take(1))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cluster in existingClusters)
|
||||||
|
{
|
||||||
|
if (cluster.Faces?.Any() == true)
|
||||||
|
{
|
||||||
|
var representativeFace = cluster.Faces.First();
|
||||||
|
if (representativeFace.Embedding != null)
|
||||||
|
{
|
||||||
|
var similarity = CalculateSimilarity(face.Embedding, representativeFace.Embedding);
|
||||||
|
if (similarity >= SIMILARITY_THRESHOLD)
|
||||||
|
{
|
||||||
|
face.ClusterId = cluster.Id;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新聚类
|
||||||
|
var newCluster = new FaceCluster
|
||||||
|
{
|
||||||
|
Name = $"未知人物 {DateTime.Now:yyyyMMddHHmmss}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.FaceClusters.Add(newCluster);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
face.ClusterId = newCluster.Id;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return newCluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double CalculateSimilarity(float[] embedding1, float[] embedding2)
|
||||||
|
{
|
||||||
|
if (embedding1.Length != embedding2.Length) return 0;
|
||||||
|
|
||||||
|
// 计算余弦相似度
|
||||||
|
double dot = 0, norm1 = 0, norm2 = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < embedding1.Length; i++)
|
||||||
|
{
|
||||||
|
dot += embedding1[i] * embedding2[i];
|
||||||
|
norm1 += embedding1[i] * embedding1[i];
|
||||||
|
norm2 += embedding2[i] * embedding2[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (norm1 == 0 || norm2 == 0) return 0;
|
||||||
|
|
||||||
|
return dot / (Math.Sqrt(norm1) * Math.Sqrt(norm2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FaceCluster?> FindBestClusterAsync(Face face, List<FaceCluster> newClusters, MyDbContext dbContext)
|
||||||
|
{
|
||||||
|
if (face.Embedding == null) return null;
|
||||||
|
|
||||||
|
double bestSimilarity = 0;
|
||||||
|
FaceCluster? bestCluster = null;
|
||||||
|
|
||||||
|
// 检查现有数据库中的聚类
|
||||||
|
var existingClusters = await dbContext.FaceClusters
|
||||||
|
.Include(c => c.Faces.Take(5)) // 取前5个人脸作为比较
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cluster in existingClusters.Concat(newClusters))
|
||||||
|
{
|
||||||
|
if (cluster.Faces?.Any() == true)
|
||||||
|
{
|
||||||
|
foreach (var clusterFace in cluster.Faces)
|
||||||
|
{
|
||||||
|
if (clusterFace.Embedding != null)
|
||||||
|
{
|
||||||
|
var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding);
|
||||||
|
if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD)
|
||||||
|
{
|
||||||
|
bestSimilarity = similarity;
|
||||||
|
bestCluster = cluster;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestCluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<FaceCluster>> ClusterUserFacesAsync(int userId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 获取指定用户所有有嵌入向量但未分类的人脸
|
||||||
|
var unclusteredFaces = await dbContext.Faces
|
||||||
|
.Where(f => f.Embedding != null && f.ClusterId == null && f.Picture.UserId == userId)
|
||||||
|
.Include(f => f.Picture)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var clusters = new List<FaceCluster>();
|
||||||
|
|
||||||
|
foreach (var face in unclusteredFaces)
|
||||||
|
{
|
||||||
|
var assignedCluster = await FindBestUserClusterAsync(face, userId, clusters, dbContext);
|
||||||
|
|
||||||
|
if (assignedCluster != null)
|
||||||
|
{
|
||||||
|
face.ClusterId = assignedCluster.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newCluster = new FaceCluster
|
||||||
|
{
|
||||||
|
Name = $"未知人物 {DateTime.Now:yyyyMMddHHmmss}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.FaceClusters.Add(newCluster);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
face.ClusterId = newCluster.Id;
|
||||||
|
clusters.Add(newCluster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
logger.LogInformation("用户 {UserId} 人脸聚类完成,共处理 {FaceCount} 个人脸,生成 {ClusterCount} 个聚类",
|
||||||
|
userId, unclusteredFaces.Count, clusters.Count);
|
||||||
|
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FaceCluster?> FindBestUserClusterAsync(Face face, int userId, List<FaceCluster> newClusters, MyDbContext dbContext)
|
||||||
|
{
|
||||||
|
if (face.Embedding == null) return null;
|
||||||
|
|
||||||
|
double bestSimilarity = 0;
|
||||||
|
FaceCluster? bestCluster = null;
|
||||||
|
|
||||||
|
// 检查该用户现有的聚类
|
||||||
|
var existingClusters = await dbContext.FaceClusters
|
||||||
|
.Where(c => dbContext.Faces.Any(f => f.ClusterId == c.Id && f.Picture.UserId == userId))
|
||||||
|
.Include(c => c.Faces.Where(f => f.Picture.UserId == userId).Take(5))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cluster in existingClusters.Concat(newClusters))
|
||||||
|
{
|
||||||
|
if (cluster.Faces?.Any() == true)
|
||||||
|
{
|
||||||
|
foreach (var clusterFace in cluster.Faces)
|
||||||
|
{
|
||||||
|
if (clusterFace.Embedding != null)
|
||||||
|
{
|
||||||
|
var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding);
|
||||||
|
if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD)
|
||||||
|
{
|
||||||
|
bestSimilarity = similarity;
|
||||||
|
bestCluster = cluster;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestCluster;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Foxel.Models.DataBase;
|
||||||
|
|
||||||
|
public interface IFaceClusteringService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 对所有未分类的人脸进行聚类
|
||||||
|
/// </summary>
|
||||||
|
Task<List<FaceCluster>> ClusterFacesAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对指定用户的未分类人脸进行聚类
|
||||||
|
/// </summary>
|
||||||
|
Task<List<FaceCluster>> ClusterUserFacesAsync(int userId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为新检测到的人脸分配到现有聚类或创建新聚类
|
||||||
|
/// </summary>
|
||||||
|
Task<FaceCluster?> AssignFaceToClusterAsync(int faceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算两个人脸嵌入向量的相似度
|
||||||
|
/// </summary>
|
||||||
|
double CalculateSimilarity(float[] embedding1, float[] embedding2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
using Foxel.Models;
|
||||||
|
using Foxel.Models.Response.Face;
|
||||||
|
using Foxel.Models.Response.Picture;
|
||||||
|
using Foxel.Services.Mapping;
|
||||||
|
using Foxel.Api.Management;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Foxel.Services.Management;
|
||||||
|
|
||||||
|
public class FaceManagementService(
|
||||||
|
IDbContextFactory<MyDbContext> contextFactory,
|
||||||
|
IMappingService mappingService,
|
||||||
|
ILogger<FaceManagementService> logger) : IFaceManagementService
|
||||||
|
{
|
||||||
|
public async Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1) pageSize = 20;
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
var clusterQuery = dbContext.FaceClusters
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
Cluster = c,
|
||||||
|
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id),
|
||||||
|
ThumbnailPath = dbContext.Faces
|
||||||
|
.Where(f => f.ClusterId == c.Id)
|
||||||
|
.Include(f => f.Picture)
|
||||||
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.Select(f => f.Picture.ThumbnailPath)
|
||||||
|
.FirstOrDefault()
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.FaceCount)
|
||||||
|
.ThenByDescending(x => x.Cluster.LastUpdatedAt);
|
||||||
|
|
||||||
|
var totalCount = await clusterQuery.CountAsync();
|
||||||
|
var clusterData = await clusterQuery
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var clusterResponses = clusterData.Select(data => new FaceClusterResponse
|
||||||
|
{
|
||||||
|
Id = data.Cluster.Id,
|
||||||
|
Name = data.Cluster.Name,
|
||||||
|
PersonName = data.Cluster.PersonName,
|
||||||
|
Description = data.Cluster.Description,
|
||||||
|
FaceCount = data.FaceCount,
|
||||||
|
LastUpdatedAt = data.Cluster.LastUpdatedAt,
|
||||||
|
ThumbnailPath = data.ThumbnailPath,
|
||||||
|
CreatedAt = data.Cluster.CreatedAt
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new PaginatedResult<FaceClusterResponse>
|
||||||
|
{
|
||||||
|
Data = clusterResponses,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<PictureResponse>> GetPicturesByClusterAsync(int clusterId, int page = 1, int pageSize = 20)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1) pageSize = 20;
|
||||||
|
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 获取该聚类下所有图片ID
|
||||||
|
var pictureIds = await dbContext.Faces
|
||||||
|
.Where(f => f.ClusterId == clusterId)
|
||||||
|
.Select(f => f.PictureId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var query = dbContext.Pictures
|
||||||
|
.Where(p => pictureIds.Contains(p.Id))
|
||||||
|
.Include(p => p.User)
|
||||||
|
.Include(p => p.Tags)
|
||||||
|
.Include(p => p.Faces)
|
||||||
|
.OrderByDescending(p => p.CreatedAt);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var pictures = await query
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var pictureResponses = pictures
|
||||||
|
.Select(p => mappingService.MapPictureToResponse(p))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PaginatedResult<PictureResponse>
|
||||||
|
{
|
||||||
|
Data = pictureResponses,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FaceClusterResponse> UpdateClusterAsync(int clusterId, string? personName, string? description = null)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var cluster = await dbContext.FaceClusters
|
||||||
|
.Include(c => c.Faces)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == clusterId);
|
||||||
|
|
||||||
|
if (cluster == null)
|
||||||
|
throw new KeyNotFoundException($"找不到ID为 {clusterId} 的人脸聚类");
|
||||||
|
|
||||||
|
cluster.PersonName = personName;
|
||||||
|
if (description != null)
|
||||||
|
cluster.Description = description;
|
||||||
|
cluster.LastUpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// 如果设置了人物姓名,更新聚类名称
|
||||||
|
if (!string.IsNullOrWhiteSpace(personName))
|
||||||
|
{
|
||||||
|
cluster.Name = personName;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new FaceClusterResponse
|
||||||
|
{
|
||||||
|
Id = cluster.Id,
|
||||||
|
Name = cluster.Name,
|
||||||
|
PersonName = cluster.PersonName,
|
||||||
|
Description = cluster.Description,
|
||||||
|
FaceCount = cluster.Faces?.Count ?? 0,
|
||||||
|
LastUpdatedAt = cluster.LastUpdatedAt,
|
||||||
|
CreatedAt = cluster.CreatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> MergeClustersAsync(int sourceClusterId, int targetClusterId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var sourceFaces = await dbContext.Faces
|
||||||
|
.Where(f => f.ClusterId == sourceClusterId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var targetCluster = await dbContext.FaceClusters
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == targetClusterId);
|
||||||
|
|
||||||
|
if (targetCluster == null)
|
||||||
|
throw new KeyNotFoundException($"找不到目标聚类 {targetClusterId}");
|
||||||
|
|
||||||
|
// 将源聚类的所有人脸移动到目标聚类
|
||||||
|
foreach (var face in sourceFaces)
|
||||||
|
{
|
||||||
|
face.ClusterId = targetClusterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除源聚类
|
||||||
|
var sourceCluster = await dbContext.FaceClusters.FindAsync(sourceClusterId);
|
||||||
|
if (sourceCluster != null)
|
||||||
|
{
|
||||||
|
dbContext.FaceClusters.Remove(sourceCluster);
|
||||||
|
}
|
||||||
|
targetCluster.LastUpdatedAt = DateTime.UtcNow;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
logger.LogInformation("成功合并聚类 {SourceId} 到 {TargetId},移动了 {FaceCount} 个人脸",
|
||||||
|
sourceClusterId, targetClusterId, sourceFaces.Count);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemoveFaceFromClusterAsync(int faceId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var face = await dbContext.Faces.FindAsync(faceId);
|
||||||
|
if (face == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
face.ClusterId = null;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<FaceClusterResponse>> GetUserFaceClustersAsync(int userId, int page = 1, int pageSize = 20)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1) pageSize = 20;
|
||||||
|
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var clusterQuery = dbContext.FaceClusters
|
||||||
|
.Where(c => dbContext.Faces.Any(f => f.ClusterId == c.Id && f.Picture.UserId == userId))
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
Cluster = c,
|
||||||
|
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id && f.Picture.UserId == userId),
|
||||||
|
ThumbnailPath = dbContext.Faces
|
||||||
|
.Where(f => f.ClusterId == c.Id && f.Picture.UserId == userId)
|
||||||
|
.Include(f => f.Picture)
|
||||||
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.Select(f => f.Picture.ThumbnailPath)
|
||||||
|
.FirstOrDefault()
|
||||||
|
})
|
||||||
|
.Where(x => x.FaceCount > 0)
|
||||||
|
.OrderByDescending(x => x.FaceCount)
|
||||||
|
.ThenByDescending(x => x.Cluster.LastUpdatedAt);
|
||||||
|
|
||||||
|
var totalCount = await clusterQuery.CountAsync();
|
||||||
|
var clusterData = await clusterQuery
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var clusterResponses = clusterData.Select(data => new FaceClusterResponse
|
||||||
|
{
|
||||||
|
Id = data.Cluster.Id,
|
||||||
|
Name = data.Cluster.Name,
|
||||||
|
PersonName = data.Cluster.PersonName,
|
||||||
|
Description = data.Cluster.Description,
|
||||||
|
FaceCount = data.FaceCount,
|
||||||
|
LastUpdatedAt = data.Cluster.LastUpdatedAt,
|
||||||
|
ThumbnailPath = data.ThumbnailPath,
|
||||||
|
CreatedAt = data.Cluster.CreatedAt
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new PaginatedResult<FaceClusterResponse>
|
||||||
|
{
|
||||||
|
Data = clusterResponses,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<PictureResponse>> GetUserPicturesByClusterAsync(int userId, int clusterId, int page = 1, int pageSize = 20)
|
||||||
|
{
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1) pageSize = 20;
|
||||||
|
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 验证聚类是否包含该用户的人脸
|
||||||
|
var hasUserFaces = await dbContext.Faces
|
||||||
|
.AnyAsync(f => f.ClusterId == clusterId && f.Picture.UserId == userId);
|
||||||
|
|
||||||
|
if (!hasUserFaces)
|
||||||
|
throw new KeyNotFoundException($"找不到用户 {userId} 的聚类 {clusterId}");
|
||||||
|
|
||||||
|
// 获取该聚类下该用户的所有图片ID
|
||||||
|
var pictureIds = await dbContext.Faces
|
||||||
|
.Where(f => f.ClusterId == clusterId && f.Picture.UserId == userId)
|
||||||
|
.Select(f => f.PictureId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var query = dbContext.Pictures
|
||||||
|
.Where(p => pictureIds.Contains(p.Id) && p.UserId == userId)
|
||||||
|
.Include(p => p.User)
|
||||||
|
.Include(p => p.Tags)
|
||||||
|
.Include(p => p.Faces)
|
||||||
|
.OrderByDescending(p => p.CreatedAt);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var pictures = await query
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var pictureResponses = pictures
|
||||||
|
.Select(p => mappingService.MapPictureToResponse(p))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PaginatedResult<PictureResponse>
|
||||||
|
{
|
||||||
|
Data = pictureResponses,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FaceClusterResponse> UpdateUserClusterAsync(int userId, int clusterId, string? personName, string? description = null)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 验证聚类是否包含该用户的人脸
|
||||||
|
var hasUserFaces = await dbContext.Faces
|
||||||
|
.AnyAsync(f => f.ClusterId == clusterId && f.Picture.UserId == userId);
|
||||||
|
|
||||||
|
if (!hasUserFaces)
|
||||||
|
throw new KeyNotFoundException($"找不到用户 {userId} 的聚类 {clusterId}");
|
||||||
|
|
||||||
|
var cluster = await dbContext.FaceClusters
|
||||||
|
.Include(c => c.Faces.Where(f => f.Picture.UserId == userId))
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == clusterId);
|
||||||
|
|
||||||
|
if (cluster == null)
|
||||||
|
throw new KeyNotFoundException($"找不到ID为 {clusterId} 的人脸聚类");
|
||||||
|
|
||||||
|
cluster.PersonName = personName;
|
||||||
|
if (description != null)
|
||||||
|
cluster.Description = description;
|
||||||
|
cluster.LastUpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(personName))
|
||||||
|
{
|
||||||
|
cluster.Name = personName;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new FaceClusterResponse
|
||||||
|
{
|
||||||
|
Id = cluster.Id,
|
||||||
|
Name = cluster.Name,
|
||||||
|
PersonName = cluster.PersonName,
|
||||||
|
Description = cluster.Description,
|
||||||
|
FaceCount = cluster.Faces?.Count ?? 0,
|
||||||
|
LastUpdatedAt = cluster.LastUpdatedAt,
|
||||||
|
CreatedAt = cluster.CreatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> MergeUserClustersAsync(int userId, int sourceClusterId, int targetClusterId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// 验证两个聚类都包含该用户的人脸
|
||||||
|
var sourceHasUserFaces = await dbContext.Faces
|
||||||
|
.AnyAsync(f => f.ClusterId == sourceClusterId && f.Picture.UserId == userId);
|
||||||
|
var targetHasUserFaces = await dbContext.Faces
|
||||||
|
.AnyAsync(f => f.ClusterId == targetClusterId && f.Picture.UserId == userId);
|
||||||
|
|
||||||
|
if (!sourceHasUserFaces || !targetHasUserFaces)
|
||||||
|
throw new KeyNotFoundException("找不到指定的聚类或无权访问");
|
||||||
|
|
||||||
|
// 只移动该用户的人脸
|
||||||
|
var sourceFaces = await dbContext.Faces
|
||||||
|
.Where(f => f.ClusterId == sourceClusterId && f.Picture.UserId == userId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var targetCluster = await dbContext.FaceClusters
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == targetClusterId);
|
||||||
|
|
||||||
|
if (targetCluster == null)
|
||||||
|
throw new KeyNotFoundException($"找不到目标聚类 {targetClusterId}");
|
||||||
|
|
||||||
|
foreach (var face in sourceFaces)
|
||||||
|
{
|
||||||
|
face.ClusterId = targetClusterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查源聚类是否还有其他用户的人脸,如果没有则删除
|
||||||
|
var remainingFaces = await dbContext.Faces
|
||||||
|
.CountAsync(f => f.ClusterId == sourceClusterId);
|
||||||
|
|
||||||
|
if (remainingFaces == 0)
|
||||||
|
{
|
||||||
|
var sourceCluster = await dbContext.FaceClusters.FindAsync(sourceClusterId);
|
||||||
|
if (sourceCluster != null)
|
||||||
|
{
|
||||||
|
dbContext.FaceClusters.Remove(sourceCluster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetCluster.LastUpdatedAt = DateTime.UtcNow;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("用户 {UserId} 成功合并聚类 {SourceId} 到 {TargetId},移动了 {FaceCount} 个人脸",
|
||||||
|
userId, sourceClusterId, targetClusterId, sourceFaces.Count);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemoveUserFaceFromClusterAsync(int userId, int faceId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var face = await dbContext.Faces
|
||||||
|
.Include(f => f.Picture)
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == faceId && f.Picture.UserId == userId);
|
||||||
|
|
||||||
|
if (face == null)
|
||||||
|
throw new KeyNotFoundException("找不到指定的人脸或无权访问");
|
||||||
|
|
||||||
|
face.ClusterId = null;
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteClusterAsync(int clusterId)
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var cluster = await dbContext.FaceClusters
|
||||||
|
.Include(c => c.Faces)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == clusterId);
|
||||||
|
|
||||||
|
if (cluster == null)
|
||||||
|
throw new KeyNotFoundException($"找不到ID为 {clusterId} 的人脸聚类");
|
||||||
|
|
||||||
|
// 将所有人脸的聚类ID设为null
|
||||||
|
if (cluster.Faces != null)
|
||||||
|
{
|
||||||
|
foreach (var face in cluster.Faces)
|
||||||
|
{
|
||||||
|
face.ClusterId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.FaceClusters.Remove(cluster);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogInformation("删除聚类 {ClusterId},影响了 {FaceCount} 个人脸",
|
||||||
|
clusterId, cluster.Faces?.Count ?? 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FaceClusterStatistics> GetClusterStatisticsAsync()
|
||||||
|
{
|
||||||
|
await using var dbContext = await contextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var totalClusters = await dbContext.FaceClusters.CountAsync();
|
||||||
|
var totalFaces = await dbContext.Faces.CountAsync();
|
||||||
|
var unclusteredFaces = await dbContext.Faces.CountAsync(f => f.ClusterId == null);
|
||||||
|
var namedClusters = await dbContext.FaceClusters.CountAsync(c => !string.IsNullOrEmpty(c.PersonName));
|
||||||
|
|
||||||
|
var clustersByUserQuery = await dbContext.Faces
|
||||||
|
.Where(f => f.ClusterId != null)
|
||||||
|
.GroupBy(f => f.Picture.UserId)
|
||||||
|
.Select(g => new { UserId = g.Key, ClusterCount = g.Select(f => f.ClusterId).Distinct().Count() })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var clustersByUser = clustersByUserQuery
|
||||||
|
.Where(x => x.UserId.HasValue)
|
||||||
|
.ToDictionary(x => x.UserId.Value, x => x.ClusterCount);
|
||||||
|
|
||||||
|
return new FaceClusterStatistics
|
||||||
|
{
|
||||||
|
TotalClusters = totalClusters,
|
||||||
|
TotalFaces = totalFaces,
|
||||||
|
UnclusteredFaces = unclusteredFaces,
|
||||||
|
NamedClusters = namedClusters,
|
||||||
|
ClustersByUser = clustersByUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Foxel.Models;
|
||||||
|
using Foxel.Models.Response.Face;
|
||||||
|
using Foxel.Models.Response.Picture;
|
||||||
|
using Foxel.Api.Management;
|
||||||
|
|
||||||
|
namespace Foxel.Services.Management;
|
||||||
|
|
||||||
|
public interface IFaceManagementService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有人脸聚类(管理员)
|
||||||
|
/// </summary>
|
||||||
|
Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定用户的人脸聚类
|
||||||
|
/// </summary>
|
||||||
|
Task<PaginatedResult<FaceClusterResponse>> GetUserFaceClustersAsync(int userId, int page = 1, int pageSize = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据聚类ID获取相关图片(管理员)
|
||||||
|
/// </summary>
|
||||||
|
Task<PaginatedResult<PictureResponse>> GetPicturesByClusterAsync(int clusterId, int page = 1, int pageSize = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据聚类ID获取指定用户的相关图片
|
||||||
|
/// </summary>
|
||||||
|
Task<PaginatedResult<PictureResponse>> GetUserPicturesByClusterAsync(int userId, int clusterId, int page = 1, int pageSize = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新聚类信息(管理员)
|
||||||
|
/// </summary>
|
||||||
|
Task<FaceClusterResponse> UpdateClusterAsync(int clusterId, string? personName, string? description = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新用户聚类信息
|
||||||
|
/// </summary>
|
||||||
|
Task<FaceClusterResponse> UpdateUserClusterAsync(int userId, int clusterId, string? personName, string? description = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 合并两个聚类(管理员)
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> MergeClustersAsync(int sourceClusterId, int targetClusterId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 合并用户的两个聚类
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> MergeUserClustersAsync(int userId, int sourceClusterId, int targetClusterId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从聚类中移除人脸(管理员)
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> RemoveFaceFromClusterAsync(int faceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从用户聚类中移除人脸
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> RemoveUserFaceFromClusterAsync(int userId, int faceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除聚类(管理员)
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> DeleteClusterAsync(int clusterId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取聚类统计信息(管理员)
|
||||||
|
/// </summary>
|
||||||
|
Task<FaceClusterStatistics> GetClusterStatisticsAsync();
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ namespace Foxel.Services.Mapping
|
|||||||
W = face.W,
|
W = face.W,
|
||||||
H = face.H,
|
H = face.H,
|
||||||
FaceConfidence = face.FaceConfidence,
|
FaceConfidence = face.FaceConfidence,
|
||||||
PersonName = face.PersonName
|
PersonName = face.Cluster?.Name
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,16 +157,17 @@ public class PictureService(
|
|||||||
// 构建基础查询
|
// 构建基础查询
|
||||||
IQueryable<Picture> query = dbContext.Pictures
|
IQueryable<Picture> query = dbContext.Pictures
|
||||||
.Include(p => p.Tags)
|
.Include(p => p.Tags)
|
||||||
.Include(p => p.Faces)
|
.Include(p => p.User)
|
||||||
.Include(p => p.User);
|
.Include(p => p.Faces!)
|
||||||
|
.ThenInclude(f => f.Cluster);
|
||||||
|
|
||||||
// 应用文本搜索条件
|
// 应用文本搜索条件
|
||||||
if (!string.IsNullOrWhiteSpace(searchQuery))
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
||||||
{
|
{
|
||||||
var searchTerm = searchQuery.ToLower();
|
var searchTerm = searchQuery.ToLower();
|
||||||
query = query.Where(p =>
|
query = query.Where(p =>
|
||||||
(p.Name.ToLower().Contains(searchTerm)) ||
|
p.Name.ToLower().Contains(searchTerm) ||
|
||||||
(p.Description.ToLower().Contains(searchTerm)));
|
p.Description.ToLower().Contains(searchTerm));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用共通的查询条件
|
// 应用共通的查询条件
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { fetchApi, type BaseResult } from './fetchClient';
|
||||||
|
|
||||||
|
// 人脸聚类响应数据
|
||||||
|
export interface FaceClusterResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
personName?: string;
|
||||||
|
description?: string;
|
||||||
|
faceCount: number;
|
||||||
|
lastUpdatedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
thumbnailPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新聚类请求
|
||||||
|
export interface UpdateClusterRequest {
|
||||||
|
personName?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并聚类请求
|
||||||
|
export interface MergeClustersRequest {
|
||||||
|
sourceClusterId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人脸聚类统计信息
|
||||||
|
export interface FaceClusterStatistics {
|
||||||
|
totalClusters: number;
|
||||||
|
totalFaces: number;
|
||||||
|
unclusteredFaces: number;
|
||||||
|
namedClusters: number;
|
||||||
|
clustersByUser: Record<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取人脸聚类列表(管理员)
|
||||||
|
export async function getFaceClusters(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
userId?: number
|
||||||
|
): Promise<any> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.append('page', page.toString());
|
||||||
|
queryParams.append('pageSize', pageSize.toString());
|
||||||
|
if (userId) {
|
||||||
|
queryParams.append('userId', userId.toString());
|
||||||
|
}
|
||||||
|
const url = `/management/face/clusters?${queryParams.toString()}`;
|
||||||
|
const result = await fetchApi(url);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据聚类获取图片(管理员)
|
||||||
|
export async function getPicturesByCluster(
|
||||||
|
clusterId: number,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20
|
||||||
|
): Promise<any> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.append('page', page.toString());
|
||||||
|
queryParams.append('pageSize', pageSize.toString());
|
||||||
|
const url = `/management/face/clusters/${clusterId}/pictures?${queryParams.toString()}`;
|
||||||
|
const result = await fetchApi(url);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新人脸聚类信息(管理员)
|
||||||
|
export async function updateCluster(
|
||||||
|
clusterId: number,
|
||||||
|
data: UpdateClusterRequest
|
||||||
|
): Promise<BaseResult<FaceClusterResponse>> {
|
||||||
|
return fetchApi<FaceClusterResponse>(`/management/face/clusters/${clusterId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始人脸聚类(管理员)
|
||||||
|
export async function startFaceClustering(userId?: number): Promise<BaseResult<boolean>> {
|
||||||
|
const queryParams = userId ? `?userId=${userId}` : '';
|
||||||
|
return fetchApi<boolean>(`/management/face/clusters/analyze${queryParams}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并聚类(管理员)
|
||||||
|
export async function mergeClusters(
|
||||||
|
targetClusterId: number,
|
||||||
|
sourceClusterId: number
|
||||||
|
): Promise<BaseResult<boolean>> {
|
||||||
|
const data: MergeClustersRequest = {
|
||||||
|
sourceClusterId,
|
||||||
|
};
|
||||||
|
return fetchApi<boolean>(`/management/face/clusters/${targetClusterId}/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除聚类(管理员)
|
||||||
|
export async function deleteCluster(clusterId: number): Promise<BaseResult<boolean>> {
|
||||||
|
return fetchApi<boolean>(`/management/face/clusters/${clusterId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从聚类中移除人脸(管理员)
|
||||||
|
export async function removeFaceFromCluster(faceId: number): Promise<BaseResult<boolean>> {
|
||||||
|
return fetchApi<boolean>(`/management/face/faces/${faceId}/cluster`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取人脸聚类统计信息(管理员)
|
||||||
|
export async function getClusterStatistics(): Promise<BaseResult<FaceClusterStatistics>> {
|
||||||
|
return fetchApi<FaceClusterStatistics>('/management/face/statistics');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ export * from './albumApi';
|
|||||||
export * from './albumManagementApi';
|
export * from './albumManagementApi';
|
||||||
export * from './backgroundTaskApi';
|
export * from './backgroundTaskApi';
|
||||||
export * from './configApi';
|
export * from './configApi';
|
||||||
|
export * from './faceManagementApi';
|
||||||
export * from './fetchClient';
|
export * from './fetchClient';
|
||||||
export * from './logManagementApi';
|
export * from './logManagementApi';
|
||||||
export * from './pictureApi';
|
export * from './pictureApi';
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table, Button, Card, Space, Modal, message, Typography,
|
||||||
|
Row, Col, Image, Form, Input, Avatar,
|
||||||
|
Select, Tag, Tooltip, Spin, Empty, Statistic,
|
||||||
|
Popconfirm
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined, ReloadOutlined, PlayCircleOutlined,
|
||||||
|
EditOutlined, TeamOutlined, MergeCellsOutlined,
|
||||||
|
ExclamationCircleOutlined, EyeOutlined, DeleteOutlined,
|
||||||
|
BarChartOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
getFaceClusters, updateCluster, startFaceClustering, mergeClusters,
|
||||||
|
getPicturesByCluster, deleteCluster, getClusterStatistics, getUsers,
|
||||||
|
type FaceClusterResponse, type UpdateClusterRequest, type FaceClusterStatistics
|
||||||
|
} from '../../../api';
|
||||||
|
import type { PictureResponse } from '../../../api/pictureApi';
|
||||||
|
import { useOutletContext } from 'react-router';
|
||||||
|
import type { Breakpoint } from 'antd';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { confirm } = Modal;
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FaceManagement: React.FC = () => {
|
||||||
|
const { isMobile } = useOutletContext<{ isMobile: boolean }>();
|
||||||
|
|
||||||
|
const [clusters, setClusters] = useState<FaceClusterResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [clusteringLoading, setClusteringLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | undefined>();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [statistics, setStatistics] = useState<FaceClusterStatistics | null>(null);
|
||||||
|
|
||||||
|
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||||
|
const [isMergeModalVisible, setIsMergeModalVisible] = useState(false);
|
||||||
|
const [isPictureModalVisible, setIsPictureModalVisible] = useState(false);
|
||||||
|
const [editingCluster, setEditingCluster] = useState<FaceClusterResponse | null>(null);
|
||||||
|
const [targetCluster, setTargetCluster] = useState<FaceClusterResponse | null>(null);
|
||||||
|
const [clusterPictures, setClusterPictures] = useState<PictureResponse[]>([]);
|
||||||
|
const [picturesLoading, setPicturesLoading] = useState(false);
|
||||||
|
|
||||||
|
const [editForm] = Form.useForm<UpdateClusterRequest>();
|
||||||
|
const [mergeForm] = Form.useForm();
|
||||||
|
|
||||||
|
// 获取用户列表
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await getUsers();
|
||||||
|
if (response.success) {
|
||||||
|
setUsers((response.data || []).map(user => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.userName ,
|
||||||
|
email: user.email
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户列表失败:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取统计信息
|
||||||
|
const fetchStatistics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await getClusterStatistics();
|
||||||
|
if (response.success) {
|
||||||
|
setStatistics(response.data || null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计信息失败:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchClusters = useCallback(async (
|
||||||
|
page = currentPage, size = pageSize, userId = selectedUserId
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getFaceClusters(page, size, userId);
|
||||||
|
if (response.success) {
|
||||||
|
const actualData = response.data?.data || response.data;
|
||||||
|
setClusters(Array.isArray(actualData) ? actualData : []);
|
||||||
|
setTotal(response.data?.totalCount || response.totalCount || 0);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '获取人脸聚类失败');
|
||||||
|
setClusters([]);
|
||||||
|
setTotal(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取人脸聚类失败,请检查网络连接');
|
||||||
|
setClusters([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentPage, pageSize, selectedUserId]);
|
||||||
|
|
||||||
|
const fetchClusterPictures = useCallback(async (clusterId: number) => {
|
||||||
|
setPicturesLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getPicturesByCluster(clusterId, 1, 50);
|
||||||
|
if (response.success) {
|
||||||
|
// 修复:正确提取嵌套的数据结构
|
||||||
|
const actualData = response.data?.data || response.data;
|
||||||
|
setClusterPictures(Array.isArray(actualData) ? actualData : []);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '获取聚类图片失败');
|
||||||
|
setClusterPictures([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取聚类图片失败');
|
||||||
|
setClusterPictures([]);
|
||||||
|
} finally {
|
||||||
|
setPicturesLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClusters();
|
||||||
|
fetchUsers();
|
||||||
|
fetchStatistics();
|
||||||
|
}, [fetchClusters, fetchUsers, fetchStatistics]);
|
||||||
|
|
||||||
|
const handlePageChange = (page: number, size?: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
if (size) setPageSize(size);
|
||||||
|
fetchClusters(page, size || pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserChange = (userId?: number) => {
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchClusters(1, pageSize, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showEditModal = (cluster: FaceClusterResponse) => {
|
||||||
|
setEditingCluster(cluster);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
personName: cluster.personName,
|
||||||
|
description: cluster.description,
|
||||||
|
});
|
||||||
|
setIsEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMergeModal = (cluster: FaceClusterResponse) => {
|
||||||
|
setTargetCluster(cluster);
|
||||||
|
mergeForm.resetFields();
|
||||||
|
setIsMergeModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPicturesModal = (cluster: FaceClusterResponse) => {
|
||||||
|
setEditingCluster(cluster);
|
||||||
|
setIsPictureModalVisible(true);
|
||||||
|
fetchClusterPictures(cluster.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditOk = async () => {
|
||||||
|
if (!editingCluster) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await editForm.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
const response = await updateCluster(editingCluster.id, values);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
message.success('更新聚类信息成功');
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
fetchClusters();
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '更新失败');
|
||||||
|
}
|
||||||
|
} catch (errorInfo) {
|
||||||
|
console.log('Validate Failed:', errorInfo);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMergeOk = async () => {
|
||||||
|
if (!targetCluster) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await mergeForm.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
const response = await mergeClusters(targetCluster.id, values.sourceClusterId);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
message.success('合并聚类成功');
|
||||||
|
setIsMergeModalVisible(false);
|
||||||
|
fetchClusters();
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '合并失败');
|
||||||
|
}
|
||||||
|
} catch (errorInfo) {
|
||||||
|
console.log('Validate Failed:', errorInfo);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartClustering = () => {
|
||||||
|
confirm({
|
||||||
|
title: '开始人脸聚类分析',
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
content: selectedUserId
|
||||||
|
? `这将分析用户 ${users.find(u => u.id === selectedUserId)?.username} 的未分类人脸,可能需要一些时间。确定要开始吗?`
|
||||||
|
: '这将分析所有用户的未分类人脸,可能需要一些时间。确定要开始吗?',
|
||||||
|
async onOk() {
|
||||||
|
setClusteringLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await startFaceClustering(selectedUserId);
|
||||||
|
if (response.success) {
|
||||||
|
message.success('人脸聚类任务已开始,请稍后刷新查看结果');
|
||||||
|
fetchStatistics(); // 刷新统计信息
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '启动聚类失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('启动聚类失败');
|
||||||
|
} finally {
|
||||||
|
setClusteringLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCluster = async (clusterId: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await deleteCluster(clusterId);
|
||||||
|
if (response.success) {
|
||||||
|
message.success('删除聚类成功');
|
||||||
|
fetchClusters();
|
||||||
|
fetchStatistics();
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 80,
|
||||||
|
responsive: ['md'] as Breakpoint[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '代表图片',
|
||||||
|
dataIndex: 'thumbnailPath',
|
||||||
|
key: 'thumbnail',
|
||||||
|
width: 80,
|
||||||
|
render: (path?: string) => (
|
||||||
|
path ? (
|
||||||
|
<Image
|
||||||
|
width={50}
|
||||||
|
height={50}
|
||||||
|
src={path}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 8 }}
|
||||||
|
preview={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
size={50}
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
style={{ backgroundColor: '#f0f0f0', color: '#999' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '聚类名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string, record: FaceClusterResponse) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{name}</div>
|
||||||
|
{record.personName && (
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
人物: {record.personName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
responsive: ['lg'] as Breakpoint[],
|
||||||
|
render: (desc?: string) => desc || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '人脸数量',
|
||||||
|
dataIndex: 'faceCount',
|
||||||
|
key: 'faceCount',
|
||||||
|
width: 100,
|
||||||
|
render: (count: number) => (
|
||||||
|
<Tag color="blue" icon={<TeamOutlined />}>
|
||||||
|
{count}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后更新',
|
||||||
|
dataIndex: 'lastUpdatedAt',
|
||||||
|
key: 'lastUpdatedAt',
|
||||||
|
responsive: ['lg'] as Breakpoint[],
|
||||||
|
render: (date: Date) => new Date(date).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 280,
|
||||||
|
render: (_: any, record: FaceClusterResponse) => (
|
||||||
|
<Space size="small" wrap>
|
||||||
|
<Tooltip title="查看图片">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => showPicturesModal(record)}
|
||||||
|
>
|
||||||
|
图片
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="编辑聚类">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => showEditModal(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="合并聚类">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<MergeCellsOutlined />}
|
||||||
|
onClick={() => showMergeModal(record)}
|
||||||
|
>
|
||||||
|
合并
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm
|
||||||
|
title="删除聚类"
|
||||||
|
description={`确定要删除聚类 "${record.name}" 吗?删除后人脸将变为未分类状态。`}
|
||||||
|
onConfirm={() => handleDeleteCluster(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Tooltip title="删除聚类">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="face-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>
|
||||||
|
|
||||||
|
{/* 统计信息卡片 */}
|
||||||
|
{statistics && (
|
||||||
|
<Card style={{ marginTop: 16 }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title="总聚类数"
|
||||||
|
value={statistics.totalClusters}
|
||||||
|
prefix={<TeamOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title="总人脸数"
|
||||||
|
value={statistics.totalFaces}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title="未分类人脸"
|
||||||
|
value={statistics.unclusteredFaces}
|
||||||
|
valueStyle={{ color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Statistic
|
||||||
|
title="已命名聚类"
|
||||||
|
value={statistics.namedClusters}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card style={{ marginTop: 16 }}>
|
||||||
|
<Row gutter={[16, 16]} justify="space-between" style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={16} md={18}>
|
||||||
|
<Space wrap>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={handleStartClustering}
|
||||||
|
loading={clusteringLoading}
|
||||||
|
>
|
||||||
|
{selectedUserId ? '为选定用户聚类' : '开始全局聚类'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => fetchClusters()}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<BarChartOutlined />}
|
||||||
|
onClick={fetchStatistics}
|
||||||
|
>
|
||||||
|
刷新统计
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8} md={6}>
|
||||||
|
<Select
|
||||||
|
placeholder="选择用户筛选"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
value={selectedUserId}
|
||||||
|
onChange={handleUserChange}
|
||||||
|
options={[
|
||||||
|
{ value: undefined, label: '所有用户' },
|
||||||
|
...users.map(user => ({
|
||||||
|
value: user.id,
|
||||||
|
label: `${user.username} (${user.email})`,
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={clusters}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: currentPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
onChange: handlePageChange,
|
||||||
|
showTotal: (total) => `共 ${total} 个聚类`,
|
||||||
|
}}
|
||||||
|
size={isMobile ? "small" : "middle"}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 编辑聚类模态框 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑聚类信息"
|
||||||
|
open={isEditModalVisible}
|
||||||
|
onOk={handleEditOk}
|
||||||
|
onCancel={() => setIsEditModalVisible(false)}
|
||||||
|
confirmLoading={loading}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="personName" label="人物姓名">
|
||||||
|
<Input placeholder="请输入人物姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={3} placeholder="请输入描述信息" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 合并聚类模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={`合并聚类到: ${targetCluster?.name || ''}`}
|
||||||
|
open={isMergeModalVisible}
|
||||||
|
onOk={handleMergeOk}
|
||||||
|
onCancel={() => setIsMergeModalVisible(false)}
|
||||||
|
confirmLoading={loading}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={mergeForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="sourceClusterId"
|
||||||
|
label="选择要合并的源聚类"
|
||||||
|
rules={[{ required: true, message: '请选择源聚类' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择要合并的聚类"
|
||||||
|
options={Array.isArray(clusters) ? clusters
|
||||||
|
.filter(c => c.id !== targetCluster?.id)
|
||||||
|
.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: `${c.name} (${c.faceCount} 个人脸)`,
|
||||||
|
})) : []}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Text type="secondary">
|
||||||
|
合并后,源聚类将被删除,其所有人脸将移动到目标聚类中。
|
||||||
|
</Text>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 查看聚类图片模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={`聚类图片: ${editingCluster?.name || ''}`}
|
||||||
|
open={isPictureModalVisible}
|
||||||
|
onCancel={() => setIsPictureModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Spin spinning={picturesLoading}>
|
||||||
|
{Array.isArray(clusterPictures) && clusterPictures.length > 0 ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
maxHeight: 400,
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
{clusterPictures.map(picture => (
|
||||||
|
<div key={picture.id} style={{ textAlign: 'center' }}>
|
||||||
|
<Image
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
src={picture.thumbnailPath || picture.path}
|
||||||
|
style={{
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #f0f0f0'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{picture.name || `图片${picture.id}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无图片" />
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaceManagement;
|
||||||
@@ -27,6 +27,7 @@ import UserDetail from '../pages/admin/users/UserDetail';
|
|||||||
import AdminLogManagement from '../pages/admin/log/Index';
|
import AdminLogManagement from '../pages/admin/log/Index';
|
||||||
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
|
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
|
||||||
import AlbumManagement from '../pages/admin/album/Index';
|
import AlbumManagement from '../pages/admin/album/Index';
|
||||||
|
import FaceManagement from '../pages/admin/face/Index';
|
||||||
|
|
||||||
export interface RouteConfig {
|
export interface RouteConfig {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -187,6 +188,18 @@ const routes: RouteConfig[] = [
|
|||||||
breadcrumb: {
|
breadcrumb: {
|
||||||
title: '相册管理'
|
title: '相册管理'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'faces-admin',
|
||||||
|
key: 'admin-face',
|
||||||
|
icon: <FolderOutlined />,
|
||||||
|
label: '人脸管理',
|
||||||
|
element: <FaceManagement />,
|
||||||
|
area: 'admin',
|
||||||
|
groupLabel: '内容管理',
|
||||||
|
breadcrumb: {
|
||||||
|
title: '人脸管理'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'log',
|
path: 'log',
|
||||||
|
|||||||
Reference in New Issue
Block a user