mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-24 09:39:58 +08:00
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:
91
Web/src/components/TaskProgressBar.tsx
Normal file
91
Web/src/components/TaskProgressBar.tsx
Normal 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;
|
||||
57
Web/src/components/UserAvatar.tsx
Normal file
57
Web/src/components/UserAvatar.tsx
Normal 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;
|
||||
270
Web/src/components/image/ImageGrid.css
Normal file
270
Web/src/components/image/ImageGrid.css
Normal 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;
|
||||
}
|
||||
}
|
||||
693
Web/src/components/image/ImageGrid.tsx
Normal file
693
Web/src/components/image/ImageGrid.tsx
Normal 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;
|
||||
414
Web/src/components/image/ImageInfo.tsx
Normal file
414
Web/src/components/image/ImageInfo.tsx
Normal 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;
|
||||
368
Web/src/components/image/ImageViewer.css
Normal file
368
Web/src/components/image/ImageViewer.css
Normal 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;
|
||||
}
|
||||
654
Web/src/components/image/ImageViewer.tsx
Normal file
654
Web/src/components/image/ImageViewer.tsx
Normal 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;
|
||||
72
Web/src/components/image/ShareImageDialog.css
Normal file
72
Web/src/components/image/ShareImageDialog.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
168
Web/src/components/image/ShareImageDialog.tsx
Normal file
168
Web/src/components/image/ShareImageDialog.tsx
Normal 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: ``,
|
||||
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;
|
||||
100
Web/src/components/search/SearchDialog.css
Normal file
100
Web/src/components/search/SearchDialog.css
Normal 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;
|
||||
}
|
||||
}
|
||||
304
Web/src/components/search/SearchDialog.tsx
Normal file
304
Web/src/components/search/SearchDialog.tsx
Normal 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;
|
||||
396
Web/src/components/upload/ImageUploadDialog.tsx
Normal file
396
Web/src/components/upload/ImageUploadDialog.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user