diff --git a/Api/FaceController.cs b/Api/FaceController.cs new file mode 100644 index 0000000..e1f18fe --- /dev/null +++ b/Api/FaceController.cs @@ -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 logger) : BaseApiController +{ + /// + /// 获取当前用户的人脸聚类列表 + /// + [HttpGet("clusters")] + public async Task>>> 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>("获取人脸聚类列表失败", 500); + } + } + + /// + /// 根据聚类获取当前用户的图片 + /// + [HttpGet("clusters/{clusterId}/pictures")] + public async Task>>> 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>("找不到指定的人脸聚类或无权访问", 404); + } + catch (Exception ex) + { + logger.LogError(ex, "获取用户聚类图片失败: UserId={UserId}, ClusterId={ClusterId}", + GetCurrentUserId(), clusterId); + return Error>("获取聚类图片失败", 500); + } + } + + /// + /// 更新当前用户的人脸聚类信息 + /// + [HttpPut("clusters/{clusterId}")] + public async Task>> 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("找不到指定的人脸聚类或无权访问", 404); + } + catch (Exception ex) + { + logger.LogError(ex, "更新用户聚类信息失败: UserId={UserId}, ClusterId={ClusterId}", + GetCurrentUserId(), clusterId); + return Error("更新聚类信息失败", 500); + } + } + + /// + /// 开始当前用户的人脸聚类 + /// + [HttpPost("clusters/analyze")] + public async Task>> StartMyFaceClustering() + { + try + { + await faceClusteringService.ClusterUserFacesAsync(GetCurrentUserId()); + return Success(true, "人脸聚类任务已开始"); + } + catch (Exception ex) + { + logger.LogError(ex, "启动用户人脸聚类失败: UserId={UserId}", GetCurrentUserId()); + return Error("启动人脸聚类失败", 500); + } + } + + /// + /// 合并当前用户的聚类 + /// + [HttpPost("clusters/{targetClusterId}/merge")] + public async Task>> MergeMyUserClusters( + int targetClusterId, [FromBody] MergeClustersRequest request) + { + try + { + var result = await faceManagementService.MergeUserClustersAsync( + GetCurrentUserId(), request.SourceClusterId, targetClusterId); + return Success(result, "合并聚类成功"); + } + catch (KeyNotFoundException ex) + { + return Error(ex.Message, 404); + } + catch (Exception ex) + { + logger.LogError(ex, "合并用户聚类失败: UserId={UserId}", GetCurrentUserId()); + return Error("合并聚类失败", 500); + } + } + + /// + /// 从聚类中移除人脸 + /// + [HttpDelete("faces/{faceId}/cluster")] + public async Task>> RemoveFaceFromCluster(int faceId) + { + try + { + var result = await faceManagementService.RemoveUserFaceFromClusterAsync(GetCurrentUserId(), faceId); + return Success(result, "移除人脸成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到指定的人脸或无权访问", 404); + } + catch (Exception ex) + { + logger.LogError(ex, "移除用户人脸失败: UserId={UserId}, FaceId={FaceId}", + GetCurrentUserId(), faceId); + return Error("移除人脸失败", 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; } +} \ No newline at end of file diff --git a/Api/Management/FaceMamagementController.cs b/Api/Management/FaceMamagementController.cs new file mode 100644 index 0000000..58fe243 --- /dev/null +++ b/Api/Management/FaceMamagementController.cs @@ -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 logger) : BaseApiController +{ + /// + /// 获取所有用户的人脸聚类列表 + /// + [HttpGet("clusters")] + public async Task>>> 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>("获取人脸聚类列表失败", 500); + } + } + + /// + /// 根据聚类获取图片(管理员可查看所有) + /// + [HttpGet("clusters/{clusterId}/pictures")] + public async Task>>> 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>("找不到指定的人脸聚类", 404); + } + catch (Exception ex) + { + logger.LogError(ex, "管理员获取聚类图片失败: ClusterId={ClusterId}", clusterId); + return Error>("获取聚类图片失败", 500); + } + } + + /// + /// 更新人脸聚类信息(管理员) + /// + [HttpPut("clusters/{clusterId}")] + public async Task>> UpdateCluster( + int clusterId, [FromBody] UpdateClusterRequest request) + { + try + { + var result = await faceManagementService.UpdateClusterAsync( + clusterId, request.PersonName, request.Description); + return Success(result, "更新聚类信息成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到指定的人脸聚类", 404); + } + catch (Exception ex) + { + logger.LogError(ex, "管理员更新聚类信息失败: ClusterId={ClusterId}", clusterId); + return Error("更新聚类信息失败", 500); + } + } + + /// + /// 开始全局人脸聚类(管理员) + /// + [HttpPost("clusters/analyze")] + public async Task>> 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("启动人脸聚类失败", 500); + } + } + + /// + /// 合并聚类(管理员) + /// + [HttpPost("clusters/{targetClusterId}/merge")] + public async Task>> MergeClusters( + int targetClusterId, [FromBody] MergeClustersRequest request) + { + try + { + var result = await faceManagementService.MergeClustersAsync( + request.SourceClusterId, targetClusterId); + return Success(result, "合并聚类成功"); + } + catch (KeyNotFoundException ex) + { + return Error(ex.Message, 404); + } + catch (Exception ex) + { + logger.LogError(ex, "管理员合并聚类失败"); + return Error("合并聚类失败", 500); + } + } + + /// + /// 删除聚类(管理员) + /// + [HttpDelete("clusters/{clusterId}")] + public async Task>> DeleteCluster(int clusterId) + { + try + { + var result = await faceManagementService.DeleteClusterAsync(clusterId); + return Success(result, "删除聚类成功"); + } + catch (KeyNotFoundException) + { + return Error("找不到指定的人脸聚类", 404); + } + catch (Exception ex) + { + logger.LogError(ex, "管理员删除聚类失败: ClusterId={ClusterId}", clusterId); + return Error("删除聚类失败", 500); + } + } + + /// + /// 从聚类中移除人脸(管理员) + /// + [HttpDelete("faces/{faceId}/cluster")] + public async Task>> RemoveFaceFromCluster(int faceId) + { + try + { + var result = await faceManagementService.RemoveFaceFromClusterAsync(faceId); + return Success(result, "移除人脸成功"); + } + catch (Exception ex) + { + logger.LogError(ex, "管理员移除人脸失败: FaceId={FaceId}", faceId); + return Error("移除人脸失败", 500); + } + } + + /// + /// 获取人脸聚类统计信息 + /// + [HttpGet("statistics")] + public async Task>> GetClusterStatistics() + { + try + { + var result = await faceManagementService.GetClusterStatisticsAsync(); + return Success(result, "获取统计信息成功"); + } + catch (Exception ex) + { + logger.LogError(ex, "获取聚类统计信息失败"); + return Error("获取统计信息失败", 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 ClustersByUser { get; set; } = new(); +} diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs index f0bc28a..0f0927d 100644 --- a/Extensions/ServiceCollectionExtensions.cs +++ b/Extensions/ServiceCollectionExtensions.cs @@ -12,7 +12,6 @@ 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; using Foxel.Services.Background.Processors; using Foxel.Services.Mapping; @@ -42,6 +41,9 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } public static void AddApplicationDbContext(this IServiceCollection services, IConfiguration configuration) diff --git a/Models/DataBase/Face.cs b/Models/DataBase/Face.cs index ab2b8bf..376446c 100644 --- a/Models/DataBase/Face.cs +++ b/Models/DataBase/Face.cs @@ -6,19 +6,21 @@ namespace Foxel.Models.DataBase; public class Face : BaseModel { public float[]? Embedding { get; set; } - + public int X { get; set; } public int Y { get; set; } public int W { get; set; } public int H { get; set; } - + [Range(0.0, 1.0)] public double FaceConfidence { get; set; } - + public int PictureId { get; set; } - - [StringLength(255)] - public string? PersonName { get; set; } + [ForeignKey("PictureId")] public Picture Picture { get; set; } = null!; + public int? ClusterId { get; set; } + + [ForeignKey("ClusterId")] + public FaceCluster? Cluster { get; set; } } diff --git a/Models/DataBase/FaceCluster.cs b/Models/DataBase/FaceCluster.cs new file mode 100644 index 0000000..dcb434e --- /dev/null +++ b/Models/DataBase/FaceCluster.cs @@ -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; } + + /// + /// 用户设置的人物名称 + /// + [StringLength(255)] + public string? PersonName { get; set; } + + /// + /// 最后更新时间 + /// + public DateTime LastUpdatedAt { get; set; } + + public ICollection? Faces { get; set; } +} \ No newline at end of file diff --git a/Models/Response/Face/FaceClusterResponse.cs b/Models/Response/Face/FaceClusterResponse.cs new file mode 100644 index 0000000..ca48c1a --- /dev/null +++ b/Models/Response/Face/FaceClusterResponse.cs @@ -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; } +} \ No newline at end of file diff --git a/Models/Response/Picture/PictureResponse.cs b/Models/Response/Picture/PictureResponse.cs index 2f35e51..5c72f50 100644 --- a/Models/Response/Picture/PictureResponse.cs +++ b/Models/Response/Picture/PictureResponse.cs @@ -1,4 +1,5 @@ using Foxel.Models.DataBase; +using Foxel.Models.Response.Face; namespace Foxel.Models.Response.Picture; @@ -27,5 +28,5 @@ public record PictureResponse public string? AlbumName { get; set; } public PermissionType Permission { get; set; } = PermissionType.Public; public string? StorageModeName { get; set; } - public List? Faces { get; set; } + public List? Faces { get; set; } } diff --git a/MyDbContext.cs b/MyDbContext.cs index 83cb16d..81dca95 100644 --- a/MyDbContext.cs +++ b/MyDbContext.cs @@ -16,6 +16,7 @@ public class MyDbContext(DbContextOptions options) : DbContext(opti public DbSet BackgroundTasks { get; set; } = null!; public DbSet StorageModes { get; set; } = null!; public DbSet Faces { get; set; } = null!; + public DbSet FaceClusters { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Services/AI/FaceClusteringService.cs b/Services/AI/FaceClusteringService.cs new file mode 100644 index 0000000..cbec273 --- /dev/null +++ b/Services/AI/FaceClusteringService.cs @@ -0,0 +1,236 @@ +using Foxel.Models.DataBase; +using Microsoft.EntityFrameworkCore; + +namespace Foxel.Services.AI; + +public class FaceClusteringService( + IDbContextFactory contextFactory, + ILogger logger) : IFaceClusteringService +{ + private const double SIMILARITY_THRESHOLD = 0.5; + + public async Task> 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(); + + 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 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 FindBestClusterAsync(Face face, List 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> 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(); + + 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 FindBestUserClusterAsync(Face face, int userId, List 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; + } +} \ No newline at end of file diff --git a/Services/AI/IFaceClusteringService.cs b/Services/AI/IFaceClusteringService.cs new file mode 100644 index 0000000..9034899 --- /dev/null +++ b/Services/AI/IFaceClusteringService.cs @@ -0,0 +1,24 @@ +using Foxel.Models.DataBase; + +public interface IFaceClusteringService +{ + /// + /// 对所有未分类的人脸进行聚类 + /// + Task> ClusterFacesAsync(); + + /// + /// 对指定用户的未分类人脸进行聚类 + /// + Task> ClusterUserFacesAsync(int userId); + + /// + /// 为新检测到的人脸分配到现有聚类或创建新聚类 + /// + Task AssignFaceToClusterAsync(int faceId); + + /// + /// 计算两个人脸嵌入向量的相似度 + /// + double CalculateSimilarity(float[] embedding1, float[] embedding2); +} \ No newline at end of file diff --git a/Services/Management/FaceManagementService.cs b/Services/Management/FaceManagementService.cs new file mode 100644 index 0000000..df491c5 --- /dev/null +++ b/Services/Management/FaceManagementService.cs @@ -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 contextFactory, + IMappingService mappingService, + ILogger logger) : IFaceManagementService +{ + public async Task> 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 + { + Data = clusterResponses, + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task> 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 + { + Data = pictureResponses, + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task 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 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 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> 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 + { + Data = clusterResponses, + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task> 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 + { + Data = pictureResponses, + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task 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 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 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 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 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 + }; + } +} \ No newline at end of file diff --git a/Services/Management/IFaceManagementService.cs b/Services/Management/IFaceManagementService.cs new file mode 100644 index 0000000..57c5538 --- /dev/null +++ b/Services/Management/IFaceManagementService.cs @@ -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 +{ + /// + /// 获取所有人脸聚类(管理员) + /// + Task> GetFaceClustersAsync(int page = 1, int pageSize = 20); + + /// + /// 获取指定用户的人脸聚类 + /// + Task> GetUserFaceClustersAsync(int userId, int page = 1, int pageSize = 20); + + /// + /// 根据聚类ID获取相关图片(管理员) + /// + Task> GetPicturesByClusterAsync(int clusterId, int page = 1, int pageSize = 20); + + /// + /// 根据聚类ID获取指定用户的相关图片 + /// + Task> GetUserPicturesByClusterAsync(int userId, int clusterId, int page = 1, int pageSize = 20); + + /// + /// 更新聚类信息(管理员) + /// + Task UpdateClusterAsync(int clusterId, string? personName, string? description = null); + + /// + /// 更新用户聚类信息 + /// + Task UpdateUserClusterAsync(int userId, int clusterId, string? personName, string? description = null); + + /// + /// 合并两个聚类(管理员) + /// + Task MergeClustersAsync(int sourceClusterId, int targetClusterId); + + /// + /// 合并用户的两个聚类 + /// + Task MergeUserClustersAsync(int userId, int sourceClusterId, int targetClusterId); + + /// + /// 从聚类中移除人脸(管理员) + /// + Task RemoveFaceFromClusterAsync(int faceId); + + /// + /// 从用户聚类中移除人脸 + /// + Task RemoveUserFaceFromClusterAsync(int userId, int faceId); + + /// + /// 删除聚类(管理员) + /// + Task DeleteClusterAsync(int clusterId); + + /// + /// 获取聚类统计信息(管理员) + /// + Task GetClusterStatisticsAsync(); +} \ No newline at end of file diff --git a/Services/Mapping/MappingService.cs b/Services/Mapping/MappingService.cs index 69d63c3..be95785 100644 --- a/Services/Mapping/MappingService.cs +++ b/Services/Mapping/MappingService.cs @@ -71,7 +71,7 @@ namespace Foxel.Services.Mapping W = face.W, H = face.H, FaceConfidence = face.FaceConfidence, - PersonName = face.PersonName + PersonName = face.Cluster?.Name }).ToList(), }; } diff --git a/Services/Media/PictureService.cs b/Services/Media/PictureService.cs index 05f6e4a..44ce0a6 100644 --- a/Services/Media/PictureService.cs +++ b/Services/Media/PictureService.cs @@ -157,16 +157,17 @@ public class PictureService( // 构建基础查询 IQueryable query = dbContext.Pictures .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)) { var searchTerm = searchQuery.ToLower(); query = query.Where(p => - (p.Name.ToLower().Contains(searchTerm)) || - (p.Description.ToLower().Contains(searchTerm))); + p.Name.ToLower().Contains(searchTerm) || + p.Description.ToLower().Contains(searchTerm)); } // 应用共通的查询条件 @@ -617,7 +618,7 @@ public class PictureService( UserIdForPicture = picture.UserId }; await backgroundTaskQueue.QueueVisualRecognitionTaskAsync(visualRecognitionPayload); - + // 添加人脸识别任务 var faceRecognitionPayload = new Background.Processors.FaceRecognitionPayload { diff --git a/Web/src/api/faceManagementApi.ts b/Web/src/api/faceManagementApi.ts new file mode 100644 index 0000000..3c1402c --- /dev/null +++ b/Web/src/api/faceManagementApi.ts @@ -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; +} + +// 获取人脸聚类列表(管理员) +export async function getFaceClusters( + page: number = 1, + pageSize: number = 20, + userId?: number +): Promise { + 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 { + 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> { + return fetchApi(`/management/face/clusters/${clusterId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +// 开始人脸聚类(管理员) +export async function startFaceClustering(userId?: number): Promise> { + const queryParams = userId ? `?userId=${userId}` : ''; + return fetchApi(`/management/face/clusters/analyze${queryParams}`, { + method: 'POST', + }); +} + +// 合并聚类(管理员) +export async function mergeClusters( + targetClusterId: number, + sourceClusterId: number +): Promise> { + const data: MergeClustersRequest = { + sourceClusterId, + }; + return fetchApi(`/management/face/clusters/${targetClusterId}/merge`, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +// 删除聚类(管理员) +export async function deleteCluster(clusterId: number): Promise> { + return fetchApi(`/management/face/clusters/${clusterId}`, { + method: 'DELETE', + }); +} + +// 从聚类中移除人脸(管理员) +export async function removeFaceFromCluster(faceId: number): Promise> { + return fetchApi(`/management/face/faces/${faceId}/cluster`, { + method: 'DELETE', + }); +} + +// 获取人脸聚类统计信息(管理员) +export async function getClusterStatistics(): Promise> { + return fetchApi('/management/face/statistics'); +} + diff --git a/Web/src/api/index.ts b/Web/src/api/index.ts index c6ab60f..7b692ef 100644 --- a/Web/src/api/index.ts +++ b/Web/src/api/index.ts @@ -3,6 +3,7 @@ export * from './albumApi'; export * from './albumManagementApi'; export * from './backgroundTaskApi'; export * from './configApi'; +export * from './faceManagementApi'; export * from './fetchClient'; export * from './logManagementApi'; export * from './pictureApi'; diff --git a/Web/src/pages/admin/face/Index.tsx b/Web/src/pages/admin/face/Index.tsx new file mode 100644 index 0000000..7af8734 --- /dev/null +++ b/Web/src/pages/admin/face/Index.tsx @@ -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([]); + 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(); + const [users, setUsers] = useState([]); + const [statistics, setStatistics] = useState(null); + + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const [isMergeModalVisible, setIsMergeModalVisible] = useState(false); + const [isPictureModalVisible, setIsPictureModalVisible] = useState(false); + const [editingCluster, setEditingCluster] = useState(null); + const [targetCluster, setTargetCluster] = useState(null); + const [clusterPictures, setClusterPictures] = useState([]); + const [picturesLoading, setPicturesLoading] = useState(false); + + const [editForm] = Form.useForm(); + 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: , + 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 ? ( + + ) : ( + } + style={{ backgroundColor: '#f0f0f0', color: '#999' }} + /> + ) + ), + }, + { + title: '聚类名称', + dataIndex: 'name', + key: 'name', + render: (name: string, record: FaceClusterResponse) => ( +
+
{name}
+ {record.personName && ( + + 人物: {record.personName} + + )} +
+ ), + }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + responsive: ['lg'] as Breakpoint[], + render: (desc?: string) => desc || '-', + }, + { + title: '人脸数量', + dataIndex: 'faceCount', + key: 'faceCount', + width: 100, + render: (count: number) => ( + }> + {count} + + ), + }, + { + 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) => ( + + + + + + + + + + + handleDeleteCluster(record.id)} + okText="确定" + cancelText="取消" + > + + + + + + ), + }, + ]; + + return ( +
+ + + + + 人脸管理 + + + 管理系统中的人脸聚类,识别和标记图片中的人物 + + + + + {/* 统计信息卡片 */} + {statistics && ( + + + + } + /> + + + } + /> + + + + + + + + + + )} + + + + + + + + + + + + + + + + + + + + {/* 合并聚类模态框 */} + setIsMergeModalVisible(false)} + confirmLoading={loading} + destroyOnClose + > +
+ +