diff --git a/Api/FaceController.cs b/Api/FaceController.cs index e1f18fe..6da6bdd 100644 --- a/Api/FaceController.cs +++ b/Api/FaceController.cs @@ -24,7 +24,11 @@ public class FaceController( { try { - var result = await faceManagementService.GetUserFaceClustersAsync(GetCurrentUserId(), page, pageSize); + var userId = GetCurrentUserId(); + if (!userId.HasValue) + return Error>("用户未认证", 401); + + var result = await faceManagementService.GetUserFaceClustersAsync(userId.Value, page, pageSize); return Success(result, "获取人脸聚类列表成功"); } catch (Exception ex) @@ -43,8 +47,12 @@ public class FaceController( { try { + var userId = GetCurrentUserId(); + if (!userId.HasValue) + return Error>("用户未认证", 401); + var result = await faceManagementService.GetUserPicturesByClusterAsync( - GetCurrentUserId(), clusterId, page, pageSize); + userId.Value, clusterId, page, pageSize); return Success(result, "获取聚类图片成功"); } catch (KeyNotFoundException) @@ -68,8 +76,12 @@ public class FaceController( { try { + var userId = GetCurrentUserId(); + if (!userId.HasValue) + return Error("用户未认证", 401); + var result = await faceManagementService.UpdateUserClusterAsync( - GetCurrentUserId(), clusterId, request.PersonName, request.Description); + userId.Value, clusterId, request.PersonName, request.Description); return Success(result, "更新聚类信息成功"); } catch (KeyNotFoundException) @@ -92,7 +104,11 @@ public class FaceController( { try { - await faceClusteringService.ClusterUserFacesAsync(GetCurrentUserId()); + var userId = GetCurrentUserId(); + if (!userId.HasValue) + return Error("用户未认证", 401); + + await faceClusteringService.ClusterUserFacesAsync(userId.Value); return Success(true, "人脸聚类任务已开始"); } catch (Exception ex) @@ -111,8 +127,12 @@ public class FaceController( { try { + var userId = GetCurrentUserId(); + if (!userId.HasValue) + return Error("用户未认证", 401); + var result = await faceManagementService.MergeUserClustersAsync( - GetCurrentUserId(), request.SourceClusterId, targetClusterId); + userId.Value, request.SourceClusterId, targetClusterId); return Success(result, "合并聚类成功"); } catch (KeyNotFoundException ex) @@ -134,7 +154,11 @@ public class FaceController( { try { - var result = await faceManagementService.RemoveUserFaceFromClusterAsync(GetCurrentUserId(), faceId); + var userId = GetCurrentUserId(); + if (!userId.HasValue) + return Error("用户未认证", 401); + + var result = await faceManagementService.RemoveUserFaceFromClusterAsync(userId.Value, faceId); return Success(result, "移除人脸成功"); } catch (KeyNotFoundException) @@ -149,12 +173,6 @@ public class FaceController( } } - private int GetCurrentUserId() - { - // 从JWT或Claims中获取当前用户ID - var userIdClaim = User.FindFirst("id") ?? User.FindFirst("sub"); - return int.Parse(userIdClaim?.Value ?? "0"); - } } public record UpdateClusterRequest diff --git a/Services/AI/FaceClusteringService.cs b/Services/AI/FaceClusteringService.cs index cbec273..b6d0ce9 100644 --- a/Services/AI/FaceClusteringService.cs +++ b/Services/AI/FaceClusteringService.cs @@ -7,13 +7,14 @@ public class FaceClusteringService( IDbContextFactory contextFactory, ILogger logger) : IFaceClusteringService { - private const double SIMILARITY_THRESHOLD = 0.5; + private const double BASE_SIMILARITY_THRESHOLD = 0.3; + private const double HIGH_CONFIDENCE_THRESHOLD = 0.5; + private const int MAX_COMPARISON_FACES = 10; 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) @@ -77,7 +78,7 @@ public class FaceClusteringService( if (representativeFace.Embedding != null) { var similarity = CalculateSimilarity(face.Embedding, representativeFace.Embedding); - if (similarity >= SIMILARITY_THRESHOLD) + if (similarity >= BASE_SIMILARITY_THRESHOLD) { face.ClusterId = cluster.Id; await dbContext.SaveChangesAsync(); @@ -107,7 +108,23 @@ public class FaceClusteringService( { if (embedding1.Length != embedding2.Length) return 0; - // 计算余弦相似度 + // 1. 余弦相似度 + double cosineSim = CalculateCosineSimilarity(embedding1, embedding2); + + // 2. 欧几里得距离转换为相似度 + double euclideanSim = CalculateEuclideanSimilarity(embedding1, embedding2); + + // 3. 曼哈顿距离转换为相似度 + double manhattanSim = CalculateManhattanSimilarity(embedding1, embedding2); + + // 加权组合多个相似度指标 + double weightedSimilarity = cosineSim * 0.6 + euclideanSim * 0.3 + manhattanSim * 0.1; + + return weightedSimilarity; + } + + private double CalculateCosineSimilarity(float[] embedding1, float[] embedding2) + { double dot = 0, norm1 = 0, norm2 = 0; for (int i = 0; i < embedding1.Length; i++) @@ -118,42 +135,112 @@ public class FaceClusteringService( } if (norm1 == 0 || norm2 == 0) return 0; - return dot / (Math.Sqrt(norm1) * Math.Sqrt(norm2)); } + private double CalculateEuclideanSimilarity(float[] embedding1, float[] embedding2) + { + double sumSquareDiff = 0; + for (int i = 0; i < embedding1.Length; i++) + { + double diff = embedding1[i] - embedding2[i]; + sumSquareDiff += diff * diff; + } + + double distance = Math.Sqrt(sumSquareDiff); + // 转换为相似度:距离越小,相似度越高 + return 1.0 / (1.0 + distance); + } + + private double CalculateManhattanSimilarity(float[] embedding1, float[] embedding2) + { + double sumAbsDiff = 0; + for (int i = 0; i < embedding1.Length; i++) + { + sumAbsDiff += Math.Abs(embedding1[i] - embedding2[i]); + } + + // 转换为相似度 + return 1.0 / (1.0 + sumAbsDiff / embedding1.Length); + } + private async Task FindBestClusterAsync(Face face, List newClusters, MyDbContext dbContext) { if (face.Embedding == null) return null; - double bestSimilarity = 0; - FaceCluster? bestCluster = null; + var clusterSimilarities = new List<(FaceCluster cluster, double avgSimilarity, double maxSimilarity, int comparisonCount)>(); // 检查现有数据库中的聚类 var existingClusters = await dbContext.FaceClusters - .Include(c => c.Faces.Take(5)) // 取前5个人脸作为比较 + .Include(c => c.Faces.Take(MAX_COMPARISON_FACES)) .ToListAsync(); foreach (var cluster in existingClusters.Concat(newClusters)) { if (cluster.Faces?.Any() == true) { - foreach (var clusterFace in cluster.Faces) + var similarities = new List(); + + foreach (var clusterFace in cluster.Faces.Take(MAX_COMPARISON_FACES)) { if (clusterFace.Embedding != null) { var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding); - if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD) - { - bestSimilarity = similarity; - bestCluster = cluster; - } + similarities.Add(similarity); } } + + if (similarities.Any()) + { + double avgSimilarity = similarities.Average(); + double maxSimilarity = similarities.Max(); + + clusterSimilarities.Add((cluster, avgSimilarity, maxSimilarity, similarities.Count)); + } } } - return bestCluster; + // 智能选择最佳聚类 + return SelectBestCluster(clusterSimilarities); + } + + private FaceCluster? SelectBestCluster(List<(FaceCluster cluster, double avgSimilarity, double maxSimilarity, int comparisonCount)> clusterSimilarities) + { + if (!clusterSimilarities.Any()) return null; + + // 按照综合评分排序 + var rankedClusters = clusterSimilarities + .Where(cs => cs.avgSimilarity >= BASE_SIMILARITY_THRESHOLD || cs.maxSimilarity >= HIGH_CONFIDENCE_THRESHOLD) + .Select(cs => new + { + cs.cluster, + cs.avgSimilarity, + cs.maxSimilarity, + cs.comparisonCount, + // 综合评分:平均相似度权重60%,最高相似度权重30%,样本数量权重10% + Score = cs.avgSimilarity * 0.6 + cs.maxSimilarity * 0.3 + + Math.Min(cs.comparisonCount / (double)MAX_COMPARISON_FACES, 1.0) * 0.1 + }) + .OrderByDescending(x => x.Score) + .ToList(); + + if (!rankedClusters.Any()) return null; + + var bestMatch = rankedClusters.First(); + + // 额外验证:如果最高相似度很高,直接接受 + if (bestMatch.maxSimilarity >= HIGH_CONFIDENCE_THRESHOLD) + { + return bestMatch.cluster; + } + + // 如果平均相似度足够高且有足够样本,接受 + if (bestMatch.avgSimilarity >= BASE_SIMILARITY_THRESHOLD && bestMatch.comparisonCount >= 2) + { + return bestMatch.cluster; + } + + return null; } public async Task> ClusterUserFacesAsync(int userId) @@ -203,34 +290,160 @@ public class FaceClusteringService( { if (face.Embedding == null) return null; - double bestSimilarity = 0; - FaceCluster? bestCluster = null; + var clusterSimilarities = new List<(FaceCluster cluster, double avgSimilarity, double maxSimilarity, int comparisonCount)>(); // 检查该用户现有的聚类 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)) + .Include(c => c.Faces.Where(f => f.Picture.UserId == userId).Take(MAX_COMPARISON_FACES)) .ToListAsync(); foreach (var cluster in existingClusters.Concat(newClusters)) { if (cluster.Faces?.Any() == true) { - foreach (var clusterFace in cluster.Faces) + var similarities = new List(); + + foreach (var clusterFace in cluster.Faces.Take(MAX_COMPARISON_FACES)) { if (clusterFace.Embedding != null) { var similarity = CalculateSimilarity(face.Embedding, clusterFace.Embedding); - if (similarity > bestSimilarity && similarity >= SIMILARITY_THRESHOLD) - { - bestSimilarity = similarity; - bestCluster = cluster; - } + similarities.Add(similarity); + } + } + + if (similarities.Any()) + { + double avgSimilarity = similarities.Average(); + double maxSimilarity = similarities.Max(); + + clusterSimilarities.Add((cluster, avgSimilarity, maxSimilarity, similarities.Count)); + } + } + } + + return SelectBestCluster(clusterSimilarities); + } + + // 新增:聚类质量评估方法 + public async Task EvaluateClusterQualityAsync(int clusterId) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + var cluster = await dbContext.FaceClusters + .Include(c => c.Faces.Where(f => f.Embedding != null)) + .FirstOrDefaultAsync(c => c.Id == clusterId); + + if (cluster?.Faces == null || !cluster.Faces.Any()) + { + return new ClusterQualityMetrics { IsValid = false }; + } + + var embeddings = cluster.Faces.Select(f => f.Embedding).Where(e => e != null).ToArray(); + if (embeddings.Length < 2) + { + return new ClusterQualityMetrics { IsValid = true, InternalSimilarity = 1.0, FaceCount = embeddings.Length }; + } + + // 计算内部相似度 + var similarities = new List(); + for (int i = 0; i < embeddings.Length; i++) + { + for (int j = i + 1; j < embeddings.Length; j++) + { + similarities.Add(CalculateSimilarity(embeddings[i]!, embeddings[j]!)); + } + } + + return new ClusterQualityMetrics + { + IsValid = true, + InternalSimilarity = similarities.Average(), + MinSimilarity = similarities.Min(), + MaxSimilarity = similarities.Max(), + FaceCount = embeddings.Length, + SimilarityStandardDeviation = CalculateStandardDeviation(similarities) + }; + } + + private double CalculateStandardDeviation(List values) + { + if (!values.Any()) return 0; + + double mean = values.Average(); + double sumSquaredDifferences = values.Sum(v => Math.Pow(v - mean, 2)); + return Math.Sqrt(sumSquaredDifferences / values.Count); + } + + // 新增:动态阈值调整 + public async Task CalculateOptimalThresholdAsync(int userId) + { + await using var dbContext = await contextFactory.CreateDbContextAsync(); + + var userClusters = 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 && f.Embedding != null)) + .ToListAsync(); + + var intraClusterSimilarities = new List(); + var interClusterSimilarities = new List(); + + // 计算聚类内相似度 + foreach (var cluster in userClusters.Where(c => c.Faces.Count > 1)) + { + var faces = cluster.Faces.Where(f => f.Embedding != null).ToArray(); + for (int i = 0; i < faces.Length; i++) + { + for (int j = i + 1; j < faces.Length; j++) + { + intraClusterSimilarities.Add(CalculateSimilarity(faces[i].Embedding!, faces[j].Embedding!)); + } + } + } + + // 计算聚类间相似度 + for (int i = 0; i < userClusters.Count; i++) + { + for (int j = i + 1; j < userClusters.Count; j++) + { + var cluster1Faces = userClusters[i].Faces.Where(f => f.Embedding != null).Take(5).ToArray(); + var cluster2Faces = userClusters[j].Faces.Where(f => f.Embedding != null).Take(5).ToArray(); + + foreach (var face1 in cluster1Faces) + { + foreach (var face2 in cluster2Faces) + { + interClusterSimilarities.Add(CalculateSimilarity(face1.Embedding!, face2.Embedding!)); } } } } - return bestCluster; + if (!intraClusterSimilarities.Any() || !interClusterSimilarities.Any()) + { + return BASE_SIMILARITY_THRESHOLD; + } + + // 找到最优分割点 + double minIntra = intraClusterSimilarities.Min(); + double maxInter = interClusterSimilarities.Max(); + + // 理想阈值应该在聚类间最大相似度和聚类内最小相似度之间 + double optimalThreshold = (minIntra + maxInter) / 2.0; + + // 确保在合理范围内 + return Math.Max(0.4, Math.Min(0.9, optimalThreshold)); } +} + +// 新增:聚类质量评估结果类 +public class ClusterQualityMetrics +{ + public bool IsValid { get; set; } + public double InternalSimilarity { get; set; } + public double MinSimilarity { get; set; } + public double MaxSimilarity { get; set; } + public int FaceCount { get; set; } + public double SimilarityStandardDeviation { get; set; } } \ No newline at end of file diff --git a/Web/src/api/faceExploreApi.ts b/Web/src/api/faceExploreApi.ts new file mode 100644 index 0000000..bacc74f --- /dev/null +++ b/Web/src/api/faceExploreApi.ts @@ -0,0 +1,66 @@ +import { fetchApi, type BaseResult } from './fetchClient'; +import type { FaceClusterResponse, UpdateClusterRequest } from './faceManagementApi'; + +// 获取当前用户的人脸聚类列表 +export async function getMyFaceClusters( + page: number = 1, + pageSize: number = 20 +): Promise { + const queryParams = new URLSearchParams(); + queryParams.append('page', page.toString()); + queryParams.append('pageSize', pageSize.toString()); + const url = `/face/clusters?${queryParams.toString()}`; + const result = await fetchApi(url); + return result; +} + +// 根据聚类获取当前用户的图片 +export async function getMyPicturesByCluster( + 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 = `/face/clusters/${clusterId}/pictures?${queryParams.toString()}`; + const result = await fetchApi(url); + return result; +} + +// 更新当前用户的人脸聚类信息 +export async function updateMyCluster( + clusterId: number, + data: UpdateClusterRequest +): Promise> { + return fetchApi(`/face/clusters/${clusterId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +// 开始当前用户的人脸聚类 +export async function startMyFaceClustering(): Promise> { + return fetchApi('/face/clusters/analyze', { + method: 'POST', + }); +} + +// 合并当前用户的聚类 +export async function mergeMyUserClusters( + targetClusterId: number, + sourceClusterId: number +): Promise> { + const data = { sourceClusterId }; + return fetchApi(`/face/clusters/${targetClusterId}/merge`, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +// 从聚类中移除人脸 +export async function removeFaceFromCluster(faceId: number): Promise> { + return fetchApi(`/face/faces/${faceId}/cluster`, { + method: 'DELETE', + }); +} diff --git a/Web/src/api/index.ts b/Web/src/api/index.ts index 7b692ef..e0333be 100644 --- a/Web/src/api/index.ts +++ b/Web/src/api/index.ts @@ -11,4 +11,14 @@ export * from './pictureManagementApi'; export * from './tagApi'; export * from './userManagementApi'; export * from './vectorDbApi'; -export * from './storageManagementApi'; \ No newline at end of file +export * from './storageManagementApi'; + +// 重新导出用户端人脸探索 API,避免与管理端冲突 +export { + getMyFaceClusters, + getMyPicturesByCluster, + updateMyCluster, + startMyFaceClustering, + mergeMyUserClusters, + removeFaceFromCluster as removeMyFaceFromCluster +} from './faceExploreApi'; \ No newline at end of file diff --git a/Web/src/pages/explore/Index.tsx b/Web/src/pages/explore/Index.tsx new file mode 100644 index 0000000..8bcb287 --- /dev/null +++ b/Web/src/pages/explore/Index.tsx @@ -0,0 +1,488 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Card, Button, Space, Modal, message, Typography, + Row, Col, Image, Form, Input, Avatar, + Tag, Tooltip, Spin, Empty, Statistic, + Select, Grid +} from 'antd'; +import { + UserOutlined, ReloadOutlined, PlayCircleOutlined, + EditOutlined, TeamOutlined, MergeCellsOutlined, + ExclamationCircleOutlined, EyeOutlined, + HeartOutlined, SearchOutlined +} from '@ant-design/icons'; +import { + getMyFaceClusters, updateMyCluster, startMyFaceClustering, mergeMyUserClusters, + getMyPicturesByCluster, + type FaceClusterResponse, type UpdateClusterRequest +} from '../../api'; +import type { PictureResponse } from '../../api/pictureApi'; + +const { Title, Text, Paragraph } = Typography; +const { confirm } = Modal; +const { useBreakpoint } = Grid; + +const FaceExplore: React.FC = () => { + const screens = useBreakpoint(); + const isMobile = !screens.md; + + 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] = useState(12); + + 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 fetchClusters = useCallback(async (page = currentPage) => { + setLoading(true); + try { + const response = await getMyFaceClusters(page, pageSize); + 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]); + + const fetchClusterPictures = useCallback(async (clusterId: number) => { + setPicturesLoading(true); + try { + const response = await getMyPicturesByCluster(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(); + }, [fetchClusters]); + + const handleLoadMore = () => { + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + fetchClusters(nextPage); + }; + + 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 updateMyCluster(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 mergeMyUserClusters(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: '这将分析您上传图片中的未分类人脸,帮助您找到相同的人物。可能需要一些时间,确定要开始吗?', + async onOk() { + setClusteringLoading(true); + try { + const response = await startMyFaceClustering(); + if (response.success) { + message.success('人脸聚类任务已开始,请稍后刷新查看结果'); + } else { + message.error(response.message || '启动聚类失败'); + } + } catch (error) { + message.error('启动聚类失败'); + } finally { + setClusteringLoading(false); + } + } + }); + }; + + const renderClusterCard = (cluster: FaceClusterResponse) => ( + + {cluster.thumbnailPath ? ( + {cluster.name} + ) : ( + } + style={{ backgroundColor: '#e6f7ff' }} + /> + )} + + } + actions={[ + + showPicturesModal(cluster)} /> + , + + showEditModal(cluster)} /> + , + + showMergeModal(cluster)} /> + + ]} + > + + {cluster.name} + + {cluster.faceCount} 张人脸 + + + } + description={ +
+ {cluster.personName && ( + + {cluster.personName} + + )} + {cluster.description && ( + + {cluster.description} + + )} + + 最后更新: {new Date(cluster.lastUpdatedAt).toLocaleDateString()} + +
+ } + /> +
+ ); + + return ( +
+ {/* 页面头部 */} +
+ + + + +
+ 人脸探索 + + 发现和管理您照片中的人物,为每个聚类命名和整理 + +
+
+ + + + + + + +
+
+ + {/* 统计信息 */} + + + + } + valueStyle={{ color: '#1890ff' }} + /> + + + + + c.personName).length} + prefix={} + valueStyle={{ color: '#52c41a' }} + /> + + + + + sum + c.faceCount, 0)} + prefix={} + valueStyle={{ color: '#722ed1' }} + /> + + + + + {/* 聚类网格 */} + + {clusters.length > 0 ? ( + <> + + {clusters.map(cluster => ( + + {renderClusterCard(cluster)} + + ))} + + + {/* 加载更多按钮 */} + {clusters.length < total && ( +
+ +
+ )} + + ) : ( + + 还没有发现人脸聚类 + + 点击"开始聚类分析"来分析您的照片并发现其中的人物 + +
+ } + image={Empty.PRESENTED_IMAGE_SIMPLE} + > + + + )} + + + {/* 编辑聚类模态框 */} + setIsEditModalVisible(false)} + confirmLoading={loading} + destroyOnClose + > +
+ + + + + + +
+
+ + {/* 合并聚类模态框 */} + setIsMergeModalVisible(false)} + confirmLoading={loading} + destroyOnClose + > +
+ +