refactor(imageGrid): add context menu and image card components for enhanced image interaction

This commit is contained in:
shiyu
2025-06-07 12:43:56 +08:00
parent bbf4e30847
commit 31a4f2e469
10 changed files with 832 additions and 801 deletions

View File

@@ -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;

View 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;

View 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;

View File

@@ -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 {

View 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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';