refactor: restructure directories to improve module organization Foxel.Models.Request.Picture - Foxel.Models.Request.Tag - Foxel.Models.Request.Auth - Foxel.Models.Request.Picture

This commit is contained in:
ShiYu
2025-05-23 15:07:37 +08:00
parent a03e245d67
commit 0691f1c87d
91 changed files with 30 additions and 30 deletions

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Progress, Tag, Tooltip } from 'antd';
import {
ClockCircleOutlined,
SyncOutlined,
CheckCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons';
import { ProcessingStatus } from '../api/types';
interface TaskProgressBarProps {
status: ProcessingStatus;
progress: number;
error?: string;
showLabel?: boolean;
size?: 'small' | 'default';
className?: string;
style?: React.CSSProperties;
}
const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
status,
progress,
error,
showLabel = true,
size = 'default',
className,
style
}) => {
let statusColor = '';
let icon = null;
let statusText = '';
let progressStatus: "success" | "exception" | "active" | "normal" | undefined;
switch (status) {
case ProcessingStatus.Pending:
statusColor = 'orange';
progressStatus = 'normal';
icon = <ClockCircleOutlined />;
statusText = '等待中';
break;
case ProcessingStatus.Processing:
statusColor = 'processing';
progressStatus = 'active';
icon = <SyncOutlined spin />;
statusText = '处理中';
break;
case ProcessingStatus.Completed:
statusColor = 'success';
progressStatus = 'success';
icon = <CheckCircleOutlined />;
statusText = '已完成';
break;
case ProcessingStatus.Failed:
statusColor = 'error';
progressStatus = 'exception';
icon = <CloseCircleOutlined />;
statusText = '失败';
break;
}
return (
<div className={className} style={{ ...style }}>
{showLabel && (
<div style={{ marginBottom: 4, display: 'flex', alignItems: 'center' }}>
<Tag color={statusColor} icon={icon} style={{ marginRight: 8 }}>
{statusText}
</Tag>
{status === ProcessingStatus.Failed && error && (
<Tooltip title={error}>
<span style={{ color: '#ff4d4f', cursor: 'pointer', fontSize: 13 }}>
</span>
</Tooltip>
)}
</div>
)}
<Tooltip title={`${progress}%`}>
<Progress
percent={progress}
size={size}
status={progressStatus}
showInfo={size !== 'small'}
strokeColor={status === ProcessingStatus.Failed ? '#ff4d4f' : undefined}
/>
</Tooltip>
</div>
);
};
export default TaskProgressBar;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { Avatar as AntAvatar, type AvatarProps as AntAvatarProps } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import md5 from 'md5';
interface UserAvatarProps extends Omit<AntAvatarProps, 'src'> {
email?: string;
url?: string;
text?: string;
}
const UserAvatar: React.FC<UserAvatarProps> = ({
email,
url,
text,
size = 46,
style,
...restProps
}) => {
// 确定头像源
const getAvatarSrc = () => {
if (url) {
return url;
}
if (email) {
const hash = md5(email.toLowerCase().trim());
return `https://cn.cravatar.com/avatar/${hash}?s=${typeof size === 'number' ? size * 2 : 96}&d=identicon`;
}
return null;
};
const avatarSrc = getAvatarSrc();
// 默认样式
const defaultStyle = {
backgroundColor: !avatarSrc && !text ? '#18181b' : undefined,
cursor: 'pointer',
boxShadow: '0 3px 10px rgba(0,0,0,0.1)',
...style
};
return (
<AntAvatar
size={size}
src={avatarSrc}
style={defaultStyle}
icon={!avatarSrc && !text ? <UserOutlined /> : null}
{...restProps}
>
{!avatarSrc && text ? text.charAt(0).toUpperCase() : null}
</AntAvatar>
);
};
export default UserAvatar;

View File

