mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-12 02:20:28 +08:00
refactor(imageGrid): add context menu and image card components for enhanced image interaction
This commit is contained in:
@@ -1,781 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Typography, Empty, message, Pagination, Modal } from 'antd';
|
||||
import {
|
||||
HeartOutlined, HeartFilled, LockOutlined, GlobalOutlined, TeamOutlined,
|
||||
DeleteOutlined, EditOutlined, DownloadOutlined, ShareAltOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { PictureResponse } from '../../api';
|
||||
import { favoritePicture, unfavoritePicture, getPictures, deleteMultiplePictures } from '../../api';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import ShareImageDialog from './ShareImageDialog';
|
||||
import EditImageDialog from './EditImageDialog';
|
||||
import './ImageGrid.css';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const permissionTypeMap: Record<number, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
0: { label: '公开', icon: <GlobalOutlined />, color: '#52c41a' },
|
||||
1: { label: '好友可见', icon: <TeamOutlined />, color: '#1890ff' },
|
||||
2: { label: '私人', icon: <LockOutlined />, color: '#ff4d4f' }
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch (error) {
|
||||
console.error('日期格式化错误:', error);
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
// 简化API参数接口
|
||||
interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
albumId?: number;
|
||||
excludeAlbumId?: number;
|
||||
onlyFavorites?: boolean;
|
||||
tags?: string;
|
||||
searchQuery?: string;
|
||||
sortBy?: string;
|
||||
includeAllPublic?: boolean;
|
||||
useVectorSearch?: boolean;
|
||||
similarityThreshold?: number;
|
||||
}
|
||||
|
||||
// 右键菜单类型接口
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
imageId?: number;
|
||||
image?: PictureResponse;
|
||||
}
|
||||
|
||||
// 简化Props接口,使用默认值
|
||||
interface ImageGridProps {
|
||||
// 核心功能属性
|
||||
onToggleFavorite?: (image: PictureResponse) => void;
|
||||
showFavoriteCount?: boolean;
|
||||
emptyText?: string;
|
||||
showPagination?: boolean;
|
||||
|
||||
// 数据源属性集合
|
||||
dataSource?: PictureResponse[];
|
||||
totalImages?: number;
|
||||
loading?: boolean;
|
||||
|
||||
// 合并查询相关参数
|
||||
queryParams?: {
|
||||
albumId?: number;
|
||||
excludeAlbumId?: number;
|
||||
onlyFavorites?: boolean;
|
||||
tags?: string[];
|
||||
searchQuery?: string;
|
||||
sortBy?: string;
|
||||
includeAllPublic?: boolean;
|
||||
useVectorSearch?: boolean;
|
||||
similarityThreshold?: number;
|
||||
_searchId?: number; // 添加搜索ID属性
|
||||
};
|
||||
|
||||
// 分页相关属性
|
||||
pageSize?: number;
|
||||
defaultPage?: number;
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
onImagesLoaded?: (images: PictureResponse[], totalCount: number) => void;
|
||||
|
||||
// 选择模式相关属性
|
||||
selectedIds?: number[];
|
||||
selectable?: boolean;
|
||||
onSelectionChange?: (selectedIds: number[]) => void;
|
||||
|
||||
// 新增操作回调
|
||||
onDelete?: (image: PictureResponse) => void;
|
||||
onEdit?: (image: PictureResponse) => void;
|
||||
onDownload?: (image: PictureResponse) => void;
|
||||
onShare?: (image: PictureResponse) => void;
|
||||
}
|
||||
|
||||
|
||||
const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
// 使用解构赋值时直接设置默认值
|
||||
onToggleFavorite,
|
||||
showFavoriteCount = false,
|
||||
emptyText = "暂无图片",
|
||||
showPagination = true,
|
||||
|
||||
dataSource,
|
||||
totalImages: externalTotalImages,
|
||||
loading: externalLoading,
|
||||
|
||||
queryParams = {},
|
||||
|
||||
pageSize: externalPageSize = 20,
|
||||
defaultPage = 1,
|
||||
onPageChange,
|
||||
onImagesLoaded,
|
||||
|
||||
selectedIds = [],
|
||||
selectable = false,
|
||||
onSelectionChange,
|
||||
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDownload,
|
||||
onShare,
|
||||
}) => {
|
||||
// 获取当前登录用户信息
|
||||
const { user, hasRole } = useAuth();
|
||||
|
||||
// 使用更紧凑的状态定义
|
||||
const [images, setImages] = useState<PictureResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(defaultPage);
|
||||
const [pageSize, setPageSize] = useState(externalPageSize);
|
||||
const [totalImages, setTotalImages] = useState(0);
|
||||
const [viewerState, setViewerState] = useState({ visible: false, index: 0 });
|
||||
const [shareDialogState, setShareDialogState] = useState<{
|
||||
visible: boolean;
|
||||
image: PictureResponse | null;
|
||||
}>({
|
||||
visible: false,
|
||||
image: null
|
||||
});
|
||||
|
||||
// 添加右键菜单状态
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
// 添加编辑对话框状态
|
||||
const [editDialogState, setEditDialogState] = useState<{
|
||||
visible: boolean;
|
||||
image: PictureResponse | null;
|
||||
}>({
|
||||
visible: false,
|
||||
image: null
|
||||
});
|
||||
|
||||
|
||||
// 简化标志变量
|
||||
const isUsingExternalData = !!dataSource;
|
||||
const isLoading = isUsingExternalData ? externalLoading : loading;
|
||||
|
||||
// 请求状态追踪
|
||||
const requestState = useRef({
|
||||
inProgress: false,
|
||||
lastParams: '',
|
||||
noResultsFor: ''
|
||||
});
|
||||
|
||||
const favoriteOperationsInProgress = useRef<Map<number, boolean>>(new Map());
|
||||
|
||||
const buildQueryParams = useCallback((): PaginationParams => ({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...queryParams,
|
||||
searchQuery: queryParams.searchQuery,
|
||||
tags: Array.isArray(queryParams.tags) ? queryParams.tags.join(',') : undefined,
|
||||
useVectorSearch: queryParams.useVectorSearch,
|
||||
similarityThreshold: queryParams.similarityThreshold
|
||||
}), [currentPage, pageSize, queryParams]);
|
||||
|
||||
const loadImages = useCallback(async () => {
|
||||
if (isUsingExternalData || requestState.current.inProgress) return;
|
||||
|
||||
const params = buildQueryParams();
|
||||
const paramsString = JSON.stringify(params);
|
||||
|
||||
if (requestState.current.noResultsFor === paramsString && images.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestState.current.noResultsFor === paramsString) {
|
||||
requestState.current.noResultsFor = '';
|
||||
}
|
||||
|
||||
if (requestState.current.lastParams === paramsString) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.current = {
|
||||
inProgress: true,
|
||||
lastParams: paramsString,
|
||||
noResultsFor: requestState.current.noResultsFor
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await getPictures(params);
|
||||
|
||||
requestState.current.lastParams = paramsString;
|
||||
|
||||
if (result.success) {
|
||||
setImages(result.data || []);
|
||||
setTotalImages(result.totalCount || 0);
|
||||
onImagesLoaded?.(result.data || [], result.totalCount || 0);
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
requestState.current.noResultsFor = paramsString;
|
||||
}
|
||||
} else {
|
||||
message.error(result.message || '获取图片失败');
|
||||
requestState.current.noResultsFor = paramsString;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载图片列表出错');
|
||||
requestState.current.noResultsFor = paramsString;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
requestState.current.inProgress = false;
|
||||
}
|
||||
}, [buildQueryParams, isUsingExternalData, onImagesLoaded]);
|
||||
|
||||
// 简化useEffect
|
||||
useEffect(() => {
|
||||
if (!isUsingExternalData) loadImages();
|
||||
}, [loadImages, isUsingExternalData]);
|
||||
|
||||
// 同步外部数据
|
||||
useEffect(() => {
|
||||
if (isUsingExternalData && dataSource) setImages(dataSource);
|
||||
}, [dataSource, isUsingExternalData]);
|
||||
|
||||
// 优化收藏/取消收藏逻辑,添加防抖功能
|
||||
const handleToggleFavorite = async (image: PictureResponse) => {
|
||||
const { id, isFavorited } = image;
|
||||
|
||||
// 检查此图片是否有收藏操作正在进行中
|
||||
if (favoriteOperationsInProgress.current.get(id)) {
|
||||
// 如果有操作正在进行中,直接返回,不执行新的操作
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置操作锁定状态
|
||||
favoriteOperationsInProgress.current.set(id, true);
|
||||
|
||||
const api = isFavorited ? unfavoritePicture : favoritePicture;
|
||||
const result = await api(id);
|
||||
|
||||
if (result.success) {
|
||||
message.success(isFavorited ? '已取消收藏' : '已添加到收藏');
|
||||
|
||||
// 更新本地状态
|
||||
setImages(prevImages =>
|
||||
prevImages.map(img =>
|
||||
img.id === id ? {
|
||||
...img,
|
||||
isFavorited: !isFavorited,
|
||||
favoriteCount: isFavorited
|
||||
? Math.max(0, (img.favoriteCount || 0) - 1)
|
||||
: (img.favoriteCount || 0) + 1
|
||||
} : img
|
||||
)
|
||||
);
|
||||
|
||||
onToggleFavorite?.(image);
|
||||
} else {
|
||||
message.error(result.message || (isFavorited ? '取消收藏失败' : '收藏失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败,请重试');
|
||||
} finally {
|
||||
// 操作完成后,延迟300ms释放锁定状态,进一步防止快速重复点击
|
||||
setTimeout(() => {
|
||||
favoriteOperationsInProgress.current.delete(id);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = (page: number, size: number) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) setPageSize(size);
|
||||
onPageChange?.(page, size);
|
||||
};
|
||||
|
||||
// 优化图片点击处理逻辑
|
||||
const handleImageClick = (image: PictureResponse, index: number) => {
|
||||
if (selectable && onSelectionChange) {
|
||||
const isSelected = selectedIds.includes(image.id);
|
||||
const newSelectedIds = isSelected
|
||||
? selectedIds.filter(id => id !== image.id)
|
||||
: [...selectedIds, image.id];
|
||||
onSelectionChange(newSelectedIds);
|
||||
} else {
|
||||
setViewerState({ visible: true, index });
|
||||
}
|
||||
};
|
||||
|
||||
// 处理右键菜单
|
||||
const handleContextMenu = (e: React.MouseEvent, image: PictureResponse) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
imageId: image.id,
|
||||
image
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu(prev => ({
|
||||
...prev,
|
||||
visible: false
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = () => {
|
||||
if (contextMenu.visible) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
};
|
||||
}, [contextMenu.visible]);
|
||||
|
||||
// 处理图片分享
|
||||
const handleShareImage = (image: PictureResponse) => {
|
||||
setShareDialogState({
|
||||
visible: true,
|
||||
image
|
||||
});
|
||||
};
|
||||
|
||||
// 关闭分享对话框
|
||||
const handleCloseShareDialog = () => {
|
||||
setShareDialogState({
|
||||
...shareDialogState,
|
||||
visible: false
|
||||
});
|
||||
};
|
||||
|
||||
// 处理编辑图片
|
||||
const handleEditImage = (image: PictureResponse) => {
|
||||
setEditDialogState({
|
||||
visible: true,
|
||||
image
|
||||
});
|
||||
};
|
||||
|
||||
// 关闭编辑对话框
|
||||
const handleCloseEditDialog = () => {
|
||||
setEditDialogState({
|
||||
...editDialogState,
|
||||
visible: false
|
||||
});
|
||||
};
|
||||
|
||||
// 处理图片更新成功
|
||||
const handleImageUpdateSuccess = (updatedImage: PictureResponse) => {
|
||||
setImages(prevImages =>
|
||||
prevImages.map(img => {
|
||||
if (img.id === updatedImage.id) {
|
||||
return {
|
||||
...img,
|
||||
...updatedImage,
|
||||
userId: img.userId,
|
||||
permission: updatedImage.permission ?? img.permission
|
||||
};
|
||||
}
|
||||
return img;
|
||||
})
|
||||
);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// 修改handleMenuAction中的编辑处理
|
||||
const handleMenuAction = (action: string) => {
|
||||
if (!contextMenu.image) return;
|
||||
|
||||
switch (action) {
|
||||
case 'favorite':
|
||||
handleToggleFavorite(contextMenu.image);
|
||||
break;
|
||||
case 'delete':
|
||||
handleDeleteImage(contextMenu.image);
|
||||
break;
|
||||
case 'edit':
|
||||
handleEditImage(contextMenu.image);
|
||||
break;
|
||||
case 'download':
|
||||
onDownload?.(contextMenu.image);
|
||||
break;
|
||||
case 'share':
|
||||
handleShareImage(contextMenu.image);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// 判断用户是否有权限编辑或删除图片
|
||||
const canEditImage = (image: PictureResponse): boolean => {
|
||||
if (user && hasRole('Administrator')) {
|
||||
return true;
|
||||
}
|
||||
return !!user && !!image.userId && user.id === image.userId;
|
||||
};
|
||||
|
||||
// 计算图片宽度的函数 - 修改为计算最小宽度
|
||||
const calculateImageMinWidth = (image: PictureResponse): number => {
|
||||
const fixedHeight = 200; // 固定高度
|
||||
const defaultMinWidth = 180; // 默认最小宽度
|
||||
|
||||
if (image.exifInfo?.width && image.exifInfo?.height) {
|
||||
const aspectRatio = image.exifInfo.width / image.exifInfo.height;
|
||||
const calculatedWidth = Math.round(fixedHeight * aspectRatio);
|
||||
// 确保最小宽度不小于180px,最大不超过400px
|
||||
return Math.max(180, Math.min(400, calculatedWidth));
|
||||
}
|
||||
|
||||
return defaultMinWidth;
|
||||
};
|
||||
|
||||
// 优化渲染内容函数
|
||||
const renderContent = () => {
|
||||
// 渲染加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="image-grid">
|
||||
{Array.from({ length: pageSize }).map((_, index) => (
|
||||
<div
|
||||
key={`loading-${index}`}
|
||||
className="custom-card image-loading-effect"
|
||||
style={{ minWidth: 180 }}
|
||||
>
|
||||
<div className="custom-card-cover" style={{ background: '#f5f5f5' }}>
|
||||
{/* 简单的加载状态 */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染空状态
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description={emptyText}
|
||||
style={{ margin: '80px 0' }}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染图片网格
|
||||
return (
|
||||
<div className="image-grid">
|
||||
{images.map((image, index) => {
|
||||
const isOwner = canEditImage(image);
|
||||
const imageMinWidth = calculateImageMinWidth(image);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className={`custom-card ${selectedIds.includes(image.id) ? 'custom-card-selected' : ''} ${selectable ? 'custom-card-selectable-mode' : ''}`}
|
||||
style={{
|
||||
minWidth: imageMinWidth,
|
||||
flexBasis: imageMinWidth // 设置flex基准值
|
||||
}}
|
||||
onClick={() => handleImageClick(image, index)}
|
||||
onContextMenu={(e) => handleContextMenu(e, image)}
|
||||
>
|
||||
<div className="custom-card-cover">
|
||||
<img
|
||||
alt={image.name}
|
||||
src={image.thumbnailPath || image.path}
|
||||
className="custom-card-thumbnail"
|
||||
/>
|
||||
|
||||
{!selectable && (
|
||||
<>
|
||||
{/* 顶部指示器 - 悬停时显示 */}
|
||||
<div className="custom-card-indicators">
|
||||
<div className="custom-card-permission" style={{
|
||||
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
|
||||
}}>
|
||||
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
|
||||
</div>
|
||||
|
||||
<div className="custom-card-metadata">
|
||||
{image.exifInfo && image.exifInfo.width && image.exifInfo.height
|
||||
? `${Math.round(image.exifInfo.width * image.exifInfo.height / 1000000)}MP`
|
||||
: 'N/A'}
|
||||
{' | '}
|
||||
{formatDate(
|
||||
typeof image.takenAt === 'string'
|
||||
? image.takenAt
|
||||
: image.takenAt
|
||||
? image.takenAt.toISOString()
|
||||
: typeof image.createdAt === 'string'
|
||||
? image.createdAt
|
||||
: image.createdAt.toISOString()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 悬停时显示的信息覆盖层 */}
|
||||
<div className="custom-card-overlay">
|
||||
<div className="custom-card-info">
|
||||
<div className="custom-card-title">{image.name}</div>
|
||||
|
||||
{image.tags && image.tags.length > 0 && (
|
||||
<div className="custom-card-tags-container">
|
||||
{image.tags.slice(0, 3).map((tag, tagIndex) => (
|
||||
<Text key={`${image.id}-${tag}-${tagIndex}`} className="image-tag">#{tag}</Text>
|
||||
))}
|
||||
{image.tags.length > 3 && (
|
||||
<Text className="image-tag">+{image.tags.length - 3}</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="custom-card-actions">
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(image);
|
||||
}}
|
||||
>
|
||||
{image.isFavorited ? (
|
||||
<HeartFilled style={{ fontSize: 14, color: '#ff4d4f' }} />
|
||||
) : (
|
||||
<HeartOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onEdit) {
|
||||
onEdit(image);
|
||||
} else {
|
||||
handleEditImage(image);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EditOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare?.(image);
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload?.(image);
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染右键菜单
|
||||
const renderContextMenu = () => {
|
||||
if (!contextMenu.visible) return null;
|
||||
|
||||
const menuStyle = {
|
||||
position: 'fixed' as const,
|
||||
top: contextMenu.y,
|
||||
left: contextMenu.x,
|
||||
};
|
||||
|
||||
const currentImage = contextMenu.image;
|
||||
if (!currentImage) return null;
|
||||
|
||||
const isFavorited = currentImage.isFavorited;
|
||||
const isOwner = canEditImage(currentImage);
|
||||
|
||||
return (
|
||||
<div className="context-menu" style={menuStyle}>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleMenuAction('favorite')}
|
||||
>
|
||||
{isFavorited ? (
|
||||
<><HeartFilled style={{ color: '#ff4d4f' }} /> 取消收藏</>
|
||||
) : (
|
||||
<><HeartOutlined /> 收藏</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleMenuAction('download')}
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleMenuAction('edit')}
|
||||
>
|
||||
<EditOutlined /> 编辑
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleMenuAction('share')}
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ color: '#ff4d4f' }}
|
||||
onClick={() => handleMenuAction('delete')}
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理删除图片
|
||||
const handleDeleteImage = async (image: PictureResponse) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除图片 "${image.name}" 吗?此操作不可恢复。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const result = await deleteMultiplePictures( [image.id] );
|
||||
|
||||
if (result.success) {
|
||||
message.success('图片已成功删除');
|
||||
|
||||
// 更新本地图片列表,移除被删除的图片
|
||||
setImages(prevImages =>
|
||||
prevImages.filter(img => img.id !== image.id)
|
||||
);
|
||||
|
||||
onDelete?.(image);
|
||||
|
||||
if (images.length === 1 && currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
} else {
|
||||
message.error(result.message || '删除图片失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除图片失败,请重试');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 简化组件返回结构
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{renderContextMenu()}
|
||||
|
||||
{showPagination && images.length > 0 && (
|
||||
<div className="image-grid-pagination">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={isUsingExternalData ? (externalTotalImages || 0) : totalImages}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
locale={{
|
||||
// 添加完整的中文本地化配置
|
||||
items_per_page: '条/页',
|
||||
jump_to: '跳至',
|
||||
jump_to_confirm: '确定',
|
||||
page: '页',
|
||||
prev_page: '上一页',
|
||||
next_page: '下一页',
|
||||
prev_5: '向前 5 页',
|
||||
next_5: '向后 5 页',
|
||||
prev_3: '向前 3 页',
|
||||
next_3: '向后 3 页'
|
||||
}}
|
||||
pageSizeOptions={['8', '16', '32', '64']}
|
||||
showTotal={(total) => `共 ${total} 张图片`}
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImageViewer
|
||||
visible={viewerState.visible}
|
||||
onClose={() => setViewerState({ ...viewerState, visible: false })}
|
||||
images={images}
|
||||
initialIndex={viewerState.index}
|
||||
onFavorite={handleToggleFavorite} // 总是传递本地的handleToggleFavorite而不是条件判断
|
||||
showFavoriteCount={showFavoriteCount}
|
||||
onShare={handleShareImage}
|
||||
/>
|
||||
|
||||
<ShareImageDialog
|
||||
visible={shareDialogState.visible}
|
||||
onClose={handleCloseShareDialog}
|
||||
image={shareDialogState.image}
|
||||
/>
|
||||
|
||||
<EditImageDialog
|
||||
visible={editDialogState.visible}
|
||||
onClose={handleCloseEditDialog}
|
||||
image={editDialogState.image}
|
||||
onSuccess={handleImageUpdateSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGrid;
|
||||
89
Web/src/components/image/ImageGrid/ContextMenu.tsx
Normal file
89
Web/src/components/image/ImageGrid/ContextMenu.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
HeartOutlined, HeartFilled, DeleteOutlined, EditOutlined, DownloadOutlined, ShareAltOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { PictureResponse } from '../../../api';
|
||||
|
||||
export type ContextMenuAction = 'favorite' | 'delete' | 'edit' | 'download' | 'share';
|
||||
|
||||
interface ContextMenuProps {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
image: PictureResponse | null;
|
||||
isOwner: boolean;
|
||||
onAction: (action: ContextMenuAction, image: PictureResponse) => void;
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
visible,
|
||||
x,
|
||||
y,
|
||||
image,
|
||||
isOwner,
|
||||
onAction,
|
||||
}) => {
|
||||
if (!visible || !image) return null;
|
||||
|
||||
const menuStyle = {
|
||||
position: 'fixed' as const,
|
||||
top: y,
|
||||
left: x,
|
||||
};
|
||||
|
||||
const handleAction = (action: ContextMenuAction) => {
|
||||
if (image) {
|
||||
onAction(action, image);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-menu" style={menuStyle}>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAction('favorite')}
|
||||
>
|
||||
{image.isFavorited ? (
|
||||
<><HeartFilled style={{ color: '#ff4d4f' }} /> 取消收藏</>
|
||||
) : (
|
||||
<><HeartOutlined /> 收藏</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAction('download')}
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAction('edit')}
|
||||
>
|
||||
<EditOutlined /> 编辑
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAction('share')}
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ color: '#ff4d4f' }}
|
||||
onClick={() => handleAction('delete')}
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
180
Web/src/components/image/ImageGrid/ImageCard.tsx
Normal file
180
Web/src/components/image/ImageGrid/ImageCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import {
|
||||
HeartOutlined, HeartFilled, EditOutlined, DownloadOutlined, ShareAltOutlined,
|
||||
GlobalOutlined, TeamOutlined, LockOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { PictureResponse } from '../../../api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const permissionTypeMap: Record<number, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
0: { label: '公开', icon: <GlobalOutlined />, color: '#52c41a' },
|
||||
1: { label: '好友可见', icon: <TeamOutlined />, color: '#1890ff' },
|
||||
2: { label: '私人', icon: <LockOutlined />, color: '#ff4d4f' }
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | Date | undefined): string => {
|
||||
try {
|
||||
if (!dateString) return '-';
|
||||
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch (error) {
|
||||
console.error('日期格式化错误:', error);
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const calculateImageMinWidth = (image: PictureResponse): number => {
|
||||
const fixedHeight = 200;
|
||||
const defaultMinWidth = 180;
|
||||
|
||||
if (image.exifInfo?.width && image.exifInfo?.height) {
|
||||
const aspectRatio = image.exifInfo.width / image.exifInfo.height;
|
||||
const calculatedWidth = Math.round(fixedHeight * aspectRatio);
|
||||
return Math.max(180, Math.min(400, calculatedWidth));
|
||||
}
|
||||
return defaultMinWidth;
|
||||
};
|
||||
|
||||
interface ImageCardProps {
|
||||
image: PictureResponse;
|
||||
isSelected: boolean;
|
||||
selectable: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu: (event: React.MouseEvent) => void;
|
||||
onToggleFavorite: (image: PictureResponse) => void;
|
||||
onEdit: (image: PictureResponse) => void;
|
||||
onShare: (image: PictureResponse) => void;
|
||||
onDownload: (image: PictureResponse) => void;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
const ImageCard: React.FC<ImageCardProps> = ({
|
||||
image,
|
||||
isSelected,
|
||||
selectable,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onToggleFavorite,
|
||||
onEdit,
|
||||
onShare,
|
||||
onDownload,
|
||||
isOwner,
|
||||
}) => {
|
||||
const imageMinWidth = calculateImageMinWidth(image);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`custom-card ${isSelected ? 'custom-card-selected' : ''} ${selectable ? 'custom-card-selectable-mode' : ''}`}
|
||||
style={{
|
||||
minWidth: imageMinWidth,
|
||||
flexBasis: imageMinWidth
|
||||
}}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div className="custom-card-cover">
|
||||
<img
|
||||
alt={image.name}
|
||||
src={image.thumbnailPath || image.path}
|
||||
className="custom-card-thumbnail"
|
||||
/>
|
||||
|
||||
{!selectable && (
|
||||
<>
|
||||
<div className="custom-card-indicators">
|
||||
<div className="custom-card-permission" style={{
|
||||
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
|
||||
}}>
|
||||
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
|
||||
</div>
|
||||
|
||||
<div className="custom-card-metadata">
|
||||
{image.exifInfo && image.exifInfo.width && image.exifInfo.height
|
||||
? `${Math.round(image.exifInfo.width * image.exifInfo.height / 1000000)}MP`
|
||||
: 'N/A'}
|
||||
{' | '}
|
||||
{formatDate(image.takenAt || image.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-card-overlay">
|
||||
<div className="custom-card-info">
|
||||
<div className="custom-card-title">{image.name}</div>
|
||||
|
||||
{image.tags && image.tags.length > 0 && (
|
||||
<div className="custom-card-tags-container">
|
||||
{image.tags.slice(0, 3).map((tag, tagIndex) => (
|
||||
<Text key={`${image.id}-${tag}-${tagIndex}`} className="image-tag">#{tag}</Text>
|
||||
))}
|
||||
{image.tags.length > 3 && (
|
||||
<Text className="image-tag">+{image.tags.length - 3}</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="custom-card-actions">
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(image);
|
||||
}}
|
||||
>
|
||||
{image.isFavorited ? (
|
||||
<HeartFilled style={{ fontSize: 14, color: '#ff4d4f' }} />
|
||||
) : (
|
||||
<HeartOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(image);
|
||||
}}
|
||||
>
|
||||
<EditOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare(image);
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="custom-card-action-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(image);
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: 14, color: '#ffffff' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageCard;
|
||||
@@ -4,7 +4,6 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
justify-content: space-between;
|
||||
/* 改为space-between让每行铺满 */
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -33,7 +32,6 @@
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 图片占满卡片 */
|
||||
.custom-card-cover {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
@@ -53,7 +51,6 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 信息覆盖层 - 默认隐藏 */
|
||||
.custom-card-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -74,7 +71,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 信息内容样式 */
|
||||
.custom-card-info {
|
||||
transform: translateY(20px);
|
||||
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
@@ -118,7 +114,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 权限和元数据指示器 */
|
||||
.custom-card-indicators {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -161,7 +156,6 @@
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 操作按钮容器 */
|
||||
.custom-card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -188,7 +182,6 @@
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 右键菜单样式 */
|
||||
.context-menu {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
@@ -211,7 +204,6 @@
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 选中状态样式 */
|
||||
.custom-card-selected {
|
||||
box-shadow: 0 0 0 3px #1890ff, 0 8px 20px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
@@ -252,7 +244,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 移除旧的加载动画,不再需要 */
|
||||
.image-loading-effect {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -279,12 +270,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.image-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.custom-card {
|
||||
height: 150px;
|
||||
}
|
||||
@@ -309,7 +295,8 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.custom-card {
|
||||
height: 120px;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.custom-card-overlay {
|
||||
556
Web/src/components/image/ImageGrid/ImageGrid.tsx
Normal file
556
Web/src/components/image/ImageGrid/ImageGrid.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Empty, message, Pagination, Modal } from 'antd';
|
||||
import type { PictureResponse } from '../../../api';
|
||||
import { favoritePicture, unfavoritePicture, getPictures, deleteMultiplePictures } from '../../../api';
|
||||
import ImageViewer from '../ImageViewer';
|
||||
import ShareImageDialog from '../ShareImageDialog';
|
||||
import EditImageDialog from '../EditImageDialog';
|
||||
import './ImageGrid.css';
|
||||
import { useAuth } from '../../../auth/AuthContext';
|
||||
import ImageCard from './ImageCard';
|
||||
import ContextMenu, { type ContextMenuAction } from './ContextMenu';
|
||||
|
||||
interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
albumId?: number;
|
||||
excludeAlbumId?: number;
|
||||
onlyFavorites?: boolean;
|
||||
tags?: string;
|
||||
searchQuery?: string;
|
||||
sortBy?: string;
|
||||
includeAllPublic?: boolean;
|
||||
useVectorSearch?: boolean;
|
||||
similarityThreshold?: number;
|
||||
}
|
||||
|
||||
// 右键菜单类型接口 - This specific state structure is for ImageGrid
|
||||
interface ContextMenuComponentState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
image: PictureResponse | null; // Store the whole image object
|
||||
}
|
||||
|
||||
// 简化Props接口,使用默认值
|
||||
interface ImageGridProps {
|
||||
// 核心功能属性
|
||||
onToggleFavorite?: (image: PictureResponse) => void;
|
||||
showFavoriteCount?: boolean;
|
||||
emptyText?: string;
|
||||
showPagination?: boolean;
|
||||
|
||||
// 数据源属性集合
|
||||
dataSource?: PictureResponse[];
|
||||
totalImages?: number;
|
||||
loading?: boolean;
|
||||
|
||||
// 查询相关参数
|
||||
queryParams?: {
|
||||
albumId?: number;
|
||||
excludeAlbumId?: number;
|
||||
onlyFavorites?: boolean;
|
||||
tags?: string[];
|
||||
searchQuery?: string;
|
||||
sortBy?: string;
|
||||
includeAllPublic?: boolean;
|
||||
useVectorSearch?: boolean;
|
||||
similarityThreshold?: number;
|
||||
_searchId?: number; // 添加搜索ID属性
|
||||
};
|
||||
|
||||
// 分页相关属性
|
||||
pageSize?: number;
|
||||
defaultPage?: number;
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
onImagesLoaded?: (images: PictureResponse[], totalCount: number) => void;
|
||||
|
||||
// 选择模式相关属性
|
||||
selectedIds?: number[];
|
||||
selectable?: boolean;
|
||||
onSelectionChange?: (selectedIds: number[]) => void;
|
||||
|
||||
// 操作回调
|
||||
onDelete?: (image: PictureResponse) => void;
|
||||
onEdit?: (image: PictureResponse) => void;
|
||||
onDownload?: (image: PictureResponse) => void;
|
||||
onShare?: (image: PictureResponse) => void;
|
||||
}
|
||||
|
||||
const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
onToggleFavorite,
|
||||
showFavoriteCount = false,
|
||||
emptyText = "暂无图片",
|
||||
showPagination = true,
|
||||
|
||||
dataSource,
|
||||
totalImages: externalTotalImages,
|
||||
loading: externalLoading,
|
||||
|
||||
queryParams = {},
|
||||
|
||||
pageSize: externalPageSize = 20,
|
||||
defaultPage = 1,
|
||||
onPageChange,
|
||||
onImagesLoaded,
|
||||
|
||||
selectedIds = [],
|
||||
selectable = false,
|
||||
onSelectionChange,
|
||||
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDownload,
|
||||
}) => {
|
||||
const { user, hasRole } = useAuth();
|
||||
|
||||
const [images, setImages] = useState<PictureResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(defaultPage);
|
||||
const [pageSize, setPageSize] = useState(externalPageSize);
|
||||
const [totalImages, setTotalImages] = useState(0);
|
||||
const [viewerState, setViewerState] = useState({ visible: false, index: 0 });
|
||||
const [shareDialogState, setShareDialogState] = useState<{
|
||||
visible: boolean;
|
||||
image: PictureResponse | null;
|
||||
}>({
|
||||
visible: false,
|
||||
image: null
|
||||
});
|
||||
|
||||
const [contextMenuState, setContextMenuState] = useState<ContextMenuComponentState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
image: null,
|
||||
});
|
||||
|
||||
const [editDialogState, setEditDialogState] = useState<{
|
||||
visible: boolean;
|
||||
image: PictureResponse | null;
|
||||
}>({
|
||||
visible: false,
|
||||
image: null
|
||||
});
|
||||
|
||||
const isUsingExternalData = !!dataSource;
|
||||
const isLoading = isUsingExternalData ? externalLoading : loading;
|
||||
|
||||
const requestState = useRef({
|
||||
inProgress: false,
|
||||
lastParams: '',
|
||||
noResultsFor: ''
|
||||
});
|
||||
|
||||
const favoriteOperationsInProgress = useRef<Map<number, boolean>>(new Map());
|
||||
|
||||
const buildQueryParams = useCallback((): PaginationParams => ({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...queryParams,
|
||||
searchQuery: queryParams.searchQuery,
|
||||
tags: Array.isArray(queryParams.tags) ? queryParams.tags.join(',') : undefined,
|
||||
useVectorSearch: queryParams.useVectorSearch,
|
||||
similarityThreshold: queryParams.similarityThreshold
|
||||
}), [currentPage, pageSize, queryParams]);
|
||||
|
||||
const loadImages = useCallback(async () => {
|
||||
if (isUsingExternalData || requestState.current.inProgress) return;
|
||||
|
||||
const params = buildQueryParams();
|
||||
const paramsString = JSON.stringify(params);
|
||||
|
||||
if (requestState.current.noResultsFor === paramsString && images.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestState.current.noResultsFor === paramsString) {
|
||||
requestState.current.noResultsFor = '';
|
||||
}
|
||||
|
||||
if (requestState.current.lastParams === paramsString) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.current = {
|
||||
inProgress: true,
|
||||
lastParams: paramsString,
|
||||
noResultsFor: requestState.current.noResultsFor
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await getPictures(params);
|
||||
|
||||
requestState.current.lastParams = paramsString;
|
||||
|
||||
if (result.success) {
|
||||
setImages(result.data || []);
|
||||
setTotalImages(result.totalCount || 0);
|
||||
onImagesLoaded?.(result.data || [], result.totalCount || 0);
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
requestState.current.noResultsFor = paramsString;
|
||||
}
|
||||
} else {
|
||||
message.error(result.message || '获取图片失败');
|
||||
requestState.current.noResultsFor = paramsString;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载图片列表出错');
|
||||
requestState.current.noResultsFor = paramsString;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
requestState.current.inProgress = false;
|
||||
}
|
||||
}, [buildQueryParams, isUsingExternalData, onImagesLoaded, images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUsingExternalData) loadImages();
|
||||
}, [loadImages, isUsingExternalData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUsingExternalData && dataSource) setImages(dataSource);
|
||||
}, [dataSource, isUsingExternalData]);
|
||||
|
||||
// 防止重复收藏操作
|
||||
const handleToggleFavoriteInternal = async (image: PictureResponse) => {
|
||||
const { id, isFavorited } = image;
|
||||
|
||||
if (favoriteOperationsInProgress.current.get(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
favoriteOperationsInProgress.current.set(id, true);
|
||||
|
||||
const api = isFavorited ? unfavoritePicture : favoritePicture;
|
||||
const result = await api(id);
|
||||
|
||||
if (result.success) {
|
||||
message.success(isFavorited ? '已取消收藏' : '已添加到收藏');
|
||||
|
||||
setImages(prevImages =>
|
||||
prevImages.map(img =>
|
||||
img.id === id ? {
|
||||
...img,
|
||||
isFavorited: !isFavorited,
|
||||
favoriteCount: isFavorited
|
||||
? Math.max(0, (img.favoriteCount || 0) - 1)
|
||||
: (img.favoriteCount || 0) + 1
|
||||
} : img
|
||||
)
|
||||
);
|
||||
|
||||
onToggleFavorite?.(image);
|
||||
} else {
|
||||
message.error(result.message || (isFavorited ? '取消收藏失败' : '收藏失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败,请重试');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
favoriteOperationsInProgress.current.delete(id);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number, size: number) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) setPageSize(size);
|
||||
onPageChange?.(page, size);
|
||||
};
|
||||
|
||||
const handleImageClick = (image: PictureResponse, index: number) => {
|
||||
if (selectable && onSelectionChange) {
|
||||
const isSelected = selectedIds.includes(image.id);
|
||||
const newSelectedIds = isSelected
|
||||
? selectedIds.filter(id => id !== image.id)
|
||||
: [...selectedIds, image.id];
|
||||
onSelectionChange(newSelectedIds);
|
||||
} else {
|
||||
setViewerState({ visible: true, index });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardContextMenu = (e: React.MouseEvent, image: PictureResponse) => {
|
||||
e.preventDefault();
|
||||
setContextMenuState({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
image: image,
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenuState(prev => ({
|
||||
...prev,
|
||||
visible: false,
|
||||
image: null,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (contextMenuState.visible && !target.closest('.context-menu')) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
};
|
||||
}, [contextMenuState.visible]);
|
||||
|
||||
const handleShareImage = (image: PictureResponse) => {
|
||||
setShareDialogState({
|
||||
visible: true,
|
||||
image
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadImageInternal = (image: PictureResponse) => {
|
||||
if (onDownload) {
|
||||
onDownload(image);
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = image.path;
|
||||
link.download = image.name || `image_${image.id}`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleCloseShareDialog = () => {
|
||||
setShareDialogState({
|
||||
...shareDialogState,
|
||||
visible: false
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditImageInternal = (image: PictureResponse) => {
|
||||
if (onEdit) {
|
||||
onEdit(image);
|
||||
return;
|
||||
}
|
||||
setEditDialogState({
|
||||
visible: true,
|
||||
image
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseEditDialog = () => {
|
||||
setEditDialogState({
|
||||
...editDialogState,
|
||||
visible: false
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageUpdateSuccess = (updatedImage: PictureResponse) => {
|
||||
setImages(prevImages =>
|
||||
prevImages.map(img => {
|
||||
if (img.id === updatedImage.id) {
|
||||
return {
|
||||
...img,
|
||||
...updatedImage,
|
||||
userId: img.userId,
|
||||
permission: updatedImage.permission ?? img.permission
|
||||
};
|
||||
}
|
||||
return img;
|
||||
})
|
||||
);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const handleDeleteImage = async (image: PictureResponse) => {
|
||||
if (onDelete) {
|
||||
onDelete(image);
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除图片 "${image.name}" 吗?此操作不可恢复。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const result = await deleteMultiplePictures([image.id]);
|
||||
|
||||
if (result.success) {
|
||||
message.success('图片已成功删除');
|
||||
|
||||
setImages(prevImages =>
|
||||
prevImages.filter(img => img.id !== image.id)
|
||||
);
|
||||
|
||||
if (images.length === 1 && currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
} else {
|
||||
message.error(result.message || '删除图片失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除图片失败,请重试');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenuAction = (action: ContextMenuAction, image: PictureResponse) => {
|
||||
if (!image) return;
|
||||
|
||||
switch (action) {
|
||||
case 'favorite':
|
||||
handleToggleFavoriteInternal(image);
|
||||
break;
|
||||
case 'delete':
|
||||
handleDeleteImage(image);
|
||||
break;
|
||||
case 'edit':
|
||||
handleEditImageInternal(image);
|
||||
break;
|
||||
case 'download':
|
||||
handleDownloadImageInternal(image);
|
||||
break;
|
||||
case 'share':
|
||||
handleShareImage(image);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const canEditImage = (image: PictureResponse): boolean => {
|
||||
if (user && hasRole('Administrator')) {
|
||||
return true;
|
||||
}
|
||||
return !!user && !!image.userId && user.id === image.userId;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="image-grid">
|
||||
{Array.from({ length: pageSize }).map((_, index) => (
|
||||
<div
|
||||
key={`loading-${index}`}
|
||||
className="custom-card image-loading-effect"
|
||||
style={{ minWidth: 180 }}
|
||||
>
|
||||
<div className="custom-card-cover" style={{ background: '#f5f5f5' }}>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description={emptyText}
|
||||
style={{ margin: '80px 0' }}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-grid">
|
||||
{images.map((image, index) => (
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
isSelected={selectedIds.includes(image.id)}
|
||||
selectable={selectable}
|
||||
onClick={() => handleImageClick(image, index)}
|
||||
onContextMenu={(e) => handleCardContextMenu(e, image)}
|
||||
onToggleFavorite={handleToggleFavoriteInternal}
|
||||
onEdit={handleEditImageInternal}
|
||||
onShare={handleShareImage}
|
||||
onDownload={handleDownloadImageInternal}
|
||||
isOwner={canEditImage(image)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
<ContextMenu
|
||||
visible={contextMenuState.visible}
|
||||
x={contextMenuState.x}
|
||||
y={contextMenuState.y}
|
||||
image={contextMenuState.image}
|
||||
isOwner={!!contextMenuState.image && canEditImage(contextMenuState.image)}
|
||||
onAction={handleContextMenuAction}
|
||||
/>
|
||||
|
||||
{showPagination && images.length > 0 && (
|
||||
<div className="image-grid-pagination">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={isUsingExternalData ? (externalTotalImages || 0) : totalImages}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
locale={{
|
||||
items_per_page: '条/页',
|
||||
jump_to: '跳至',
|
||||
jump_to_confirm: '确定',
|
||||
page: '页',
|
||||
prev_page: '上一页',
|
||||
next_page: '下一页',
|
||||
prev_5: '向前 5 页',
|
||||
next_5: '向后 5 页',
|
||||
prev_3: '向前 3 页',
|
||||
next_3: '向后 3 页'
|
||||
}}
|
||||
pageSizeOptions={['8', '16', '20', '32', '64']}
|
||||
showTotal={(total) => `共 ${total} 张图片`}
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImageViewer
|
||||
visible={viewerState.visible}
|
||||
onClose={() => setViewerState({ ...viewerState, visible: false })}
|
||||
images={images}
|
||||
initialIndex={viewerState.index}
|
||||
onFavorite={handleToggleFavoriteInternal}
|
||||
showFavoriteCount={showFavoriteCount}
|
||||
onShare={handleShareImage}
|
||||
/>
|
||||
|
||||
<ShareImageDialog
|
||||
visible={shareDialogState.visible}
|
||||
onClose={handleCloseShareDialog}
|
||||
image={shareDialogState.image}
|
||||
/>
|
||||
|
||||
<EditImageDialog
|
||||
visible={editDialogState.visible}
|
||||
onClose={handleCloseEditDialog}
|
||||
image={editDialogState.image}
|
||||
onSuccess={handleImageUpdateSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGrid;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input, Tabs, Switch, Select, Slider, Space, Button, Typography, Tooltip, message } from 'antd';
|
||||
import { SearchOutlined, FileImageOutlined, ClearOutlined } from '@ant-design/icons';
|
||||
import ImageGrid from '../image/ImageGrid';
|
||||
import ImageGrid from '../image/ImageGrid/ImageGrid';
|
||||
import type { PictureResponse } from '../../api';
|
||||
import './SearchDialog.css';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
EditOutlined, DeleteOutlined, PlusOutlined} from '@ant-design/icons';
|
||||
import { getAlbumById, deleteAlbum, favoritePicture, unfavoritePicture, addPicturesToAlbum, updateAlbum } from '../../api';
|
||||
import type { AlbumResponse, PictureResponse } from '../../api';
|
||||
import ImageGrid from '../../components/image/ImageGrid';
|
||||
import ImageGrid from '../../components/image/ImageGrid/ImageGrid';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Typography, Button, Dropdown, message, Row, Col } from 'antd';
|
||||
import { SortAscendingOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { PictureResponse } from '../../api';
|
||||
import ImageUploadDialog from '../../components/upload/ImageUploadDialog';
|
||||
import ImageGrid from '../../components/image/ImageGrid';
|
||||
import ImageGrid from '../../components/image/ImageGrid/ImageGrid';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { Typography, Button, Dropdown } from 'antd';
|
||||
import { SortAscendingOutlined } from '@ant-design/icons';
|
||||
import type { PictureResponse } from '../../api';
|
||||
import ImageGrid from '../../components/image/ImageGrid';
|
||||
import ImageGrid from '../../components/image/ImageGrid/ImageGrid';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
FilterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import ImageGrid from '../../components/image/ImageGrid';
|
||||
import ImageGrid from '../../components/image/ImageGrid/ImageGrid';
|
||||
import type { PictureResponse } from '../../api/types';
|
||||
import { getFilteredTags } from '../../api/tagApi';
|
||||
import useIsMobile from '../../hooks/useIsMobile';
|
||||
|
||||
Reference in New Issue
Block a user