mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-07 16:46:48 +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;
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+2
-15
@@ -4,7 +4,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
/* 改为space-between让每行铺满 */
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@
|
|||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图片占满卡片 */
|
|
||||||
.custom-card-cover {
|
.custom-card-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -53,7 +51,6 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 信息覆盖层 - 默认隐藏 */
|
|
||||||
.custom-card-overlay {
|
.custom-card-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -74,7 +71,6 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 信息内容样式 */
|
|
||||||
.custom-card-info {
|
.custom-card-info {
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
@@ -118,7 +114,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 权限和元数据指示器 */
|
|
||||||
.custom-card-indicators {
|
.custom-card-indicators {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -161,7 +156,6 @@
|
|||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 操作按钮容器 */
|
|
||||||
.custom-card-actions {
|
.custom-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -188,7 +182,6 @@
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右键菜单样式 */
|
|
||||||
.context-menu {
|
.context-menu {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -211,7 +204,6 @@
|
|||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 选中状态样式 */
|
|
||||||
.custom-card-selected {
|
.custom-card-selected {
|
||||||
box-shadow: 0 0 0 3px #1890ff, 0 8px 20px rgba(0, 0, 0, 0.15) !important;
|
box-shadow: 0 0 0 3px #1890ff, 0 8px 20px rgba(0, 0, 0, 0.15) !important;
|
||||||
}
|
}
|
||||||
@@ -252,7 +244,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移除旧的加载动画,不再需要 */
|
|
||||||
.image-loading-effect {
|
.image-loading-effect {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -279,12 +270,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式调整 */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.image-grid {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-card {
|
.custom-card {
|
||||||
height: 150px;
|
height: 150px;
|
||||||
}
|
}
|
||||||
@@ -309,7 +295,8 @@
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.custom-card {
|
.custom-card {
|
||||||
height: 120px;
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-card-overlay {
|
.custom-card-overlay {
|
||||||
@@ -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 React, { useState, useEffect } from 'react';
|
||||||
import { Modal, Input, Tabs, Switch, Select, Slider, Space, Button, Typography, Tooltip, message } from 'antd';
|
import { Modal, Input, Tabs, Switch, Select, Slider, Space, Button, Typography, Tooltip, message } from 'antd';
|
||||||
import { SearchOutlined, FileImageOutlined, ClearOutlined } from '@ant-design/icons';
|
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 type { PictureResponse } from '../../api';
|
||||||
import './SearchDialog.css';
|
import './SearchDialog.css';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
EditOutlined, DeleteOutlined, PlusOutlined} from '@ant-design/icons';
|
EditOutlined, DeleteOutlined, PlusOutlined} from '@ant-design/icons';
|
||||||
import { getAlbumById, deleteAlbum, favoritePicture, unfavoritePicture, addPicturesToAlbum, updateAlbum } from '../../api';
|
import { getAlbumById, deleteAlbum, favoritePicture, unfavoritePicture, addPicturesToAlbum, updateAlbum } from '../../api';
|
||||||
import type { AlbumResponse, PictureResponse } 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 { Title, Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Typography, Button, Dropdown, message, Row, Col } from 'antd';
|
|||||||
import { SortAscendingOutlined, UploadOutlined } from '@ant-design/icons';
|
import { SortAscendingOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { PictureResponse } from '../../api';
|
import type { PictureResponse } from '../../api';
|
||||||
import ImageUploadDialog from '../../components/upload/ImageUploadDialog';
|
import ImageUploadDialog from '../../components/upload/ImageUploadDialog';
|
||||||
import ImageGrid from '../../components/image/ImageGrid';
|
import ImageGrid from '../../components/image/ImageGrid/ImageGrid';
|
||||||
import useIsMobile from '../../hooks/useIsMobile';
|
import useIsMobile from '../../hooks/useIsMobile';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useRef, useMemo, useCallback } from 'react';
|
|||||||
import { Typography, Button, Dropdown } from 'antd';
|
import { Typography, Button, Dropdown } from 'antd';
|
||||||
import { SortAscendingOutlined } from '@ant-design/icons';
|
import { SortAscendingOutlined } from '@ant-design/icons';
|
||||||
import type { PictureResponse } from '../../api';
|
import type { PictureResponse } from '../../api';
|
||||||
import ImageGrid from '../../components/image/ImageGrid';
|
import ImageGrid from '../../components/image/ImageGrid/ImageGrid';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
FilterOutlined,
|
FilterOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import ImageGrid from '../../components/image/ImageGrid';
|
import ImageGrid from '../../components/image/ImageGrid/ImageGrid';
|
||||||
import type { PictureResponse } from '../../api/types';
|
import type { PictureResponse } from '../../api/types';
|
||||||
import { getFilteredTags } from '../../api/tagApi';
|
import { getFilteredTags } from '../../api/tagApi';
|
||||||
import useIsMobile from '../../hooks/useIsMobile';
|
import useIsMobile from '../../hooks/useIsMobile';
|
||||||
|
|||||||
Reference in New Issue
Block a user