新增批量上传功能

This commit is contained in:
Lrd
2026-06-15 17:46:14 +08:00
parent 91a802e9fa
commit b8fc287a5e
+507 -48
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react';
import { Button, Empty, Input, Select, Space, Typography, Tag, message } from 'antd';
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Upload, Modal, Progress } from 'antd';
import {
SearchOutlined,
FilterOutlined,
@@ -10,6 +10,8 @@ import {
DownloadOutlined,
XOutlined,
ClockCircleOutlined,
UploadOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { gethistory, gethistoryItems } from '../api';
@@ -31,6 +33,16 @@ const GeneratedRecord: React.FC = () => {
const [previewItem, setPreviewItem] = useState<any>(null);
const videoRef = React.createRef<HTMLVideoElement>();
// 批量上传相关状态
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [uploadFiles, setUploadFiles] = useState<any[]>([]);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
const [uploading, setUploading] = useState(false);
// 多选相关状态
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// 全局 Intersection Observer 实例(复用,避免创建过多实例)
let globalObserver: IntersectionObserver | null = null;
const observerCallbacks = new Map<HTMLElement, () => void>();
@@ -160,7 +172,10 @@ const GeneratedRecord: React.FC = () => {
item: any;
mediaType: 'video' | 'image';
onClick: () => void;
}> = ({ item, mediaType, onClick }) => {
isSelected?: boolean;
onToggleSelect?: (itemId: string) => void;
isSelectionMode?: boolean;
}> = ({ item, mediaType, onClick, isSelected = false, onToggleSelect, isSelectionMode = false }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isError, setIsError] = useState(false);
const [isExpired, setIsExpired] = useState(false);
@@ -251,14 +266,18 @@ const GeneratedRecord: React.FC = () => {
position: 'relative',
backgroundColor: '#f1f5f9',
}}
onClick={onClick}
onClick={!isSelectionMode ? onClick : undefined}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.2)';
if (!isSelectionMode) {
(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.2)';
}
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
if (!isSelectionMode) {
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
}
}}
>
{/* 加载占位符 - 显示渐变背景和加载状态 */}
@@ -408,7 +427,7 @@ const GeneratedRecord: React.FC = () => {
)}
{/* 点击提示 */}
{!isExpired && (
{!isExpired && !isSelectionMode && (
<div style={{
position: 'absolute',
bottom: 0,
@@ -431,6 +450,59 @@ const GeneratedRecord: React.FC = () => {
</div>
)}
{/* 选择模式下的选择框 */}
{isSelectionMode && (
<div
style={{
position: 'absolute',
top: 8,
right: 8,
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: isSelected ? '#10b981' : 'rgba(255,255,255,0.9)',
border: isSelected ? '2px solid #10b981' : '2px solid #d1d5db',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: 10,
transition: 'all 0.2s',
}}
onClick={(e) => {
e.stopPropagation();
onToggleSelect?.(item.id);
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
{isSelected && (
<svg width={12} height={12} viewBox="0 0 12 12" fill="none">
<path d="M10 3L4.5 8.5L2 6" stroke="white" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
)}
{/* 选中状态下的边框高亮 */}
{isSelectionMode && isSelected && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '3px solid #10b981',
borderRadius: 4,
pointerEvents: 'none',
zIndex: 5,
}} />
)}
</div>
);
};
@@ -448,7 +520,6 @@ const GeneratedRecord: React.FC = () => {
// 预览文件
const handlePreview = (item: any) => {
setPreviewItem(item);
setPreviewVisible(true);
// 触发事件通知布局组件关闭浮动按钮
@@ -471,6 +542,187 @@ const GeneratedRecord: React.FC = () => {
setPreviewVisible(false);
};
// 批量上传相关函数
const handleUploadChange = (info: any) => {
// 过滤文件类型
const validFiles = info.fileList.filter((file: any) => {
const type = file.type.toLowerCase();
return type.startsWith('image/') || type.startsWith('video/');
});
// 检查无效文件并提示
const invalidFiles = info.fileList.filter((file: any) => {
const type = file.type.toLowerCase();
return !type.startsWith('image/') && !type.startsWith('video/');
});
if (invalidFiles.length > 0) {
message.warning(`已过滤 ${invalidFiles.length} 个无效文件,仅支持图片和视频`);
}
setUploadFiles(validFiles);
};
const handleRemoveFile = (file: any) => {
setUploadFiles(prev => prev.filter(f => f.uid !== file.uid));
};
const handleStartUpload = async () => {
if (uploadFiles.length === 0) {
message.warning('请先选择要上传的文件');
return;
}
setUploading(true);
// 模拟批量上传过程
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
setUploadProgress(prev => ({ ...prev, [file.uid]: 0 }));
// 模拟上传进度
for (let progress = 0; progress <= 100; progress += 10) {
await new Promise(resolve => setTimeout(resolve, 100));
setUploadProgress(prev => ({ ...prev, [file.uid]: progress }));
}
}
// 上传完成
await new Promise(resolve => setTimeout(resolve, 500));
message.success(`成功上传 ${uploadFiles.length} 个文件`);
setUploading(false);
setUploadFiles([]);
setUploadModalVisible(false);
// 刷新页面数据
setPagebreak(prev => ({ ...prev, page: 1 }));
};
// 多选相关函数
const handleToggleSelect = (itemId: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
};
const handleSelectAll = () => {
const allItemIds = recordlist.flatMap((group: any) =>
group.items.map((item: any) => item.id)
);
if (selectedItems.size === allItemIds.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(allItemIds));
}
};
const handleBatchUploadSelected = async () => {
if (selectedItems.size === 0) {
message.warning('请先选择要上传的媒体');
return;
}
setUploading(true);
const uploadProgressMap: { [key: string]: number } = {};
try {
// 遍历所有选中的项
for (const itemId of selectedItems) {
let item: any = null;
let mediaUrl = '';
let mediaType = '';
// 找到对应的 item
for (const group of recordlist) {
const found = group.items.find((i: any) => i.id === itemId);
if (found) {
item = found;
break;
}
}
if (!item) continue;
// 根据媒体类型获取 URL
if (filterMedia === 'video' && item.videoUrl) {
mediaUrl = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.videoUrl}`;
mediaType = 'video';
} else if (filterMedia === 'image' && item.imageUrl) {
mediaUrl = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${item.imageUrl}`;
mediaType = 'image';
} else {
continue;
}
// 初始化进度
uploadProgressMap[itemId] = 0;
setUploadProgress({ ...uploadProgressMap });
try {
// 下载媒体文件
const response = await fetch(mediaUrl);
if (!response.ok) throw new Error('下载失败');
const blob = await response.blob();
const file = new File([blob], item.title || `media_${itemId}`, {
type: mediaType === 'video' ? 'video/mp4' : 'image/jpeg'
});
// 上传到后台接口
const form = new FormData();
form.append('file', file);
form.append('media_type', mediaType);
form.append('original_id', itemId);
const token = localStorage.getItem('auth_token');
const uploadResponse = await fetch(
`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/batch-upload`,
{
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
}
);
if (!uploadResponse.ok) throw new Error('上传失败');
// 更新进度
uploadProgressMap[itemId] = 100;
setUploadProgress({ ...uploadProgressMap });
} catch (error) {
console.error(`上传失败: ${itemId}`, error);
uploadProgressMap[itemId] = -1; // 标记失败
setUploadProgress({ ...uploadProgressMap });
}
}
const successCount = Object.values(uploadProgressMap).filter(p => p === 100).length;
const failCount = Object.values(uploadProgressMap).filter(p => p === -1).length;
if (failCount === 0) {
message.success(`成功上传 ${successCount} 个文件`);
} else {
message.warning(`上传完成:成功 ${successCount} 个,失败 ${failCount}`);
}
// 退出选择模式
setIsSelectionMode(false);
setSelectedItems(new Set());
setUploadProgress({});
} catch (error) {
message.error('批量上传失败');
console.error(error);
} finally {
setUploading(false);
}
};
useEffect(() => {
setLoading(true);
let parameters = '';
@@ -508,8 +760,6 @@ const GeneratedRecord: React.FC = () => {
...prev,
page: prev.page + 1
}));
};
// 分组加载更多
@@ -575,7 +825,7 @@ const GeneratedRecord: React.FC = () => {
</Typography.Text>
</div> */}
{/* First row filter: 项目记录 / 创作记录 */}
{/* 操作栏:筛选 + 上传按钮 */}
<div style={{
display: 'flex',
alignItems: 'center',
@@ -585,42 +835,107 @@ const GeneratedRecord: React.FC = () => {
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
justifyContent: 'space-between',
}}>
<FilterOutlined style={{ color: '#94a3b8', fontSize: 14 }} />
<Space>
<Button
type={filterType === 'project' ? 'primary' : 'default'}
onClick={() => setFilterType('project')}
style={{
borderRadius: 8,
background: filterType === 'project'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'project' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'project' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<FolderOpenOutlined />}
>
</Button>
<Button
type={filterType === 'creation' ? 'primary' : 'default'}
onClick={() => setFilterType('creation')}
style={{
borderRadius: 8,
background: filterType === 'creation'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'creation' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'creation' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<FileTextOutlined />}
>
</Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<FilterOutlined style={{ color: '#94a3b8', fontSize: 14 }} />
<Space>
<Button
type={filterType === 'project' ? 'primary' : 'default'}
onClick={() => setFilterType('project')}
style={{
borderRadius: 8,
background: filterType === 'project'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'project' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'project' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<FolderOpenOutlined />}
>
</Button>
<Button
type={filterType === 'creation' ? 'primary' : 'default'}
onClick={() => setFilterType('creation')}
style={{
borderRadius: 8,
background: filterType === 'creation'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'creation' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'creation' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<FileTextOutlined />}
>
</Button>
</Space>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{/* 多选模式按钮 */}
{isSelectionMode ? (
<Space>
<Button
onClick={handleSelectAll}
style={{
borderRadius: 8,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
color: '#64748b',
fontWeight: 600,
}}
>
{selectedItems.size === recordlist.reduce((sum: number, group: any) => sum + group.items.length, 0) ? '取消全选' : '全选'}
</Button>
<Button
onClick={() => {
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
color: '#64748b',
fontWeight: 600,
}}
>
</Button>
<Button
type="primary"
onClick={handleBatchUploadSelected}
loading={uploading}
disabled={uploading || selectedItems.size === 0}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #10b981, #059669)',
border: 'none',
fontWeight: 600,
}}
>
{uploading ? '上传中...' : `上传选中 (${selectedItems.size})`}
</Button>
</Space>
) : (
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => setIsSelectionMode(true)}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #10b981, #059669)',
border: 'none',
fontWeight: 600,
}}
>
</Button>
)}
</div>
</div>
{/* Second row filter: 视频 / 图片 */}
@@ -703,7 +1018,10 @@ const GeneratedRecord: React.FC = () => {
key={item.id}
item={item}
mediaType={filterMedia}
onClick={() => handlePreview(item)}
onClick={() => isSelectionMode ? handleToggleSelect(item.id) : handlePreview(item)}
isSelected={selectedItems.has(item.id)}
onToggleSelect={handleToggleSelect}
isSelectionMode={isSelectionMode}
/>
))}
</div>
@@ -751,6 +1069,147 @@ const GeneratedRecord: React.FC = () => {
</div>
)}
{/* 批量上传弹窗 */}
<Modal
title="批量上传媒体"
open={uploadModalVisible}
onCancel={() => {
setUploadModalVisible(false);
setUploadFiles([]);
}}
footer={null}
width={600}
>
<div style={{ padding: '16px 0' }}>
{/* 上传区域 */}
<Upload
multiple
fileList={uploadFiles}
onChange={handleUploadChange}
beforeUpload={() => false} // 手动控制上传
accept="image/*,video/*"
listType="picture-card"
onRemove={handleRemoveFile}
>
<div style={{
width: 100,
height: 100,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #d9d9d9',
borderRadius: 8,
cursor: 'pointer',
}}>
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
<span style={{ marginTop: 8, color: '#999', fontSize: 12 }}></span>
</div>
</Upload>
{/* 上传列表和进度 */}
{uploadFiles.length > 0 && (
<div style={{ marginTop: 16 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#475569' }}>
{uploadFiles.length}
</Typography.Text>
<div style={{ marginTop: 12, maxHeight: 200, overflowY: 'auto' }}>
{uploadFiles.map((file) => (
<div
key={file.uid}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 8,
border: '1px solid #e8e8e8',
borderRadius: 6,
marginBottom: 8,
}}
>
<div style={{
width: 40,
height: 40,
borderRadius: 4,
overflow: 'hidden',
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{file.type?.startsWith('image/') ? (
<PictureOutlined style={{ color: '#3b82f6', fontSize: 16 }} />
) : file.type?.startsWith('video/') ? (
<VideoCameraOutlined style={{ color: '#f59e0b', fontSize: 16 }} />
) : (
<FileTextOutlined style={{ color: '#999', fontSize: 16 }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
color: '#333',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{file.name}
</div>
{uploadProgress[file.uid] !== undefined && (
<Progress
percent={uploadProgress[file.uid]}
size="small"
showInfo={false}
style={{ marginTop: 4 }}
/>
)}
</div>
<Button
icon={<XOutlined />}
onClick={() => handleRemoveFile(file)}
style={{
background: 'transparent',
border: 'none',
color: '#999',
}}
/>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<div style={{
display: 'flex',
gap: 12,
marginTop: 24,
paddingTop: 16,
borderTop: '1px solid #f0f0f0',
justifyContent: 'flex-end',
}}>
<Button
onClick={() => {
setUploadModalVisible(false);
setUploadFiles([]);
}}
style={{ borderRadius: 8 }}
>
</Button>
<Button
type="primary"
onClick={handleStartUpload}
loading={uploading}
disabled={uploading || uploadFiles.length === 0}
style={{ borderRadius: 8 }}
>
{uploading ? '上传中...' : '开始上传'}
</Button>
</div>
</div>
</Modal>
{/* 预览弹窗 */}
{previewVisible && previewItem && (
<div