@@ -0,0 +1,270 @@
.image-grid {
margin-bottom: 40px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-gap: 24px;
}
/* 现代化卡片样式 */
.custom-card {
position: relative;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 20px rgba(0,0,0,0.08);
transition: all 0.35s cubic-bezier(0.23, 1, 0.32, 1);
height: 100%;
background: #ffffff;
transform: translateY(0);
cursor: pointer;
aspect-ratio: 1 / 1;
}
.custom-card:hover {
transform: translateY(-5px);
box-shadow: 0 14px 28px rgba(0,0,0,0.15);
}
/* 图片占满卡片 */
.custom-card-cover {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
}
.custom-card-thumbnail {
height: 100%;
width: 100%;
object-fit: cover;
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
display: block;
}
.custom-card:hover .custom-card-thumbnail {
transform: scale(1.05);
}
/* 信息覆盖层 - 默认隐藏 */
.custom-card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0) 50%);
opacity: 0;
transition: opacity 0.35s ease;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20px;
color: white;
}
.custom-card:hover .custom-card-overlay {
opacity: 1;
}
/* 信息内容样式 */
.custom-card-info {
transform: translateY(20px);
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
opacity: 0;
}
.custom-card:hover .custom-card-info {
transform: translateY(0);
opacity: 1;
}
.custom-card-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.custom-card-tags-container {
margin-top: 6px;
margin-bottom: 10px;
max-width: 100%;
overflow: hidden;
}
.image-tag {
margin-right: 6px;
font-size: 11px !important;
background: rgba(255,255,255,0.2);
padding: 3px 8px;
border-radius: 4px;
color: #ffffff;
display: inline-block;
margin-bottom: 4px;
backdrop-filter: blur(4px);
}
/* 权限和元数据指示器 */
.custom-card-indicators {
position: absolute;
top: 12px;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 0 12px;
opacity: 0;
transition: opacity 0.35s ease;
z-index: 2;
}
.custom-card:hover .custom-card-indicators {
opacity: 1;
}
.custom-card-permission {
background-color: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 20px;
padding: 5px 10px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.custom-card-metadata {
background-color: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 20px;
padding: 5px 10px;
font-size: 12px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
backdrop-filter: blur(4px);
}
/* 操作按钮容器 */
.custom-card-actions {
display: flex;
justify-content: space-between;
margin-top: 12px;
}
.custom-card-action-item {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
backdrop-filter: blur(4px);
}
.custom-card-action-item:hover {
background-color: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
/* 右键菜单样式 */
.context-menu {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
padding: 8px 0;
min-width: 160px;
z-index: 1000;
}
.context-menu-item {
padding: 10px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.2s;
}
.context-menu-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
/* 选中状态样式 */
.custom-card-selected {
box-shadow: 0 0 0 3px #1890ff, 0 14px 28px rgba(0,0,0,0.15) !important;
}
.custom-card-selected::before {
content: '';
position: absolute;
top: 10px;
right: 10px;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: #1890ff;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.custom-card-selected::after {
content: '✓';
position: absolute;
top: 10px;
right: 10px;
width: 22px;
height: 22px;
z-index: 6;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.image-grid-pagination {
margin-top: 40px;
text-align: center;
}
/* 移除旧的加载动画,不再需要 */
.image-loading-effect {
position: relative;
overflow: hidden;
}
.image-loading-effect::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* 响应式调整 */
@media (max-width: 768px) {
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-gap: 16px;
}
}

View File

@@ -0,0 +1,693 @@
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 './ImageGrid.css';
import { useAuth } from '../../api/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 isUsingExternalData = !!dataSource;
const isLoading = isUsingExternalData ? externalLoading : loading;
// 请求状态追踪
const requestState = useRef({
inProgress: false,
lastParams: '',
noResultsFor: '' // 新增:记录哪些查询参数没有返回结果
});
// 优化构建查询参数函数
const buildQueryParams = useCallback((): PaginationParams => {
const params: PaginationParams = {
page: currentPage,
pageSize,
...queryParams,
searchQuery: queryParams.searchQuery,
tags: Array.isArray(queryParams.tags) ? queryParams.tags.join(',') : undefined,
useVectorSearch: queryParams.useVectorSearch,
similarityThreshold: queryParams.similarityThreshold
};
return params;
}, [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) {
// 如果这个查询之前没有结果,且当前还是空结果状态,不再重复请求
if (images.length === 0) {
return;
}
// 如果之前没结果但现在有图片显示重置noResultsFor让新查询可以执行
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);
// 无论成功与否都更新lastParams以避免相同参数的重复请求
requestState.current.lastParams = paramsString;
if (result.success) {
// 更新图片数据
setImages(result.data || []);
setTotalImages(result.totalCount || 0);
onImagesLoaded?.(result.data || [], result.totalCount || 0);
// 如果结果为空记录到noResultsFor
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) => {
try {
const { id, isFavorited } = image;
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('操作失败,请重试');
}
};
// 处理分页变化
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
});
};
// 修改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':
onEdit?.(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 renderContent = () => {
// 渲染加载状态
if (isLoading) {
return (
<div className="image-grid">
{Array.from({ length: pageSize }).map((_, index) => (
<div key={`loading-${index}`} className="custom-card image-loading-effect">
<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);
return (
<div
key={image.id}
className={`custom-card ${selectedIds.includes(image.id) ? 'custom-card-selected' : ''} ${selectable ? 'custom-card-selectable-mode' : ''}`}
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 && (
<div className="custom-card-tags-container">
{image.tags.map(tag => (
<Text key={tag} className="image-tag">#{tag}</Text>
))}
</div>
)}
<div className="custom-card-actions">
<div
className="custom-card-action-item"
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite(image);
}}
>
{image.isFavorited ? (
<HeartFilled style={{ fontSize: 16, color: '#ff4d4f' }} />
) : (
<HeartOutlined style={{ fontSize: 16, color: '#ffffff' }} />
)}
</div>
{isOwner && (
<div
className="custom-card-action-item"
onClick={(e) => {
e.stopPropagation();
onEdit && onEdit(image);
}}
>
<EditOutlined style={{ fontSize: 16, color: '#ffffff' }} />
</div>
)}
<div
className="custom-card-action-item"
onClick={(e) => {
e.stopPropagation();
onShare?.(image);
}}
>
<ShareAltOutlined style={{ fontSize: 16, color: '#ffffff' }} />
</div>
<div
className="custom-card-action-item"
onClick={(e) => {
e.stopPropagation();
onDownload?.(image);
}}
>
<DownloadOutlined style={{ fontSize: 16, 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={onToggleFavorite || handleToggleFavorite}
showFavoriteCount={showFavoriteCount}
onShare={handleShareImage}
/>
<ShareImageDialog
visible={shareDialogState.visible}
onClose={handleCloseShareDialog}
image={shareDialogState.image}
/>
</>
);
};
export default ImageGrid;

View File

@@ -0,0 +1,414 @@
import React, { useState } from 'react';
import { Divider } from 'antd';
import { CloseOutlined, DownOutlined, UpOutlined } from '@ant-design/icons';
import type { PictureResponse } from '../../api/types';
import './ImageViewer.css';
interface ImageInfoProps {
image: PictureResponse;
onClose: () => void;
visible: boolean;
}
const ImageInfo: React.FC<ImageInfoProps> = ({
image,
onClose,
visible
}) => {
const [expandDescription, setExpandDescription] = useState(false);
// 切换描述展开/折叠状态
const toggleDescription = () => {
setExpandDescription(!expandDescription);
};
// 格式化EXIF数据
const formatExifInfo = (exifInfo: any) => {
if (!exifInfo) return [];
// 定义EXIF信息分类
const categories = {
basic: { title: "基本信息", items: [] as any[] },
camera: { title: "相机信息", items: [] as any[] },
settings: { title: "拍摄参数", items: [] as any[] },
time: { title: "时间信息", items: [] as any[] },
location: { title: "位置信息", items: [] as any[] }
};
// 将EXIF信息映射到对应字段
const exifMapping: Record<string, { key: string; category: keyof typeof categories; formatter?: (value: any) => string }> = {
// 基本信息
width: { key: "width", category: "basic", formatter: (v) => `${v}px` },
height: { key: "height", category: "basic", formatter: (v) => `${v}px` },
// 相机信息
cameraMaker: { key: "make", category: "camera" },
cameraModel: { key: "model", category: "camera" },
software: { key: "software", category: "camera" },
// 拍摄参数
exposureTime: { key: "exposureTime", category: "settings" },
aperture: { key: "fNumber", category: "settings", formatter: (v) => `f/${v}` },
isoSpeed: { key: "iso", category: "settings", formatter: (v) => `ISO ${v}` },
focalLength: { key: "focalLength", category: "settings", formatter: (v) => `${v}mm` },
flash: { key: "flash", category: "settings" },
meteringMode: { key: "meteringMode", category: "settings" },
whiteBalance: { key: "whiteBalance", category: "settings" },
dateTimeOriginal: {
key: "dateTime",
category: "time",
formatter: (v) => {
if (typeof v === 'string' && v.match(/^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$/)) {
const normalized = v.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3');
const date = new Date(normalized);
if (!isNaN(date.getTime())) {
return date.toLocaleString();
}
}
return v.toString();
}
},
// 位置信息
gpsLatitude: { key: "latitude", category: "location" },
gpsLongitude: { key: "longitude", category: "location" }
};
// 处理每个EXIF字段
Object.entries(exifInfo).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') return;
const mapping = exifMapping[key];
if (mapping) {
const formattedValue = mapping.formatter ? mapping.formatter(value) : value.toString();
const label = formatExifLabel(mapping.key);
categories[mapping.category].items.push({
key: mapping.key,
label,
value: formattedValue
});
}
});
// 返回包含数据的分类
return Object.values(categories).filter(category => category.items.length > 0);
};
// 格式化EXIF标签名称
const formatExifLabel = (key: string): string => {
const labels: Record<string, string> = {
// 基本信息
width: "宽度",
height: "高度",
// 相机信息
make: "相机品牌",
model: "相机型号",
software: "软件",
// 拍摄参数
exposureTime: "曝光时间",
fNumber: "光圈值",
iso: "ISO感光度",
focalLength: "焦距",
flash: "闪光灯",
meteringMode: "测光模式",
whiteBalance: "白平衡",
// 时间信息
dateTime: "拍摄时间",
// 位置信息
latitude: "纬度",
longitude: "经度"
};
return labels[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
};
// 渲染EXIF信息
const renderExifInfo = (styles: any) => {
if (!image?.exifInfo) return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>EXIF信息</div>;
const formattedCategories = formatExifInfo(image.exifInfo);
if (formattedCategories.length === 0) {
return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>EXIF信息</div>;
}
return (
<div style={styles.exifContainer}>
{formattedCategories.map(category => (
<div key={category.title} style={styles.exifCategory}>
<Divider
orientation="left"
style={styles.divider}
>
{category.title}
</Divider>
<div style={styles.exifTable}>
{category.items.map(item => (
<div key={item.key} style={{ display: 'flex', borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
<div style={styles.exifLabel}>{item.label}</div>
<div style={styles.exifValue}>{item.value}</div>
</div>
))}
</div>
</div>
))}
</div>
);
};
// 定义内联样式对象
const styles = {
// 抽屉基础样式
drawer: {
position: 'fixed' as const,
top: 0,
right: 0,
width: '350px',
height: '100%',
zIndex: 1050,
backgroundColor: 'rgba(28, 30, 34, 0.5)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: 'none',
boxShadow: '-10px 0 30px rgba(0, 0, 0, 0.2)',
transition: 'transform 0.3s ease',
transform: visible ? 'translateX(0)' : 'translateX(100%)',
overflowY: 'auto' as const
},
header: {
backgroundColor: 'rgba(28, 30, 34, 0.6)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
color: 'rgba(255, 255, 255, 0.95)',
padding: '16px 20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
},
headerTitle: {
color: 'rgba(255, 255, 255, 0.95)',
margin: 0,
fontSize: '16px',
fontWeight: 500
},
closeButton: {
background: 'transparent',
border: 'none',
color: 'rgba(255, 255, 255, 0.8)',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
body: {
padding: '24px 20px',
color: 'white'
},
// 标题样式
titleContainer: {
padding: '0 0 16px 0',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
marginBottom: '20px'
},
title: {
color: 'rgba(255, 255, 255, 0.95)',
margin: '0 0 4px 0',
fontSize: '18px',
fontWeight: 500,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
},
date: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: '13px'
},
// 描述区域
descSection: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
backdropFilter: 'blur(10px) saturate(180%)',
WebkitBackdropFilter: 'blur(10px) saturate(180%)',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
},
descText: {
color: 'rgba(255, 255, 255, 0.9)',
lineHeight: '1.6',
display: '-webkit-box',
WebkitBoxOrient: 'vertical' as const,
overflow: 'hidden',
textOverflow: 'ellipsis',
WebkitLineClamp: expandDescription ? 'unset' : 8
},
expandButton: {
background: 'transparent',
border: 'none',
color: 'rgba(255, 255, 255, 0.7)',
cursor: 'pointer',
padding: '8px 0 0 0',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'center'
},
// 标签区域
tagsSection: {
marginBottom: '20px',
padding: '0 4px'
},
tagTitle: {
color: 'rgba(255, 255, 255, 0.8)',
fontSize: '14px',
fontWeight: 500,
marginBottom: '12px'
},
tagItem: {
backgroundColor: 'rgba(255, 255, 255, 0.15)',
borderRadius: '16px',
color: 'rgba(255, 255, 255, 0.9)',
border: 'none',
padding: '4px 12px',
margin: '0 8px 8px 0',
display: 'inline-block',
fontSize: '12px'
},
// 规格信息区
specsSection: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
backdropFilter: 'blur(10px) saturate(180%)',
WebkitBackdropFilter: 'blur(10px) saturate(180%)',
padding: '16px',
borderRadius: '8px',
margin: '16px 0 20px',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
},
specsContainer: {
display: 'flex',
justifyContent: 'space-around',
textAlign: 'center' as const
},
specItem: {
padding: '0 8px',
flex: 1
},
specValue: {
fontSize: '15px',
fontWeight: 500,
color: 'rgba(255, 255, 255, 0.95)',
marginBottom: '4px',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
},
specLabel: {
fontSize: '12px',
color: 'rgba(255, 255, 255, 0.6)'
},
// EXIF信息区
exifContainer: {
marginTop: '10px'
},
exifCategory: {
marginBottom: '20px'
},
divider: {
borderColor: 'rgba(255, 255, 255, 0.08)',
margin: '10px 0 16px',
fontSize: '14px',
color: 'rgba(255, 255, 255, 0.8)',
fontWeight: 500
},
exifTable: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.1)'
},
exifLabel: {
color: 'rgba(255, 255, 255, 0.7)',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
padding: '8px 12px',
width: '100px',
fontSize: '13px'
},
exifValue: {
color: 'rgba(255, 255, 255, 0.9)',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
padding: '8px 12px',
fontSize: '13px'
}
};
return (
<div style={styles.drawer}>
<div style={styles.header}>
<h3 style={styles.headerTitle}></h3>
<button style={styles.closeButton} onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div style={styles.body}>
<div style={styles.titleContainer}>
<h4 style={styles.title}>{image?.name}</h4>
<div style={styles.date}>{new Date(image?.createdAt).toLocaleString()}</div>
</div>
{image?.description && (
<div style={styles.descSection}>
<div style={styles.descText}>{image.description}</div>
{image.description.split('\n').length > 8 || image.description.length > 200 ? (
<button style={styles.expandButton} onClick={toggleDescription}>
{expandDescription ? (
<> <UpOutlined style={{ fontSize: '12px', marginLeft: '4px' }} /></>
) : (
<> <DownOutlined style={{ fontSize: '12px', marginLeft: '4px' }} /></>
)}
</button>
) : null}
</div>
)}
{image?.tags && image.tags.length > 0 && (
<div style={styles.tagsSection}>
<div style={styles.tagTitle}></div>
<div>
{image.tags.map(tag => (
<span key={tag} style={styles.tagItem}>#{tag}</span>
))}
</div>
</div>
)}
{image?.exifInfo && (
<div style={styles.specsSection}>
<div style={styles.specsContainer}>
<div style={styles.specItem}>
<div style={styles.specValue}>{image.exifInfo.width}×{image.exifInfo.height}</div>
<div style={styles.specLabel}></div>
</div>
{image.exifInfo.focalLength && (
<div style={styles.specItem}>
<div style={styles.specValue}>{image.exifInfo.focalLength}</div>
<div style={styles.specLabel}></div>
</div>
)}
</div>
</div>
)}
{/* 渲染EXIF信息 */}
{renderExifInfo(styles)}
</div>
</div>
);
};
export default ImageInfo;

View File

@@ -0,0 +1,368 @@
.image-viewer-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease;
opacity: 0;
}
.image-viewer-container.visible {
opacity: 1;
}
.viewer-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
z-index: 1001;
}
.viewer-content {
position: relative;
z-index: 1002;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.viewer-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1004;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 80%, rgba(0,0,0,0) 100%);
backdrop-filter: blur(8px);
}
.image-counter {
color: white;
font-size: 16px;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-btn {
color: white !important;
border: none;
background: transparent !important;
border-radius: 50%;
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.header-btn:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
.image-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1003;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
}
.image-transform-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: visible;
position: relative;
}
.viewer-img {
max-width: 100%;
max-height: 100%;
user-select: none;
-webkit-user-drag: none;
will-change: transform;
}
.viewer-img.dragging {
cursor: grabbing !important;
}
.zoom-controls {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
z-index: 1005;
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
padding: 8px 12px;
border-radius: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.zoom-controls .ant-btn {
color: white !important;
border: none;
background: transparent !important;
border-radius: 50%;
margin: 0 2px;
}
.zoom-controls .ant-btn:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
.nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 1005;
background: rgba(0, 0, 0, 0.3) !important;
backdrop-filter: blur(4px);
color: white !important;
border: none !important;
width: 44px;
height: 44px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.prev-button {
left: 20px;
}
.next-button {
right: 20px;
}
.nav-button:hover {
background-color: rgba(0, 0, 0, 0.7) !important;
}
.viewer-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 1004;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 80%, rgba(0,0,0,0) 100%);
backdrop-filter: blur(8px);
}
.image-name {
color: white;
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-actions {
display: flex;
gap: 8px;
}
.footer-btn {
color: white !important;
border: none;
background: transparent !important;
font-size: 18px;
border-radius: 50%;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.footer-btn:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
.footer-btn span {
margin-left: 4px;
font-size: 14px;
}
.image-info-drawer {
position: absolute !important;
z-index: 1050 !important;
}
.image-info-drawer .ant-drawer-mask {
background-color: transparent !important;
}
.image-info-drawer .ant-drawer-header {
background-color: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(15px);
border-bottom: 1px solid rgba(240, 240, 240, 0.6);
}
.drawer-content {
padding: 16px 0;
}
.image-title-section {
margin-bottom: 16px;
}
.image-title-section h4 {
margin-bottom: 4px;
}
.image-description-section {
margin-bottom: 16px;
padding: 16px;
background: rgba(249, 249, 249, 0.5) !important;
border-radius: 4px;
backdrop-filter: blur(10px) !important;
}
.image-tags-section {
margin-bottom: 16px;
}
.tag-title {
margin-bottom: 8px;
font-weight: 500;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.exif-description {
font-size: 13px;
}
.exif-description .ant-descriptions-item-label {
width: 100px;
}
.image-specs-section {
margin: 16px 0;
padding: 16px;
background: rgba(247, 249, 250, 0.5) !important;
border-radius: 8px;
backdrop-filter: blur(10px) !important;
}
.specs-container {
display: flex;
justify-content: space-around;
text-align: center;
}
.spec-item {
flex: 1;
padding: 0 8px;
}
.spec-value {
font-size: 16px;
font-weight: 500;
color: #262626;
margin-bottom: 4px;
}
.spec-label {
font-size: 12px;
color: #8c8c8c;
}
.exif-sections {
margin-top: 20px;
}
.exif-category {
margin-bottom: 24px;
}
.exif-category .ant-divider {
margin: 16px 0;
}
.exif-description {
font-size: 13px;
}
.exif-description .ant-descriptions-item-label {
width: 90px;
font-weight: 500;
}
.image-info-drawer .ant-drawer-body {
padding: 24px 20px;
}
.exif-description .ant-descriptions-view {
background-color: rgba(255, 255, 255, 0.5) !important;
backdrop-filter: blur(10px) !important;
}
.exif-description .ant-descriptions-row > th,
.exif-description .ant-descriptions-row > td {
padding: 12px 16px;
}
.image-loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1004;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loading-text {
color: white;
font-size: 14px;
margin-top: 12px;
}
.image-loading-spinner .ant-spin {
color: white;
}
.image-loading-spinner .ant-spin-dot-item {
background-color: white;
}

View File

@@ -0,0 +1,654 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Button, Space, Dropdown, message, Spin } from 'antd';
import {
ZoomInOutlined, ZoomOutOutlined, ExpandOutlined, InfoCircleOutlined,
CloseOutlined, LeftOutlined, RightOutlined, RotateLeftOutlined,
RotateRightOutlined, HeartOutlined, HeartFilled, DownloadOutlined,
ShareAltOutlined, FolderAddOutlined
} from '@ant-design/icons';
import type { PictureResponse, AlbumResponse } from '../../api/types';
import { getAlbums, addPicturesToAlbum, favoritePicture, unfavoritePicture } from '../../api';
import ImageInfo from './ImageInfo';
import ShareImageDialog from './ShareImageDialog';
import './ImageViewer.css';
interface ImageViewerProps {
visible: boolean;
onClose: () => void;
images: PictureResponse[];
initialIndex?: number;
onFavorite?: (image: PictureResponse) => void;
onNext?: () => void;
onPrevious?: () => void;
showFavoriteCount?: boolean;
onShare?: (image: PictureResponse) => void;
}
interface ImageCache {
[key: string]: {
loaded: boolean;
img: HTMLImageElement;
}
}
interface ZoomPanState {
scale: number;
positionX: number;
positionY: number;
isDragging: boolean;
dragStartX: number;
dragStartY: number;
lastPositionX: number;
lastPositionY: number;
}
const ImageViewer: React.FC<ImageViewerProps> = ({
visible,
onClose,
images,
initialIndex = 0,
onFavorite,
onNext,
onPrevious,
showFavoriteCount = false,
onShare,
}) => {
const wasVisible = useRef(visible);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isInfoDrawerOpen, setIsInfoDrawerOpen] = useState(false);
const [rotation, setRotation] = useState(0);
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
const [loadingAlbums, setLoadingAlbums] = useState(false);
const [localImages, setLocalImages] = useState<PictureResponse[]>(images);
const [shareDialogVisible, setShareDialogVisible] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [currentLoading, setCurrentLoading] = useState(false);
const [zoomPanState, setZoomPanState] = useState<ZoomPanState>({
scale: 1,
positionX: 0,
positionY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
lastPositionX: 0,
lastPositionY: 0,
});
const imageContainerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const imageCache = useRef<ImageCache>({});
const sessionKey = useRef<string>(Date.now().toString());
const currentImage = localImages[currentIndex];
const preloadRange = 2;
const MIN_SCALE = 0.1;
const MAX_SCALE = 8;
const ZOOM_FACTOR = 0.2;
const resetViewerState = useCallback(() => {
setRotation(0);
setIsInfoDrawerOpen(false);
setImageLoaded(false);
setZoomPanState({
scale: 1,
positionX: 0,
positionY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
lastPositionX: 0,
lastPositionY: 0,
});
}, []);
// 当前加载中的图片URL追踪
const currentLoadingUrl = useRef<string | null>(null);
// 预渲染图片容器
const preloadedImagesRef = useRef<{[key: string]: HTMLImageElement}>({});
// 图片过渡状态
const [fadeTransition, setFadeTransition] = useState(false);
const [, setActiveImage] = useState<string | null>(null);
const loadImage = useCallback((imageUrl: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
// 检查缓存
if (imageCache.current[imageUrl]?.loaded) {
if (currentImage && imageUrl === currentImage.path) {
setImageLoaded(true);
setActiveImage(imageUrl);
}
return resolve(imageCache.current[imageUrl].img);
}
const img = new Image();
img.onload = () => {
imageCache.current[imageUrl] = {
loaded: true,
img
};
preloadedImagesRef.current[imageUrl] = img;
if (imageUrl === currentLoadingUrl.current) {
setImageLoaded(true);
setCurrentLoading(false);
setActiveImage(imageUrl);
}
resolve(img);
};
img.onerror = () => {
if (imageUrl === currentLoadingUrl.current) {
setCurrentLoading(false);
}
reject(new Error(`Failed to load image: ${imageUrl}`));
};
img.src = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_s=${sessionKey.current}`;
});
}, [currentImage]);
// 图片切换逻辑优化
useEffect(() => {
setImageLoaded(false);
setCurrentLoading(true);
setFadeTransition(true);
// 利用缓存快速显示
if (currentImage && imageCache.current[currentImage.path]?.loaded) {
setActiveImage(currentImage.path);
setImageLoaded(true);
setCurrentLoading(false);
setTimeout(() => setFadeTransition(false), 100);
}
// 重置缩放状态
setZoomPanState(prev => ({
...prev,
scale: 1,
positionX: 0,
positionY: 0,
isDragging: false
}));
}, [currentIndex]);
// 可见性变化处理
useEffect(() => {
if (visible && !wasVisible.current) {
resetViewerState();
if (!sessionKey.current) {
sessionKey.current = Date.now().toString();
}
}
wasVisible.current = visible;
}, [visible, resetViewerState]);
// 初始索引处理
useEffect(() => {
if (visible && initialIndex >= 0 && initialIndex < images.length) {
setCurrentIndex(initialIndex);
}
}, [visible, initialIndex, images.length]);
// 图片加载逻辑
useEffect(() => {
if (!currentImage || !visible) return;
currentLoadingUrl.current = currentImage.path;
setCurrentLoading(true);
loadImage(currentImage.path)
.then(() => {
if (currentLoadingUrl.current === currentImage.path) {
setImageLoaded(true);
setCurrentLoading(false);
setActiveImage(currentImage.path);
setTimeout(() => setFadeTransition(false), 100);
}
})
.catch(error => {
console.error('Failed to load image:', error);
message.error('图片加载失败,请重试');
setCurrentLoading(false);
});
// 预加载相邻图片
if (localImages.length > 1) {
setTimeout(() => {
for (let i = 1; i <= preloadRange; i++) {
const nextIndex = currentIndex + i;
if (nextIndex < localImages.length) {
loadImage(localImages[nextIndex].path).catch(() => {});
}
const prevIndex = currentIndex - i;
if (prevIndex >= 0) {
loadImage(localImages[prevIndex].path).catch(() => {});
}
}
}, 300);
}
}, [currentImage, visible, currentIndex, localImages, loadImage]);
useEffect(() => {
setLocalImages(images);
}, [images]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!visible) return;
switch (e.key) {
case 'ArrowLeft': handlePrevious(); break;
case 'ArrowRight': handleNext(); break;
case 'Escape': onClose(); break;
case 'i': setIsInfoDrawerOpen(prev => !prev); break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [visible, currentIndex, images.length]);
const handlePrevious = useCallback(() => {
if (currentIndex > 0) {
setCurrentIndex(prevIndex => prevIndex - 1);
onPrevious?.();
}
}, [currentIndex, onPrevious]);
const handleNext = useCallback(() => {
if (currentIndex < images.length - 1) {
setCurrentIndex(prevIndex => prevIndex + 1);
onNext?.();
}
}, [currentIndex, images.length, onNext]);
const handleFavoriteClick = useCallback(async () => {
if (!currentImage) return;
try {
if (onFavorite) {
onFavorite(currentImage);
return;
}
const isFavorited = currentImage.isFavorited;
const result = isFavorited
? await unfavoritePicture(currentImage.id)
: await favoritePicture(currentImage.id);
if (result.success) {
message.success(isFavorited ? '已取消收藏' : '已添加到收藏');
setLocalImages(prevImages =>
prevImages.map(img =>
img.id === currentImage.id ? {
...img,
isFavorited: !isFavorited,
favoriteCount: isFavorited
? Math.max(0, (img.favoriteCount || 0) - 1)
: (img.favoriteCount || 0) + 1
} : img
)
);
} else {
message.error(result.message || (isFavorited ? '取消收藏失败' : '收藏失败'));
}
} catch (error) {
console.error('收藏操作失败:', error);
message.error('操作失败,请重试');
}
}, [currentImage, onFavorite]);
useEffect(() => {
if (visible) {
loadAlbums();
}
}, [visible]);
const loadAlbums = async () => {
setLoadingAlbums(true);
try {
const result = await getAlbums(1, 100);
if (result.success && result.data) {
setAlbums(result.data);
}
} catch (error) {
console.error('加载相册失败:', error);
} finally {
setLoadingAlbums(false);
}
};
const handleAddToAlbum = async (albumId: number) => {
if (!currentImage) return;
try {
const result = await addPicturesToAlbum(albumId, [currentImage.id]);
message.success(result.success ? '已添加到相册' : (result.message || '添加到相册失败'));
} catch (error) {
console.error('添加到相册失败:', error);
message.error('添加到相册失败,请重试');
}
};
const albumItems = albums.map(album => ({
key: album.id,
label: album.name,
onClick: () => handleAddToAlbum(album.id)
}));
const handleShareClick = useCallback(() => {
if (!currentImage) return;
onShare ? onShare(currentImage) : setShareDialogVisible(true);
}, [currentImage, onShare]);
const zoomIn = useCallback((factor = ZOOM_FACTOR) => {
setZoomPanState(prev => ({
...prev,
scale: Math.min(MAX_SCALE, prev.scale * (1 + factor))
}));
}, []);
const zoomOut = useCallback((factor = ZOOM_FACTOR) => {
setZoomPanState(prev => ({
...prev,
scale: Math.max(MIN_SCALE, prev.scale / (1 + factor))
}));
}, []);
const resetTransform = useCallback(() => {
setZoomPanState({
scale: 1,
positionX: 0,
positionY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
lastPositionX: 0,
lastPositionY: 0,
});
}, []);
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.preventDefault();
const delta = e.deltaY || e.deltaX;
const scaleFactor = delta > 0 ? 0.9 : 1.1;
setZoomPanState(prev => {
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * scaleFactor));
const rect = imageContainerRef.current?.getBoundingClientRect();
if (!rect) return { ...prev, scale: newScale };
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const containerCenterX = rect.width / 2;
const containerCenterY = rect.height / 2;
const dx = (mouseX - containerCenterX - prev.positionX) * (scaleFactor - 1);
const dy = (mouseY - containerCenterY - prev.positionY) * (scaleFactor - 1);
return {
...prev,
scale: newScale,
positionX: prev.positionX - dx,
positionY: prev.positionY - dy,
};
});
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setZoomPanState(prev => ({
...prev,
isDragging: true,
dragStartX: e.clientX,
dragStartY: e.clientY,
lastPositionX: prev.positionX,
lastPositionY: prev.positionY
}));
}, []);
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length === 1) {
const touch = e.touches[0];
setZoomPanState(prev => ({
...prev,
isDragging: true,
dragStartX: touch.clientX,
dragStartY: touch.clientY,
lastPositionX: prev.positionX,
lastPositionY: prev.positionY
}));
}
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (zoomPanState.isDragging) {
const dx = e.clientX - zoomPanState.dragStartX;
const dy = e.clientY - zoomPanState.dragStartY;
setZoomPanState(prev => ({
...prev,
positionX: prev.lastPositionX + dx,
positionY: prev.lastPositionY + dy
}));
}
}, [zoomPanState.isDragging, zoomPanState.dragStartX, zoomPanState.dragStartY, zoomPanState.lastPositionX, zoomPanState.lastPositionY]);
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
if (zoomPanState.isDragging && e.touches.length === 1) {
const touch = e.touches[0];
const dx = touch.clientX - zoomPanState.dragStartX;
const dy = touch.clientY - zoomPanState.dragStartY;
setZoomPanState(prev => ({
...prev,
positionX: prev.lastPositionX + dx,
positionY: prev.lastPositionY + dy
}));
}
}, [zoomPanState.isDragging, zoomPanState.dragStartX, zoomPanState.dragStartY, zoomPanState.lastPositionX, zoomPanState.lastPositionY]);
const handleMouseUp = useCallback(() => {
setZoomPanState(prev => ({ ...prev, isDragging: false }));
}, []);
const handleTouchEnd = useCallback(() => {
setZoomPanState(prev => ({ ...prev, isDragging: false }));
}, []);
const handleDoubleClick = useCallback(() => {
resetTransform();
}, [resetTransform]);
useEffect(() => {
if (visible) {
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('mouseleave', handleMouseUp);
window.addEventListener('touchend', handleTouchEnd);
window.addEventListener('touchcancel', handleTouchEnd);
return () => {
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('mouseleave', handleMouseUp);
window.removeEventListener('touchend', handleTouchEnd);
window.removeEventListener('touchcancel', handleTouchEnd);
};
}
}, [visible, handleMouseUp, handleTouchEnd]);
// 渲染优化:减少重绘和提高性能
if (images.length === 0 || !currentImage) {
return null;
}
const getImageUrl = (path: string) => {
return `${path}${path.includes('?') ? '&' : '?'}_s=${sessionKey.current}`;
};
return (
<div
className={`image-viewer-container ${visible ? 'visible' : ''}`}
style={{ display: visible ? 'block' : 'none' }}
>
<div className="viewer-overlay" onClick={onClose}></div>
<div className="viewer-content">
<div
className="image-container"
ref={imageContainerRef}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onDoubleClick={handleDoubleClick}
>
<div className="image-transform-wrapper">
{currentImage && (
<img
ref={imageRef}
src={getImageUrl(currentImage.path)}
alt={currentImage.name}
style={{
transform: `translate(${zoomPanState.positionX}px, ${zoomPanState.positionY}px) rotate(${rotation}deg) scale(${zoomPanState.scale})`,
opacity: imageLoaded ? 1 : 0.3,
transition: zoomPanState.isDragging ? 'none' :
fadeTransition ? 'opacity 0.15s ease, transform 0.1s ease-out' :
'transform 0.1s ease-out',
cursor: zoomPanState.scale > 1 ? 'grab' : 'auto',
transformOrigin: 'center center',
willChange: 'opacity, transform'
}}
className="viewer-img"
loading="eager"
/>
)}
</div>
{(!imageLoaded || currentLoading) && (
<div className="image-loading-spinner">
<Spin size="large" tip={<span className="loading-text">...</span>} />
</div>
)}
<div className="zoom-controls">
<Space>
<Button icon={<ExpandOutlined />} onClick={resetTransform} />
<Button icon={<ZoomOutOutlined />} onClick={() => zoomOut()} />
<Button icon={<ZoomInOutlined />} onClick={() => zoomIn()} />
<Button icon={<RotateLeftOutlined />} onClick={() => setRotation(prev => prev - 90)} />
<Button icon={<RotateRightOutlined />} onClick={() => setRotation(prev => prev + 90)} />
</Space>
</div>
</div>
{currentIndex > 0 && (
<Button
className="nav-button prev-button"
icon={<LeftOutlined />}
onClick={handlePrevious}
shape="circle"
size="large"
/>
)}
{currentIndex < images.length - 1 && (
<Button
className="nav-button next-button"
icon={<RightOutlined />}
onClick={handleNext}
shape="circle"
size="large"
/>
)}
</div>
<div className="viewer-header">
<div className="image-counter">
{currentIndex + 1} / {images.length}
</div>
<div className="header-actions">
<Button
type="text"
icon={isInfoDrawerOpen ? <InfoCircleOutlined style={{ color: '#1890ff' }} /> : <InfoCircleOutlined />}
onClick={() => setIsInfoDrawerOpen(prev => !prev)}
className="header-btn"
/>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className="header-btn"
/>
</div>
</div>
<div className="viewer-footer">
<div className="image-name">{currentImage.name}</div>
<div className="footer-actions">
{onFavorite && (
<Button
type="text"
icon={currentImage.isFavorited ?
<HeartFilled style={{ color: '#ff4d4f' }} /> :
<HeartOutlined style={{ color: '#fff' }} />
}
onClick={handleFavoriteClick}
className="footer-btn"
>
{showFavoriteCount && typeof currentImage.favoriteCount === 'number' && (
<span>{currentImage.favoriteCount}</span>
)}
</Button>
)}
<Dropdown menu={{ items: albumItems }} disabled={loadingAlbums || albums.length === 0}>
<Button
type="text"
icon={<FolderAddOutlined style={{ color: '#fff' }} />}
className="footer-btn"
/>
</Dropdown>
<Button
type="text"
icon={<DownloadOutlined style={{ color: '#fff' }} />}
onClick={() => window.open(currentImage.path, '_blank')}
className="footer-btn"
/>
<Button
type="text"
icon={<ShareAltOutlined style={{ color: '#fff' }} />}
onClick={handleShareClick}
className="footer-btn"
/>
</div>
</div>
{currentImage && (
<ImageInfo
image={currentImage}
visible={isInfoDrawerOpen}
onClose={() => setIsInfoDrawerOpen(false)}
/>
)}
{!onShare && currentImage && (
<ShareImageDialog
visible={shareDialogVisible}
onClose={() => setShareDialogVisible(false)}
image={currentImage}
/>
)}
</div>
);
};
export default ImageViewer;

View File

@@ -0,0 +1,72 @@
.share-image-dialog .ant-modal-body {
padding: 24px;
}
.share-image-content {
display: flex;
flex-direction: column;
}
.share-image-preview {
display: flex;
justify-content: center;
margin-bottom: 16px;
background-color: #f0f0f0;
border-radius: 8px;
padding: 16px;
overflow: hidden;
}
.share-preview-img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.share-image-controls {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.share-type-switch {
margin-bottom: 16px;
}
.share-tabs {
margin-top: 8px;
}
.share-tab-content {
width: 100%;
}
.share-input-group {
display: flex;
width: 100%;
margin-top: 8px;
}
.share-input {
flex: 1;
}
.share-copy-btn {
width: 40px;
transition: all 0.3s;
}
.share-copy-btn.copied {
background-color: #52c41a;
border-color: #52c41a;
color: white;
}
/* 响应式调整 */
@media (max-width: 576px) {
.share-image-dialog {
max-width: 90%;
}
}

View File

@@ -0,0 +1,168 @@
import React, { useState, useRef } from 'react';
import { Modal, Tabs, Input, Button, message, Radio, Space, Typography } from 'antd';
import { CopyOutlined, CheckOutlined } from '@ant-design/icons';
import type { PictureResponse } from '../../api/types';
import './ShareImageDialog.css';
const { Text } = Typography;
interface ShareImageDialogProps {
visible: boolean;
onClose: () => void;
image: PictureResponse | null;
}
const ShareImageDialog: React.FC<ShareImageDialogProps> = ({
visible,
onClose,
image
}) => {
const [imageType, setImageType] = useState<'original' | 'thumbnail'>('original');
const [copied, setCopied] = useState<Record<string, boolean>>({});
const timerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
if (!image) return null;
// 构建图片链接
const imagePath = imageType === 'original' ? image.path : (image.thumbnailPath || image.path);
const imageUrl = new URL(imagePath, window.location.origin).href;
const imageName = image.name || 'image';
// 构建不同格式的链接 - 移到这里确保随imageType变化而更新
const linkFormats = {
direct: imageUrl,
markdown: `![${imageName}](${imageUrl})`,
html: `<img src="${imageUrl}" alt="${imageName}" />`
};
const handleCopy = async (text: string, key: string) => {
try {
await navigator.clipboard.writeText(text);
// 设置复制状态
setCopied(prev => ({...prev, [key]: true}));
// 清除现有定时器
if (timerRef.current[key]) {
clearTimeout(timerRef.current[key]);
}
// 设置新定时器
timerRef.current[key] = setTimeout(() => {
setCopied(prev => ({...prev, [key]: false}));
}, 2000);
message.success('已复制到剪贴板');
} catch (error) {
message.error('复制失败,请手动复制');
}
};
// 定义标签页内容
const tabItems = [
{
key: 'direct',
label: '直接链接',
children: (
<Space direction="vertical" className="share-tab-content" style={{ width: '100%' }}>
<Text type="secondary">访</Text>
<Space.Compact style={{ width: '100%' }}>
<Input
value={linkFormats.direct}
readOnly
style={{ flex: 1 }}
/>
<Button
icon={copied.direct ? <CheckOutlined /> : <CopyOutlined />}
onClick={() => handleCopy(linkFormats.direct, 'direct')}
className={copied.direct ? 'copied' : ''}
/>
</Space.Compact>
</Space>
)
},
{
key: 'markdown',
label: 'Markdown',
children: (
<Space direction="vertical" className="share-tab-content" style={{ width: '100%' }}>
<Text type="secondary">Markdown文档的图片引用</Text>
<Space.Compact style={{ width: '100%' }}>
<Input
value={linkFormats.markdown}
readOnly
style={{ flex: 1 }}
/>
<Button
icon={copied.markdown ? <CheckOutlined /> : <CopyOutlined />}
onClick={() => handleCopy(linkFormats.markdown, 'markdown')}
className={copied.markdown ? 'copied' : ''}
/>
</Space.Compact>
</Space>
)
},
{
key: 'html',
label: 'HTML',
children: (
<Space direction="vertical" className="share-tab-content" style={{ width: '100%' }}>
<Text type="secondary">HTML图片标签</Text>
<Space.Compact style={{ width: '100%' }}>
<Input
value={linkFormats.html}
readOnly
style={{ flex: 1 }}
/>
<Button
icon={copied.html ? <CheckOutlined /> : <CopyOutlined />}
onClick={() => handleCopy(linkFormats.html, 'html')}
className={copied.html ? 'copied' : ''}
/>
</Space.Compact>
</Space>
)
}
];
return (
<Modal
title="分享图片"
open={visible}
onCancel={onClose}
footer={null}
className="share-image-dialog"
width={520}
>
<div className="share-image-content">
<div className="share-image-preview">
<img
src={imagePath}
alt={imageName}
className="share-preview-img"
/>
</div>
<div className="share-image-controls">
<Radio.Group
value={imageType}
onChange={e => setImageType(e.target.value)}
buttonStyle="solid"
className="share-type-switch"
>
<Radio.Button value="original"></Radio.Button>
<Radio.Button value="thumbnail"></Radio.Button>
</Radio.Group>
</div>
<Tabs
defaultActiveKey="direct"
className="share-tabs"
items={tabItems}
/>
</div>
</Modal>
);
};
export default ShareImageDialog;

View File

@@ -0,0 +1,100 @@
.search-dialog .ant-modal-content {
border-radius: 8px;
overflow: hidden;
}
.search-dialog .ant-modal-header {
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.search-dialog-title {
display: flex;
align-items: center;
font-weight: 500;
}
.search-dialog .ant-modal-body {
padding: 24px;
max-height: 80vh;
overflow-y: auto;
}
/* 标签页样式 */
.search-tabs {
margin-bottom: 16px;
}
/* 搜索输入区域 */
.search-input-container {
margin-bottom: 16px;
}
/* 选项分组 */
.search-option-group {
background-color: #fafafa;
border-radius: 2px;
padding: 12px;
margin-bottom: 16px;
border: 1px solid #f0f0f0;
}
.option-label {
display: block;
margin-bottom: 8px;
}
/* 向量搜索样式 */
.vector-switch-container {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.vector-switch {
margin-right: 8px;
}
.threshold-slider-container {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.threshold-slider {
margin: 12px 0;
}
/* 搜索按钮 */
.search-button-container {
margin-top: 16px;
}
/* 搜索结果区域 */
.search-results {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.search-results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.search-results-header h4 {
margin: 0;
}
.result-count {
color: #1890ff;
}
/* 响应式调整 */
@media (max-width: 768px) {
.vector-options .ant-col {
margin-bottom: 12px;
}
}

View File

@@ -0,0 +1,304 @@
import React, { useState, useEffect } from 'react';
import { Modal, Input, Tabs, Switch, Select, Slider, Space, Button, Typography, Tooltip, message, Divider } from 'antd';
import { SearchOutlined, FileImageOutlined, ClearOutlined } from '@ant-design/icons';
import ImageGrid from '../image/ImageGrid';
import type { PictureResponse } from '../../api';
import './SearchDialog.css';
const { Text, Title } = Typography;
interface SearchDialogProps {
visible: boolean;
onClose: () => void;
initialSearchText?: string;
}
const SearchDialog: React.FC<SearchDialogProps> = ({
visible,
onClose,
initialSearchText = ''
}) => {
// 搜索参数状态
const [searchText, setSearchText] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [useVectorSearch, setUseVectorSearch] = useState(true);
const [similarityThreshold, setSimilarityThreshold] = useState(0.35);
const [activeTabKey, setActiveTabKey] = useState('vector');
// 搜索结果状态
const [loading, setLoading] = useState(false);
const [searchPerformed, setSearchPerformed] = useState(false);
const [, setSearchResults] = useState<PictureResponse[]>([]);
const [totalResults, setTotalResults] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
// 添加搜索标识符,用于强制触发新搜索
const [searchId, setSearchId] = useState(1);
// 使用activeSearchParams存储当前搜索参数
const [activeSearchParams, setActiveSearchParams] = useState({
searchQuery: '',
tags: [] as string[],
useVectorSearch: true,
similarityThreshold: 0.35,
_searchId: 1, // 添加搜索ID用于区分不同搜索请求
});
// 示例标签选项实际应用中可能需要从API获取
const tagOptions = [
{ value: 'nature', label: '自然' },
{ value: 'city', label: '城市' },
{ value: 'people', label: '人物' },
{ value: 'animals', label: '动物' },
{ value: 'food', label: '美食' },
{ value: 'travel', label: '旅行' },
{ value: 'architecture', label: '建筑' },
];
// 当对话框打开或初始搜索文本变更时,更新搜索框中的文本,但不自动搜索
useEffect(() => {
if (visible && initialSearchText) {
setSearchText(initialSearchText);
}
}, [visible, initialSearchText]);
// 重置搜索表单但保持向量搜索默认设置
const resetSearch = () => {
setSearchText('');
setSelectedTags([]);
setUseVectorSearch(true); // 保持默认使用向量搜索
setSimilarityThreshold(0.35); // 保持默认阈值
setSearchPerformed(false);
setSearchResults([]);
setTotalResults(0);
setCurrentPage(1);
setActiveTabKey('vector'); // 保持默认选择向量搜索标签
setActiveSearchParams({
searchQuery: '',
tags: [],
useVectorSearch: true,
similarityThreshold: 0.35,
_searchId: searchId, // 保持当前searchId或重置为1
});
};
// 当对话框关闭时重置搜索表单
useEffect(() => {
if (!visible) {
resetSearch();
}
}, [visible]);
// 执行搜索 - 修改为仅在点击搜索按钮时调用
const handleSearch = () => {
// 搜索前先检查是否有搜索条件,避免空搜索
if (!searchText.trim() && selectedTags.length === 0 && !useVectorSearch) {
message.info('请输入搜索关键词或选择搜索条件');
return;
}
// 增加搜索ID确保每次搜索都是唯一的
const newSearchId = searchId + 1;
setSearchId(newSearchId);
setLoading(true);
setSearchPerformed(true);
// 更新活动搜索参数这将触发ImageGrid组件进行搜索
setActiveSearchParams({
searchQuery: searchText,
tags: selectedTags,
useVectorSearch,
similarityThreshold: useVectorSearch ? similarityThreshold : 0.35,
_searchId: newSearchId, // 添加唯一标识符以强制触发新搜索
});
};
// 处理图片加载完成事件
const handleImagesLoaded = (images: PictureResponse[], totalCount: number) => {
setSearchResults(images);
setTotalResults(totalCount);
setLoading(false); // 图片加载完成后关闭加载状态
// 如果搜索结果为空且已执行搜索,显示友好提示
if (images.length === 0 && searchPerformed) {
message.info('没有找到匹配的图片');
}
};
return (
<Modal
title={
<div className="search-dialog-title">
<SearchOutlined style={{ marginRight: 10 }} />
</div>
}
open={visible}
onCancel={onClose}
width={1000}
footer={null}
className="search-dialog"
destroyOnHidden
>
<Tabs
activeKey={activeTabKey}
onChange={setActiveTabKey}
className="search-tabs"
items={[
{
key: "text",
label: <span><SearchOutlined /> </span>,
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div className="search-input-container">
<Input.Search
placeholder="输入关键词搜索图片..."
value={searchText}
onChange={e => setSearchText(e.target.value)}
onSearch={handleSearch}
enterButton
allowClear
size="large"
autoFocus={activeTabKey === 'text'}
/>
</div>
<Divider orientation="left" plain></Divider>
<div className="search-option-group">
<Text strong className="option-label">:</Text>
<Select
mode="multiple"
style={{ width: '100%', marginTop: 8 }}
placeholder="选择标签进行筛选"
value={selectedTags}
onChange={setSelectedTags}
options={tagOptions}
maxTagCount={5}
/>
</div>
<div className="search-button-container">
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
size="large"
block
disabled={loading}
loading={loading}
>
{loading ? '搜索中...' : '开始搜索'}
</Button>
</div>
</Space>
)
},
{
key: "vector",
label: <span><FileImageOutlined /> </span>,
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div className="search-input-container">
<Input.Search
placeholder="输入关键词辅助搜索..."
value={searchText}
onChange={e => setSearchText(e.target.value)}
onSearch={handleSearch}
enterButton
allowClear
size="large"
autoFocus={activeTabKey === 'vector'}
/>
</div>
<Divider orientation="left" plain></Divider>
<div className="search-option-group vector-options">
<div className="vector-switch-container">
<Switch
checked={useVectorSearch}
onChange={setUseVectorSearch}
/>
<Text strong style={{ marginLeft: 8 }}></Text>
<Tooltip title="向量搜索可以查找视觉上相似的图片,而不仅仅是匹配标签或文本">
<Text type="secondary" style={{ marginLeft: 8 }}>
()
</Text>
</Tooltip>
</div>
{useVectorSearch && (
<div className="threshold-slider-container">
<Text>{similarityThreshold.toFixed(2)}</Text>
<Slider
min={0.1}
max={1.0}
step={0.05}
value={similarityThreshold}
onChange={setSimilarityThreshold}
marks={{
0.1: '低',
0.5: '中',
0.9: '高'
}}
className="threshold-slider"
/>
<Text type="secondary" style={{ fontSize: '12px' }}>
</Text>
</div>
)}
</div>
<div className="search-button-container">
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
size="large"
block
disabled={loading}
loading={loading}
>
{loading ? '搜索中...' : '开始搜索'}
</Button>
</div>
</Space>
)
}
]}
/>
{searchPerformed && (
<div className="search-results">
<div className="search-results-header">
<Title level={4}>
<span className="result-count">({totalResults})</span>
</Title>
<Button
onClick={resetSearch}
disabled={loading}
icon={<ClearOutlined />}
>
</Button>
</div>
<ImageGrid
queryParams={activeSearchParams}
loading={loading}
onImagesLoaded={handleImagesLoaded}
defaultPage={currentPage}
onPageChange={(page) => setCurrentPage(page)}
emptyText="没有找到匹配的图片"
showPagination={totalResults > 0}
/>
</div>
)}
</Modal>
);
};
export default SearchDialog;

View File

@@ -0,0 +1,396 @@
import React, { useState, useEffect } from 'react';
import { Modal, Upload, Button, Progress, message, Form, Select, Radio, Slider } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import { v4 as uuidv4 } from 'uuid';
import type { UploadFile, UploadPictureParams, AlbumResponse } from '../../api';
import { uploadPicture, getAlbums } from '../../api';
const { Dragger } = Upload;
const { Option } = Select;
interface UploadDialogProps {
visible: boolean;
onClose: () => void;
onUploadComplete: () => void;
}
const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUploadComplete }) => {
const [uploadQueue, setUploadQueue] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const [form] = Form.useForm();
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
const [concurrentUploads, setConcurrentUploads] = useState<number>(3);
useEffect(() => {
if (visible) {
fetchAlbums();
}
}, [visible]);
const fetchAlbums = async () => {
try {
const result = await getAlbums();
if (result.success && result.data) {
setAlbums(result.data);
}
} catch (error) {
console.error('获取相册列表失败:', error);
}
};
const handleBeforeUpload = (file: File) => {
// 检查是否为图片文件
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error(`${file.name} 不是图片文件`);
return false;
}
// 限制文件大小,例如 10MB
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('图片大小不能超过 10MB!');
return false;
}
// 添加到上传队列
const newFile: UploadFile = {
id: uuidv4(),
file,
status: 'pending',
percent: 0
};
setUploadQueue((prev) => [...prev, newFile]);
return false; // 阻止自动上传
};
const uploadFiles = async () => {
if (uploadQueue.length === 0) {
message.warning('请先选择需要上传的图片');
return;
}
try {
setUploading(true);
const values = await form.validateFields();
const params: UploadPictureParams = {};
if (values.permission !== undefined) {
params.permission = values.permission;
}
if (values.albumId) {
params.albumId = values.albumId;
}
let successCount = 0;
let failCount = 0;
// 创建上传队列的副本
const queue = [...uploadQueue].filter(item => item.status !== 'success');
// 上传单个文件的函数
const uploadSingleFile = async (item: UploadFile) => {
// 更新状态为上传中
setUploadQueue((prev) =>
prev.map(file => file.id === item.id ? { ...file, status: 'uploading' } : file)
);
try {
// 上传文件
const result = await uploadPicture(item.file, {
...params,
onProgress: (percent) => {
setUploadQueue((prev) =>
prev.map(file => file.id === item.id ? { ...file, percent } : file)
);
}
});
if (result.success && result.data) {
// 更新为上传成功
setUploadQueue((prev) =>
prev.map(file => file.id === item.id ? {
...file,
status: 'success',
response: result.data,
percent: 100
} : file)
);
successCount++;
} else {
// 更新为上传失败
setUploadQueue((prev) =>
prev.map(file => file.id === item.id ? {
...file,
status: 'error',
error: result.message || '上传失败'
} : file)
);
failCount++;
}
} catch (error: any) {
// 更新为上传失败
setUploadQueue((prev) =>
prev.map(file => file.id === item.id ? {
...file,
status: 'error',
error: error.message || '上传失败'
} : file)
);
failCount++;
}
};
// 批量上传函数 - 支持并发控制
const batchUpload = async () => {
// 每次处理的批次大小
const batchSize = concurrentUploads;
while (queue.length > 0) {
// 取出当前批次的文件
const batch = queue.splice(0, batchSize);
// 并行上传当前批次的所有文件
await Promise.all(batch.map(item => uploadSingleFile(item)));
}
};
// 执行批量上传
await batchUpload();
// 显示上传结果
if (successCount > 0) {
if (failCount > 0) {
message.warning(`上传完成,成功 ${successCount} 张,失败 ${failCount}`);
} else {
message.success(`成功上传 ${successCount} 张图片`);
// 如果全部成功,清空队列并关闭对话框
setTimeout(() => {
setUploadQueue([]);
onClose();
onUploadComplete();
}, 1000);
}
} else {
message.error('上传失败,请重试');
}
} catch (error) {
console.error('表单验证或上传过程出错:', error);
message.error('上传失败,请检查表单信息');
} finally {
setUploading(false);
}
};
const handleRemove = (id: string) => {
setUploadQueue((prev) => prev.filter(file => file.id !== id));
};
const handleClose = () => {
// 如果正在上传,提示用户
if (uploading) {
Modal.confirm({
title: '确认取消',
content: '上传正在进行中,确定要取消吗?',
onOk: () => {
setUploading(false);
setUploadQueue([]);
onClose();
}
});
} else {
setUploadQueue([]);
onClose();
}
};
// 自定义上传列表项
const renderUploadItem = (item: UploadFile) => {
let statusIcon;
let statusColor;
switch(item.status) {
case 'success':
statusIcon = '✓';
statusColor = '#52c41a';
break;
case 'error':
statusIcon = '✗';
statusColor = '#ff4d4f';
break;
default:
statusIcon = '';
statusColor = '#1890ff';
}
return (
<div key={item.id} style={{
display: 'flex',
alignItems: 'center',
margin: '8px 0',
padding: '8px',
background: '#f9f9f9',
borderRadius: '4px'
}}>
<div style={{ marginRight: '8px', width: '40px', height: '40px' }}>
{item.file instanceof File && (
<img
src={URL.createObjectURL(item.file)}
alt={item.file.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '4px'
}}
/>
)}
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginBottom: '4px'
}}>
{item.file.name}
</div>
{item.status === 'uploading' && (
<Progress percent={Math.round(item.percent)} size="small" />
)}
{item.status === 'error' && item.error && (
<div style={{ color: '#ff4d4f', fontSize: '12px' }}>{item.error}</div>
)}
</div>
<div style={{ marginLeft: '8px' }}>
{item.status !== 'uploading' && (
<Button
type="text"
danger={item.status !== 'success'}
size="small"
onClick={() => handleRemove(item.id)}
disabled={uploading}
>
{item.status === 'success' ? '移除' : '删除'}
</Button>
)}
{statusIcon && (
<span style={{
marginLeft: '8px',
color: statusColor,
fontWeight: 'bold'
}}>
{statusIcon}
</span>
)}
</div>
</div>
);
};
return (
<Modal
title="上传图片"
open={visible}
onCancel={handleClose}
footer={[
<Button key="back" onClick={handleClose} disabled={uploading}>
</Button>,
<Button
key="submit"
type="primary"
loading={uploading}
onClick={uploadFiles}
>
{uploading ? '正在上传...' : '开始上传'}
</Button>,
]}
width={600}
>
<Form
form={form}
layout="vertical"
initialValues={{
permission: 2,
concurrentUploads: 3
}}
>
<Form.Item
name="albumId"
label="选择相册"
>
<Select placeholder="选择要上传到的相册" allowClear>
{albums.map(album => (
<Option key={album.id} value={album.id}>{album.name}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="permission"
label="图片权限"
>
<Radio.Group>
<Radio value={0}></Radio>
<Radio value={1}></Radio>
<Radio value={2}></Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="concurrentUploads"
label="并发上传数量"
>
<Slider
min={1}
max={10}
value={concurrentUploads}
onChange={(value) => setConcurrentUploads(value)}
marks={{ 1: '1', 5: '5', 10: '10' }}
/>
</Form.Item>
</Form>
<Dragger
beforeUpload={handleBeforeUpload}
multiple
showUploadList={false}
disabled={uploading}
accept="image/*"
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
10MB
</p>
</Dragger>
<div style={{ marginTop: '16px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<div> {uploadQueue.length} </div>
</div>
<div style={{
maxHeight: '200px',
overflowY: 'auto',
border: uploadQueue.length > 0 ? '1px solid #f0f0f0' : 'none',
borderRadius: '4px',
padding: uploadQueue.length > 0 ? '8px' : '0'
}}>
{uploadQueue.map(renderUploadItem)}
</div>
</div>
</Modal>
);
};
export default ImageUploadDialog;