mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 07:36:53 +08:00
feat: 添加裁剪人脸图片功能,更新人脸管理服务以支持裁剪路径
This commit is contained in:
@@ -15,6 +15,8 @@ public class Face : BaseModel
|
|||||||
[Range(0.0, 1.0)]
|
[Range(0.0, 1.0)]
|
||||||
public double FaceConfidence { get; set; }
|
public double FaceConfidence { get; set; }
|
||||||
|
|
||||||
|
public string? CroppedImagePath { get; set; }
|
||||||
|
|
||||||
public int PictureId { get; set; }
|
public int PictureId { get; set; }
|
||||||
|
|
||||||
[ForeignKey("PictureId")]
|
[ForeignKey("PictureId")]
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ using Foxel.Services.Storage;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||||
|
|
||||||
namespace Foxel.Services.Background.Processors
|
namespace Foxel.Services.Background.Processors
|
||||||
{
|
{
|
||||||
@@ -162,8 +165,15 @@ namespace Foxel.Services.Background.Processors
|
|||||||
// 保存人脸数据到数据库
|
// 保存人脸数据到数据库
|
||||||
if (faceRecognitionResult?.Result != null && faceRecognitionResult.Result.Any())
|
if (faceRecognitionResult?.Result != null && faceRecognitionResult.Result.Any())
|
||||||
{
|
{
|
||||||
|
// 确保人脸保存目录存在
|
||||||
|
var faceImagesDir = Path.Combine(Directory.GetCurrentDirectory(), "Uploads", "faces");
|
||||||
|
Directory.CreateDirectory(faceImagesDir);
|
||||||
|
|
||||||
foreach (var faceResult in faceRecognitionResult.Result)
|
foreach (var faceResult in faceRecognitionResult.Result)
|
||||||
{
|
{
|
||||||
|
// 裁剪人脸图片
|
||||||
|
var croppedImagePath = await CropAndSaveFaceImageAsync(tempImagePath, faceResult.FacialArea, faceImagesDir);
|
||||||
|
|
||||||
var face = new Face
|
var face = new Face
|
||||||
{
|
{
|
||||||
PictureId = pictureId,
|
PictureId = pictureId,
|
||||||
@@ -172,7 +182,8 @@ namespace Foxel.Services.Background.Processors
|
|||||||
Y = faceResult.FacialArea.Y,
|
Y = faceResult.FacialArea.Y,
|
||||||
W = faceResult.FacialArea.W,
|
W = faceResult.FacialArea.W,
|
||||||
H = faceResult.FacialArea.H,
|
H = faceResult.FacialArea.H,
|
||||||
FaceConfidence = faceResult.FaceConfidence
|
FaceConfidence = faceResult.FaceConfidence,
|
||||||
|
CroppedImagePath = croppedImagePath
|
||||||
};
|
};
|
||||||
|
|
||||||
dbContext.Faces.Add(face);
|
dbContext.Faces.Add(face);
|
||||||
@@ -282,5 +293,41 @@ namespace Foxel.Services.Background.Processors
|
|||||||
_logger.LogWarning("尝试在 FaceRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
_logger.LogWarning("尝试在 FaceRecognitionProcessor 中更新不存在的任务状态: TaskId={TaskId}", taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> CropAndSaveFaceImageAsync(string originalImagePath, FacialAreaResponse facialArea, string saveDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var originalImage = await Image.LoadAsync(originalImagePath);
|
||||||
|
|
||||||
|
// 确保裁剪区域在图片范围内
|
||||||
|
var cropX = Math.Max(0, facialArea.X);
|
||||||
|
var cropY = Math.Max(0, facialArea.Y);
|
||||||
|
var cropWidth = Math.Min(facialArea.W, originalImage.Width - cropX);
|
||||||
|
var cropHeight = Math.Min(facialArea.H, originalImage.Height - cropY);
|
||||||
|
|
||||||
|
if (cropWidth <= 0 || cropHeight <= 0)
|
||||||
|
{
|
||||||
|
throw new Exception("无效的人脸区域坐标");
|
||||||
|
}
|
||||||
|
|
||||||
|
var cropRect = new Rectangle(cropX, cropY, cropWidth, cropHeight);
|
||||||
|
|
||||||
|
// 生成唯一文件名
|
||||||
|
var fileName = $"face_{Guid.NewGuid()}.jpg";
|
||||||
|
var filePath = Path.Combine(saveDirectory, fileName);
|
||||||
|
|
||||||
|
// 使用 ImageSharp 裁剪并保存
|
||||||
|
using var croppedImage = originalImage.Clone(ctx => ctx.Crop(cropRect));
|
||||||
|
await croppedImage.SaveAsJpegAsync(filePath, new JpegEncoder { Quality = 90 });
|
||||||
|
|
||||||
|
return Path.Combine("Uploads", "faces", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "裁剪人脸图片失败");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ using Foxel.Models.Response.Picture;
|
|||||||
using Foxel.Services.Mapping;
|
using Foxel.Services.Mapping;
|
||||||
using Foxel.Api.Management;
|
using Foxel.Api.Management;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Foxel.Services.Configuration;
|
||||||
|
|
||||||
namespace Foxel.Services.Management;
|
namespace Foxel.Services.Management;
|
||||||
|
|
||||||
public class FaceManagementService(
|
public class FaceManagementService(
|
||||||
IDbContextFactory<MyDbContext> contextFactory,
|
IDbContextFactory<MyDbContext> contextFactory,
|
||||||
IMappingService mappingService,
|
IMappingService mappingService,
|
||||||
|
IConfigService configService,
|
||||||
ILogger<FaceManagementService> logger) : IFaceManagementService
|
ILogger<FaceManagementService> logger) : IFaceManagementService
|
||||||
{
|
{
|
||||||
public async Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20)
|
public async Task<PaginatedResult<FaceClusterResponse>> GetFaceClustersAsync(int page = 1, int pageSize = 20)
|
||||||
@@ -22,7 +24,7 @@ public class FaceManagementService(
|
|||||||
{
|
{
|
||||||
Cluster = c,
|
Cluster = c,
|
||||||
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id),
|
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id),
|
||||||
ThumbnailPath = dbContext.Faces
|
ThumbnailPath = configService["AppSettings:ServerUrl"] + dbContext.Faces
|
||||||
.Where(f => f.ClusterId == c.Id)
|
.Where(f => f.ClusterId == c.Id)
|
||||||
.Include(f => f.Picture)
|
.Include(f => f.Picture)
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
@@ -195,11 +197,10 @@ public class FaceManagementService(
|
|||||||
{
|
{
|
||||||
Cluster = c,
|
Cluster = c,
|
||||||
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id && f.Picture.UserId == userId),
|
FaceCount = dbContext.Faces.Count(f => f.ClusterId == c.Id && f.Picture.UserId == userId),
|
||||||
ThumbnailPath = dbContext.Faces
|
ThumbnailPath = configService["AppSettings:ServerUrl"]+ dbContext.Faces
|
||||||
.Where(f => f.ClusterId == c.Id && f.Picture.UserId == userId)
|
.Where(f => f.ClusterId == c.Id && f.Picture.UserId == userId && !string.IsNullOrEmpty(f.CroppedImagePath))
|
||||||
.Include(f => f.Picture)
|
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
.Select(f => f.Picture.ThumbnailPath)
|
.Select(f => f.CroppedImagePath)
|
||||||
.FirstOrDefault()
|
.FirstOrDefault()
|
||||||
})
|
})
|
||||||
.Where(x => x.FaceCount > 0)
|
.Where(x => x.FaceCount > 0)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Card, Button, Space, Modal, message, Typography,
|
Card, Button, Space, Modal, message, Typography,
|
||||||
Row, Col, Image, Form, Input, Avatar,
|
Row, Col, Image, Form, Input, Avatar,
|
||||||
Tag, Tooltip, Spin, Empty, Statistic,
|
Tag, Tooltip, Spin, Empty, Statistic,
|
||||||
Select, Grid
|
Select, Grid
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -40,7 +40,7 @@ const FaceExplore: React.FC = () => {
|
|||||||
const [targetCluster, setTargetCluster] = useState<FaceClusterResponse | null>(null);
|
const [targetCluster, setTargetCluster] = useState<FaceClusterResponse | null>(null);
|
||||||
const [clusterPictures, setClusterPictures] = useState<PictureResponse[]>([]);
|
const [clusterPictures, setClusterPictures] = useState<PictureResponse[]>([]);
|
||||||
const [picturesLoading, setPicturesLoading] = useState(false);
|
const [picturesLoading, setPicturesLoading] = useState(false);
|
||||||
|
|
||||||
const [editForm] = Form.useForm<UpdateClusterRequest>();
|
const [editForm] = Form.useForm<UpdateClusterRequest>();
|
||||||
const [mergeForm] = Form.useForm();
|
const [mergeForm] = Form.useForm();
|
||||||
|
|
||||||
@@ -118,12 +118,12 @@ const FaceExplore: React.FC = () => {
|
|||||||
|
|
||||||
const handleEditOk = async () => {
|
const handleEditOk = async () => {
|
||||||
if (!editingCluster) return;
|
if (!editingCluster) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const values = await editForm.validateFields();
|
const values = await editForm.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await updateMyCluster(editingCluster.id, values);
|
const response = await updateMyCluster(editingCluster.id, values);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
message.success('更新聚类信息成功');
|
message.success('更新聚类信息成功');
|
||||||
setIsEditModalVisible(false);
|
setIsEditModalVisible(false);
|
||||||
@@ -140,12 +140,12 @@ const FaceExplore: React.FC = () => {
|
|||||||
|
|
||||||
const handleMergeOk = async () => {
|
const handleMergeOk = async () => {
|
||||||
if (!targetCluster) return;
|
if (!targetCluster) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const values = await mergeForm.validateFields();
|
const values = await mergeForm.validateFields();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await mergeMyUserClusters(targetCluster.id, values.sourceClusterId);
|
const response = await mergeMyUserClusters(targetCluster.id, values.sourceClusterId);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
message.success('合并聚类成功');
|
message.success('合并聚类成功');
|
||||||
setIsMergeModalVisible(false);
|
setIsMergeModalVisible(false);
|
||||||
@@ -189,31 +189,33 @@ const FaceExplore: React.FC = () => {
|
|||||||
hoverable
|
hoverable
|
||||||
className="cluster-card"
|
className="cluster-card"
|
||||||
cover={
|
cover={
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 180,
|
height: 180,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: '#f8f9fa',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden',
|
||||||
|
padding: 20
|
||||||
}}>
|
}}>
|
||||||
{cluster.thumbnailPath ? (
|
{cluster.thumbnailPath ? (
|
||||||
<Image
|
<Avatar
|
||||||
|
size={120}
|
||||||
src={cluster.thumbnailPath}
|
src={cluster.thumbnailPath}
|
||||||
alt={cluster.name}
|
style={{
|
||||||
width="100%"
|
border: '3px solid #fff',
|
||||||
height={180}
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||||
style={{
|
|
||||||
objectFit: 'cover',
|
|
||||||
display: 'block'
|
|
||||||
}}
|
}}
|
||||||
preview={false}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Avatar
|
<Avatar
|
||||||
size={80}
|
size={120}
|
||||||
icon={<UserOutlined />}
|
icon={<UserOutlined />}
|
||||||
style={{ backgroundColor: '#e6f7ff' }}
|
style={{
|
||||||
|
backgroundColor: '#e6f7ff',
|
||||||
|
border: '3px solid #fff',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,8 +249,8 @@ const FaceExplore: React.FC = () => {
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
)}
|
)}
|
||||||
{cluster.description && (
|
{cluster.description && (
|
||||||
<Paragraph
|
<Paragraph
|
||||||
style={{ margin: '8px 0 0 0' }}
|
style={{ margin: '8px 0 0 0' }}
|
||||||
ellipsis={{ rows: 2, tooltip: cluster.description }}
|
ellipsis={{ rows: 2, tooltip: cluster.description }}
|
||||||
>
|
>
|
||||||
{cluster.description}
|
{cluster.description}
|
||||||
@@ -281,16 +283,16 @@ const FaceExplore: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
onClick={handleStartClustering}
|
onClick={handleStartClustering}
|
||||||
loading={clusteringLoading}
|
loading={clusteringLoading}
|
||||||
>
|
>
|
||||||
开始聚类分析
|
开始聚类分析
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={() => fetchClusters()}
|
onClick={() => fetchClusters()}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
@@ -305,9 +307,9 @@ const FaceExplore: React.FC = () => {
|
|||||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={12} sm={8} md={6}>
|
<Col xs={12} sm={8} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="人脸聚类"
|
title="人脸聚类"
|
||||||
value={total}
|
value={total}
|
||||||
prefix={<TeamOutlined />}
|
prefix={<TeamOutlined />}
|
||||||
valueStyle={{ color: '#1890ff' }}
|
valueStyle={{ color: '#1890ff' }}
|
||||||
/>
|
/>
|
||||||
@@ -315,9 +317,9 @@ const FaceExplore: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={6}>
|
<Col xs={12} sm={8} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="已命名聚类"
|
title="已命名聚类"
|
||||||
value={clusters.filter(c => c.personName).length}
|
value={clusters.filter(c => c.personName).length}
|
||||||
prefix={<HeartOutlined />}
|
prefix={<HeartOutlined />}
|
||||||
valueStyle={{ color: '#52c41a' }}
|
valueStyle={{ color: '#52c41a' }}
|
||||||
/>
|
/>
|
||||||
@@ -325,9 +327,9 @@ const FaceExplore: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={6}>
|
<Col xs={12} sm={8} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="总人脸数"
|
title="总人脸数"
|
||||||
value={clusters.reduce((sum, c) => sum + c.faceCount, 0)}
|
value={clusters.reduce((sum, c) => sum + c.faceCount, 0)}
|
||||||
prefix={<UserOutlined />}
|
prefix={<UserOutlined />}
|
||||||
valueStyle={{ color: '#722ed1' }}
|
valueStyle={{ color: '#722ed1' }}
|
||||||
/>
|
/>
|
||||||
@@ -346,7 +348,7 @@ const FaceExplore: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 加载更多按钮 */}
|
{/* 加载更多按钮 */}
|
||||||
{clusters.length < total && (
|
{clusters.length < total && (
|
||||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||||
@@ -357,7 +359,7 @@ const FaceExplore: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Empty
|
<Empty
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<Paragraph>还没有发现人脸聚类</Paragraph>
|
<Paragraph>还没有发现人脸聚类</Paragraph>
|
||||||
@@ -368,9 +370,9 @@ const FaceExplore: React.FC = () => {
|
|||||||
}
|
}
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlayCircleOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
onClick={handleStartClustering}
|
onClick={handleStartClustering}
|
||||||
loading={clusteringLoading}
|
loading={clusteringLoading}
|
||||||
>
|
>
|
||||||
@@ -409,8 +411,8 @@ const FaceExplore: React.FC = () => {
|
|||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Form form={mergeForm} layout="vertical">
|
<Form form={mergeForm} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="sourceClusterId"
|
name="sourceClusterId"
|
||||||
label="选择要合并的聚类"
|
label="选择要合并的聚类"
|
||||||
rules={[{ required: true, message: '请选择要合并的聚类' }]}
|
rules={[{ required: true, message: '请选择要合并的聚类' }]}
|
||||||
>
|
>
|
||||||
@@ -446,40 +448,59 @@ const FaceExplore: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Spin spinning={picturesLoading}>
|
<Spin spinning={picturesLoading}>
|
||||||
{clusterPictures.length > 0 ? (
|
{clusterPictures.length > 0 ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
gap: 12,
|
gap: 16,
|
||||||
maxHeight: 500,
|
maxHeight: 500,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '8px'
|
padding: '8px'
|
||||||
}}>
|
}}>
|
||||||
{clusterPictures.map(picture => (
|
{clusterPictures.map(picture => (
|
||||||
<div key={picture.id} style={{ textAlign: 'center' }}>
|
<div key={picture.id} style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Avatar
|
||||||
width={100}
|
size={100}
|
||||||
height={100}
|
|
||||||
src={picture.thumbnailPath || picture.path}
|
src={picture.thumbnailPath || picture.path}
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'cover',
|
border: '2px solid #f0f0f0',
|
||||||
borderRadius: 6,
|
cursor: 'pointer',
|
||||||
border: '1px solid #f0f0f0'
|
transition: 'all 0.3s ease',
|
||||||
}}
|
marginBottom: 8
|
||||||
preview={{
|
|
||||||
src: picture.path
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
marginTop: 4,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
maxWidth: '100px'
|
maxWidth: '120px'
|
||||||
}}>
|
}}>
|
||||||
{picture.name || `图片${picture.id}`}
|
{picture.name || `图片${picture.id}`}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Image
|
||||||
|
width={0}
|
||||||
|
height={0}
|
||||||
|
src={picture.path}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
preview={{
|
||||||
|
src: picture.path,
|
||||||
|
mask: (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}>
|
||||||
|
查看大图
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user