feat: 添加人脸探索功能,支持用户查看和管理人脸聚类

This commit is contained in:
ShiYu
2025-06-17 00:19:28 +08:00
parent 429ce92cdb
commit aa63139ac1
6 changed files with 846 additions and 39 deletions

View File

@@ -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<any> {
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<any> {
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<BaseResult<FaceClusterResponse>> {
return fetchApi<FaceClusterResponse>(`/face/clusters/${clusterId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// 开始当前用户的人脸聚类
export async function startMyFaceClustering(): Promise<BaseResult<boolean>> {
return fetchApi<boolean>('/face/clusters/analyze', {
method: 'POST',
});
}
// 合并当前用户的聚类
export async function mergeMyUserClusters(
targetClusterId: number,
sourceClusterId: number
): Promise<BaseResult<boolean>> {
const data = { sourceClusterId };
return fetchApi<boolean>(`/face/clusters/${targetClusterId}/merge`, {
method: 'POST',
body: JSON.stringify(data),
});
}
// 从聚类中移除人脸
export async function removeFaceFromCluster(faceId: number): Promise<BaseResult<boolean>> {
return fetchApi<boolean>(`/face/faces/${faceId}/cluster`, {
method: 'DELETE',
});
}

View File

@@ -11,4 +11,14 @@ export * from './pictureManagementApi';
export * from './tagApi';
export * from './userManagementApi';
export * from './vectorDbApi';
export * from './storageManagementApi';
export * from './storageManagementApi';
// 重新导出用户端人脸探索 API避免与管理端冲突
export {
getMyFaceClusters,
getMyPicturesByCluster,
updateMyCluster,
startMyFaceClustering,
mergeMyUserClusters,
removeFaceFromCluster as removeMyFaceFromCluster
} from './faceExploreApi';

View File

@@ -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<FaceClusterResponse[]>([]);
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<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 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: <ExclamationCircleOutlined />,
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) => (
<Card
key={cluster.id}
hoverable
className="cluster-card"
cover={
<div style={{
height: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f8f9fa'
}}>
{cluster.thumbnailPath ? (
<Image
src={cluster.thumbnailPath}
alt={cluster.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'cover'
}}
preview={false}
/>
) : (
<Avatar
size={80}
icon={<UserOutlined />}
style={{ backgroundColor: '#e6f7ff' }}
/>
)}
</div>
}
actions={[
<Tooltip title="查看图片" key="view">
<EyeOutlined onClick={() => showPicturesModal(cluster)} />
</Tooltip>,
<Tooltip title="编辑信息" key="edit">
<EditOutlined onClick={() => showEditModal(cluster)} />
</Tooltip>,
<Tooltip title="合并聚类" key="merge">
<MergeCellsOutlined onClick={() => showMergeModal(cluster)} />
</Tooltip>
]}
>
<Card.Meta
title={
<div>
<Text strong>{cluster.name}</Text>
<Tag color="blue" style={{ marginLeft: 8 }}>
{cluster.faceCount}
</Tag>
</div>
}
description={
<div>
{cluster.personName && (
<Paragraph style={{ margin: 0, color: '#52c41a' }}>
<HeartOutlined /> {cluster.personName}
</Paragraph>
)}
{cluster.description && (
<Paragraph
style={{ margin: '8px 0 0 0' }}
ellipsis={{ rows: 2, tooltip: cluster.description }}
>
{cluster.description}
</Paragraph>
)}
<Text type="secondary" style={{ fontSize: '12px' }}>
: {new Date(cluster.lastUpdatedAt).toLocaleDateString()}
</Text>
</div>
}
/>
</Card>
);
return (
<div className="face-explore" style={{ padding: isMobile ? 16 : 24 }}>
{/* 页面头部 */}
<div style={{ marginBottom: 24 }}>
<Row align="middle" justify="space-between" gutter={[16, 16]}>
<Col>
<Space align="center">
<SearchOutlined style={{ fontSize: 28, color: '#1890ff' }} />
<div>
<Title level={2} style={{ margin: 0 }}></Title>
<Text type="secondary">
</Text>
</div>
</Space>
</Col>
<Col>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleStartClustering}
loading={clusteringLoading}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchClusters()}
loading={loading}
>
</Button>
</Space>
</Col>
</Row>
</div>
{/* 统计信息 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={12} sm={8} md={6}>
<Card>
<Statistic
title="人脸聚类"
value={total}
prefix={<TeamOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={6}>
<Card>
<Statistic
title="已命名聚类"
value={clusters.filter(c => c.personName).length}
prefix={<HeartOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={6}>
<Card>
<Statistic
title="总人脸数"
value={clusters.reduce((sum, c) => sum + c.faceCount, 0)}
prefix={<UserOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
</Row>
{/* 聚类网格 */}
<Spin spinning={loading}>
{clusters.length > 0 ? (
<>
<Row gutter={[16, 16]}>
{clusters.map(cluster => (
<Col xs={24} sm={12} md={8} lg={6} xl={4} key={cluster.id}>
{renderClusterCard(cluster)}
</Col>
))}
</Row>
{/* 加载更多按钮 */}
{clusters.length < total && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button size="large" onClick={handleLoadMore} loading={loading}>
({clusters.length} / {total})
</Button>
</div>
)}
</>
) : (
<Empty
description={
<div>
<Paragraph></Paragraph>
<Paragraph type="secondary">
"开始聚类分析"
</Paragraph>
</div>
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleStartClustering}
loading={clusteringLoading}
>
</Button>
</Empty>
)}
</Spin>
{/* 编辑聚类模态框 */}
<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?.personName || 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="请选择要合并的聚类"
showSearch
optionFilterProp="children"
filterOption={(input: string, option: any) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={clusters
.filter(c => c.id !== targetCluster?.id)
.map(c => ({
value: c.id,
label: `${c.personName || c.name} (${c.faceCount} 个人脸)`,
}))}
/>
</Form.Item>
<Text type="secondary">
</Text>
</Form>
</Modal>
{/* 查看聚类图片模态框 */}
<Modal
title={`${editingCluster?.personName || editingCluster?.name || ''} 的照片`}
open={isPictureModalVisible}
onCancel={() => setIsPictureModalVisible(false)}
footer={null}
width={800}
destroyOnClose
>
<Spin spinning={picturesLoading}>
{clusterPictures.length > 0 ? (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 16,
maxHeight: 500,
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 FaceExplore;

View File

@@ -28,6 +28,7 @@ import AdminLogManagement from '../pages/admin/log/Index';
import StorageManagementPage from '../pages/admin/storage/StorageManagement';
import AlbumManagement from '../pages/admin/album/Index';
import FaceManagement from '../pages/admin/face/Index';
import FaceExplore from '../pages/explore/Index';
export interface RouteConfig {
path: string;
@@ -59,6 +60,17 @@ const routes: RouteConfig[] = [
title: '所有图片'
}
},
{
path: 'explore',
key: 'explore',
icon: <CompassOutlined />,
label: '探索',
element: <FaceExplore />,
area: 'main',
breadcrumb: {
title: '探索',
}
},
{
path: 'albums',
key: 'albums',
@@ -189,7 +201,7 @@ const routes: RouteConfig[] = [
title: '相册管理'
}
},
{
{
path: 'faces-admin',
key: 'admin-face',
icon: <FolderOutlined />,