diff --git a/Web/src/components/image/ImageGrid.tsx b/Web/src/components/image/ImageGrid.tsx deleted file mode 100644 index 02c3d87..0000000 --- a/Web/src/components/image/ImageGrid.tsx +++ /dev/null @@ -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 = { - 0: { label: '公开', icon: , color: '#52c41a' }, - 1: { label: '好友可见', icon: , color: '#1890ff' }, - 2: { label: '私人', icon: , 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 = ({ - // 使用解构赋值时直接设置默认值 - 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([]); - 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({ - 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>(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 ( -
- {Array.from({ length: pageSize }).map((_, index) => ( -
-
- {/* 简单的加载状态 */} -
-
- ))} -
- ); - } - - // 渲染空状态 - if (images.length === 0) { - return ( - - ); - } - - // 渲染图片网格 - return ( -
- {images.map((image, index) => { - const isOwner = canEditImage(image); - const imageMinWidth = calculateImageMinWidth(image); - - return ( -
handleImageClick(image, index)} - onContextMenu={(e) => handleContextMenu(e, image)} - > -
- {image.name} - - {!selectable && ( - <> - {/* 顶部指示器 - 悬停时显示 */} -
-
- {permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'} -
- -
- {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() - )} -
-
- - {/* 悬停时显示的信息覆盖层 */} -
-
-
{image.name}
- - {image.tags && image.tags.length > 0 && ( -
- {image.tags.slice(0, 3).map((tag, tagIndex) => ( - #{tag} - ))} - {image.tags.length > 3 && ( - +{image.tags.length - 3} - )} -
- )} - -
-
{ - e.stopPropagation(); - handleToggleFavorite(image); - }} - > - {image.isFavorited ? ( - - ) : ( - - )} -
- - {isOwner && ( -
{ - e.stopPropagation(); - if (onEdit) { - onEdit(image); - } else { - handleEditImage(image); - } - }} - > - -
- )} - -
{ - e.stopPropagation(); - onShare?.(image); - }} - > - -
- -
{ - e.stopPropagation(); - onDownload?.(image); - }} - > - -
-
-
-
- - )} -
-
- ); - })} -
- ); - }; - - // 渲染右键菜单 - 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 ( -
-
handleMenuAction('favorite')} - > - {isFavorited ? ( - <> 取消收藏 - ) : ( - <> 收藏 - )} -
- -
handleMenuAction('download')} - > - 下载 -
- - {isOwner && ( -
handleMenuAction('edit')} - > - 编辑 -
- )} - -
handleMenuAction('share')} - > - 分享 -
- - {isOwner && ( -
handleMenuAction('delete')} - > - 删除 -
- )} -
- ); - }; - - // 处理删除图片 - 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 && ( -
- `共 ${total} 张图片`} - size="default" - /> -
- )} - - setViewerState({ ...viewerState, visible: false })} - images={images} - initialIndex={viewerState.index} - onFavorite={handleToggleFavorite} // 总是传递本地的handleToggleFavorite而不是条件判断 - showFavoriteCount={showFavoriteCount} - onShare={handleShareImage} - /> - - - - - - ); -}; - -export default ImageGrid; diff --git a/Web/src/components/image/ImageGrid/ContextMenu.tsx b/Web/src/components/image/ImageGrid/ContextMenu.tsx new file mode 100644 index 0000000..6b44067 --- /dev/null +++ b/Web/src/components/image/ImageGrid/ContextMenu.tsx @@ -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 = ({ + 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 ( +
+
handleAction('favorite')} + > + {image.isFavorited ? ( + <> 取消收藏 + ) : ( + <> 收藏 + )} +
+ +
handleAction('download')} + > + 下载 +
+ + {isOwner && ( +
handleAction('edit')} + > + 编辑 +
+ )} + +
handleAction('share')} + > + 分享 +
+ + {isOwner && ( +
handleAction('delete')} + > + 删除 +
+ )} +
+ ); +}; + +export default ContextMenu; diff --git a/Web/src/components/image/ImageGrid/ImageCard.tsx b/Web/src/components/image/ImageGrid/ImageCard.tsx new file mode 100644 index 0000000..8f7b7b3 --- /dev/null +++ b/Web/src/components/image/ImageGrid/ImageCard.tsx @@ -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 = { + 0: { label: '公开', icon: , color: '#52c41a' }, + 1: { label: '好友可见', icon: , color: '#1890ff' }, + 2: { label: '私人', icon: , 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 = ({ + image, + isSelected, + selectable, + onClick, + onContextMenu, + onToggleFavorite, + onEdit, + onShare, + onDownload, + isOwner, +}) => { + const imageMinWidth = calculateImageMinWidth(image); + + return ( +
+
+ {image.name} + + {!selectable && ( + <> +
+
+ {permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'} +
+ +
+ {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)} +
+
+ +
+
+
{image.name}
+ + {image.tags && image.tags.length > 0 && ( +
+ {image.tags.slice(0, 3).map((tag, tagIndex) => ( + #{tag} + ))} + {image.tags.length > 3 && ( + +{image.tags.length - 3} + )} +
+ )} + +
+
{ + e.stopPropagation(); + onToggleFavorite(image); + }} + > + {image.isFavorited ? ( + + ) : ( + + )} +
+ + {isOwner && ( +
{ + e.stopPropagation(); + onEdit(image); + }} + > + +
+ )} + +
{ + e.stopPropagation(); + onShare(image); + }} + > + +
+ +
{ + e.stopPropagation(); + onDownload(image); + }} + > + +
+
+
+
+ + )} +
+
+ ); +}; + +export default ImageCard; diff --git a/Web/src/components/image/ImageGrid.css b/Web/src/components/image/ImageGrid/ImageGrid.css similarity index 93% rename from Web/src/components/image/ImageGrid.css rename to Web/src/components/image/ImageGrid/ImageGrid.css index e33822d..9180e26 100644 --- a/Web/src/components/image/ImageGrid.css +++ b/Web/src/components/image/ImageGrid/ImageGrid.css @@ -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 { diff --git a/Web/src/components/image/ImageGrid/ImageGrid.tsx b/Web/src/components/image/ImageGrid/ImageGrid.tsx new file mode 100644 index 0000000..fd1af5c --- /dev/null +++ b/Web/src/components/image/ImageGrid/ImageGrid.tsx @@ -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 = ({ + 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([]); + 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({ + 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>(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 ( +
+ {Array.from({ length: pageSize }).map((_, index) => ( +
+
+
+
+ ))} +
+ ); + } + + if (images.length === 0) { + return ( + + ); + } + + return ( +
+ {images.map((image, index) => ( + handleImageClick(image, index)} + onContextMenu={(e) => handleCardContextMenu(e, image)} + onToggleFavorite={handleToggleFavoriteInternal} + onEdit={handleEditImageInternal} + onShare={handleShareImage} + onDownload={handleDownloadImageInternal} + isOwner={canEditImage(image)} + /> + ))} +
+ ); + }; + + return ( + <> + {renderContent()} + + + {showPagination && images.length > 0 && ( +
+ `共 ${total} 张图片`} + size="default" + /> +
+ )} + + setViewerState({ ...viewerState, visible: false })} + images={images} + initialIndex={viewerState.index} + onFavorite={handleToggleFavoriteInternal} + showFavoriteCount={showFavoriteCount} + onShare={handleShareImage} + /> + + + + + + ); +}; + +export default ImageGrid; diff --git a/Web/src/components/search/SearchDialog.tsx b/Web/src/components/search/SearchDialog.tsx index 2759cac..9de4bc7 100644 --- a/Web/src/components/search/SearchDialog.tsx +++ b/Web/src/components/search/SearchDialog.tsx @@ -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'; diff --git a/Web/src/pages/albumDetail/Index.tsx b/Web/src/pages/albumDetail/Index.tsx index a1df915..7137f0b 100644 --- a/Web/src/pages/albumDetail/Index.tsx +++ b/Web/src/pages/albumDetail/Index.tsx @@ -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; diff --git a/Web/src/pages/allImages/Index.tsx b/Web/src/pages/allImages/Index.tsx index 4f5c55e..e32dff5 100644 --- a/Web/src/pages/allImages/Index.tsx +++ b/Web/src/pages/allImages/Index.tsx @@ -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; diff --git a/Web/src/pages/favorites/Index.tsx b/Web/src/pages/favorites/Index.tsx index 1f21191..2c738b1 100644 --- a/Web/src/pages/favorites/Index.tsx +++ b/Web/src/pages/favorites/Index.tsx @@ -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; diff --git a/Web/src/pages/pixHub/Index.tsx b/Web/src/pages/pixHub/Index.tsx index 46425ff..7268260 100644 --- a/Web/src/pages/pixHub/Index.tsx +++ b/Web/src/pages/pixHub/Index.tsx @@ -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';