1892 lines
90 KiB
TypeScript
1892 lines
90 KiB
TypeScript
import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react';
|
||
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Upload, Modal, Progress } from 'antd';
|
||
import {
|
||
SearchOutlined,
|
||
FilterOutlined,
|
||
VideoCameraOutlined,
|
||
PictureOutlined,
|
||
FolderOpenOutlined,
|
||
FileTextOutlined,
|
||
DownloadOutlined,
|
||
XOutlined,
|
||
ClockCircleOutlined,
|
||
UploadOutlined,
|
||
PlusOutlined,
|
||
} from '@ant-design/icons';
|
||
import { gethistory, gethistoryItems, getDefaultPreTest } from '../api';
|
||
|
||
const { Search } = Input;
|
||
const { Text } = Typography;
|
||
|
||
const GeneratedRecord: React.FC = () => {
|
||
const [filterType, setFilterType] = useState<'project' | 'creation'>('project');
|
||
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
|
||
const [recordlist, setRecordList] = useState<any[]>([]);
|
||
const [Pagebreak, setPagebreak] = useState<any>({
|
||
page: 1,
|
||
pageSize: 10,
|
||
});
|
||
const [Totalnumber, setTotalnumber] = useState<number>(0);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
const [loadingGroups, setLoadingGroups] = useState<Set<string>>(new Set());
|
||
const [previewVisible, setPreviewVisible] = useState(false);
|
||
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 [uploadConfigModalVisible, setUploadConfigModalVisible] = useState(false);
|
||
const [preTestTemplates, setPreTestTemplates] = useState<any[]>([
|
||
{ id: 'template_001', name: '前测模板A' },
|
||
{ id: 'template_002', name: '前测模板B' },
|
||
{ id: 'template_003', name: '前测模板C' },
|
||
]);
|
||
const [preTestLoading, setPreTestLoading] = useState(false);
|
||
const [selectedMediaList, setSelectedMediaList] = useState<any[]>([]);
|
||
const [selectedMediaConfigs, setSelectedMediaConfigs] = useState<{
|
||
[itemId: string]: {
|
||
accountId: string;
|
||
templateId: string;
|
||
}[];
|
||
}>({});
|
||
const [batchUploadProgress, setBatchUploadProgress] = useState<{
|
||
itemId: string;
|
||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||
message: string;
|
||
}[]>([]);
|
||
|
||
// 多选相关状态
|
||
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>();
|
||
|
||
// 加载队列控制 - 限制同时加载的媒体数量
|
||
// 使用优先级队列,完全在可视区的元素优先加载
|
||
interface LoadTask {
|
||
callback: () => void;
|
||
priority: number; // 优先级:2=完全可见(立即加载),1=部分可见,0=即将进入
|
||
element: HTMLElement;
|
||
}
|
||
const loadingQueue: LoadTask[] = [];
|
||
const MAX_CONCURRENT_LOADS = 15; // 最大同时加载数量
|
||
const MAX_VISIBLE_LOADS = 8; // 可视区最大并发数(不受队列限制)
|
||
let currentLoads = 0;
|
||
let visibleLoads = 0; // 当前可视区加载数
|
||
|
||
const enqueueLoad = (callback: () => void, element: HTMLElement, isFullyVisible: boolean) => {
|
||
// 完全可见的元素立即加载,不受队列限制
|
||
if (isFullyVisible && visibleLoads < MAX_VISIBLE_LOADS) {
|
||
visibleLoads++;
|
||
currentLoads++;
|
||
callback();
|
||
} else if (currentLoads < MAX_CONCURRENT_LOADS) {
|
||
// 部分可见或预加载区域的元素,受队列限制
|
||
currentLoads++;
|
||
callback();
|
||
} else {
|
||
// 添加到优先级队列
|
||
const task: LoadTask = {
|
||
callback,
|
||
priority: isFullyVisible ? 2 : 1,
|
||
element
|
||
};
|
||
loadingQueue.push(task);
|
||
// 按优先级排序,高优先级在前
|
||
loadingQueue.sort((a, b) => b.priority - a.priority);
|
||
}
|
||
};
|
||
|
||
const completeLoad = () => {
|
||
currentLoads--;
|
||
// 如果是可视区加载完成
|
||
if (visibleLoads > 0) {
|
||
visibleLoads--;
|
||
}
|
||
// 处理队列中的任务
|
||
if (loadingQueue.length > 0) {
|
||
// 优先处理高优先级任务
|
||
const nextTask = loadingQueue.shift();
|
||
if (nextTask) {
|
||
currentLoads++;
|
||
// 如果是完全可见的任务,计入可视区加载数
|
||
if (nextTask.priority === 2) {
|
||
visibleLoads++;
|
||
}
|
||
nextTask.callback();
|
||
}
|
||
}
|
||
};
|
||
|
||
const getGlobalObserver = (): IntersectionObserver => {
|
||
if (!globalObserver) {
|
||
globalObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
entries.forEach((entry) => {
|
||
const callback = observerCallbacks.get(entry.target as HTMLElement);
|
||
if (entry.isIntersecting && callback) {
|
||
observerCallbacks.delete(entry.target as HTMLElement);
|
||
globalObserver?.unobserve(entry.target);
|
||
// 判断是否完全可见(intersectionRatio >= 1)
|
||
const isFullyVisible = entry.intersectionRatio >= 1;
|
||
// 使用优先级队列控制加载
|
||
enqueueLoad(callback, entry.target as HTMLElement, isFullyVisible);
|
||
}
|
||
});
|
||
},
|
||
{ rootMargin: '400px', threshold: [0.01, 0.5, 1.0] } // 提前400px开始加载,多阈值检测
|
||
);
|
||
}
|
||
return globalObserver;
|
||
};
|
||
|
||
// 从URL中提取exp时间戳(支持相对路径和完整URL)
|
||
const extractExpTimestamp = (url: string): number | null => {
|
||
if (!url) return null;
|
||
|
||
try {
|
||
// 尝试作为完整URL解析
|
||
const urlObj = new URL(url);
|
||
const expStr = urlObj.searchParams.get('exp');
|
||
if (expStr) {
|
||
return parseInt(expStr, 10);
|
||
}
|
||
return null;
|
||
} catch {
|
||
// 如果完整URL解析失败,尝试解析相对路径中的查询参数
|
||
try {
|
||
const queryStart = url.indexOf('?');
|
||
if (queryStart !== -1) {
|
||
const queryString = url.substring(queryStart + 1);
|
||
const params = new URLSearchParams(queryString);
|
||
const expStr = params.get('exp');
|
||
if (expStr) {
|
||
return parseInt(expStr, 10);
|
||
}
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 检查媒体是否过期
|
||
const isMediaExpired = (url: string): boolean => {
|
||
const expTimestamp = extractExpTimestamp(url);
|
||
if (!expTimestamp) {
|
||
return false; // 没有exp参数,视为不过期
|
||
}
|
||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||
return currentTimestamp > expTimestamp;
|
||
};
|
||
|
||
// 懒加载媒体组件
|
||
const LazyMedia: React.FC<{
|
||
item: any;
|
||
mediaType: 'video' | 'image';
|
||
onClick: () => void;
|
||
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);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||
const mediaRef = useRef<HTMLImageElement | HTMLVideoElement>(null);
|
||
const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
// 检查媒体是否过期
|
||
useEffect(() => {
|
||
const url = mediaType === 'video' ? item.videoUrl : item.imageUrl;
|
||
if (url && isMediaExpired(url)) {
|
||
setIsExpired(true);
|
||
}
|
||
}, [item, mediaType]);
|
||
|
||
useEffect(() => {
|
||
// 如果已过期,不需要监听
|
||
if (isExpired) return;
|
||
|
||
const placeholder = placeholderRef.current;
|
||
if (!placeholder) return;
|
||
|
||
const observer = getGlobalObserver();
|
||
const callback = () => {
|
||
setIsLoading(true);
|
||
};
|
||
|
||
observerCallbacks.set(placeholder, callback);
|
||
observer.observe(placeholder);
|
||
|
||
return () => {
|
||
observerCallbacks.delete(placeholder);
|
||
observer.unobserve(placeholder);
|
||
};
|
||
}, [isExpired]);
|
||
|
||
// 安全拼接URL,避免双斜杠
|
||
const buildUrl = (path: string, isImage: boolean = false): string => {
|
||
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||
// 移除路径开头的斜杠(如果有)
|
||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||
// 移除baseUrl结尾的斜杠(如果有)
|
||
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||
if (isImage) {
|
||
return `${cleanBase}/static/${cleanPath}&w=300&q=50`;
|
||
}
|
||
return `${cleanBase}/${cleanPath}`;
|
||
};
|
||
|
||
// 处理加载完成
|
||
const handleLoad = () => {
|
||
// 清除可能的错误延迟定时器
|
||
if (errorTimer.current) {
|
||
clearTimeout(errorTimer.current);
|
||
}
|
||
setIsLoaded(true);
|
||
setIsLoading(false);
|
||
completeLoad();
|
||
};
|
||
|
||
const handleError = () => {
|
||
// 使用防抖,只有错误持续一段时间后才显示错误状态
|
||
errorTimer.current = setTimeout(() => {
|
||
const mediaUrl = mediaType === 'video' ? buildUrl(item.videoUrl) : buildUrl(item.imageUrl);
|
||
// console.warn(`媒体加载失败: ${mediaUrl}`, item);
|
||
setIsError(true);
|
||
setIsLoading(false);
|
||
completeLoad();
|
||
}, 1000); // 1秒防抖延迟
|
||
};
|
||
|
||
// 获取媒体URL
|
||
const mediaUrl = mediaType === 'video' ? buildUrl(item.videoUrl) : buildUrl(item.imageUrl, true);
|
||
const coverUrl = item.videoCoverUrl ? buildUrl(item.videoCoverUrl, true) : undefined;
|
||
|
||
return (
|
||
<div
|
||
ref={placeholderRef}
|
||
style={{
|
||
width: 160,
|
||
height: 120,
|
||
position: 'relative',
|
||
borderRadius: 4,
|
||
cursor: 'pointer',
|
||
overflow: 'hidden',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||
}}
|
||
onClick={!isSelectionMode ? onClick : undefined}
|
||
onMouseEnter={(e) => {
|
||
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) => {
|
||
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)';
|
||
}
|
||
}}
|
||
>
|
||
|
||
{/* 媒体内容 - 当isLoading为true时开始渲染,加载完成后显示 */}
|
||
{((mediaType === 'video' && item.videoCoverUrl) || mediaType === 'image') && (isLoading || isLoaded) && !isError && (
|
||
<div style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
position: 'relative',
|
||
}}>
|
||
{/* 加载中遮罩 */}
|
||
{isLoading && !isLoaded && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: 'rgba(248, 250, 252, 0.9)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1,
|
||
}}>
|
||
<div style={{
|
||
width: 28,
|
||
height: 28,
|
||
border: '3px solid #e2e8f0',
|
||
borderTopColor: '#3b82f6',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.8s linear infinite',
|
||
}} />
|
||
</div>
|
||
)}
|
||
{mediaType === 'video' && item.videoCoverUrl && (
|
||
<img
|
||
ref={mediaRef as React.RefObject<HTMLImageElement>}
|
||
src={coverUrl}
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
opacity: isLoaded ? 1 : 0,
|
||
transition: 'opacity 0.3s ease-in-out'
|
||
}}
|
||
loading="lazy"
|
||
onLoad={handleLoad}
|
||
onError={handleError}
|
||
/>
|
||
)}
|
||
{mediaType === 'image' && (
|
||
<img
|
||
ref={mediaRef as React.RefObject<HTMLImageElement>}
|
||
src={mediaUrl}
|
||
alt="图片预览"
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
opacity: isLoaded ? 1 : 0,
|
||
transition: 'opacity 0.3s ease-in-out'
|
||
}}
|
||
loading="lazy"
|
||
onLoad={handleLoad}
|
||
onError={handleError}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 加载占位符 - 显示渐变背景和加载状态 */}
|
||
{!isLoading && !isLoaded && !isError && (
|
||
<div style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||
}}>
|
||
<div style={{
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 8,
|
||
backgroundColor: '#cbd5e1',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}>
|
||
{mediaType === 'video' ? (
|
||
<VideoCameraOutlined style={{ color: '#64748b', fontSize: 16 }} />
|
||
) : (
|
||
<PictureOutlined style={{ color: '#64748b', fontSize: 16 }} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 视频无封面时直接显示占位符 */}
|
||
{mediaType === 'video' && !item.videoCoverUrl && !isError && (
|
||
<div style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: '#1e293b',
|
||
}}>
|
||
<VideoCameraOutlined style={{ color: '#64748b', fontSize: 24 }} />
|
||
<Text style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>暂无封面</Text>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* 过期占位符 */}
|
||
{isExpired && (
|
||
<div style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: '#fef3c7',
|
||
color: '#d97706',
|
||
fontSize: 12,
|
||
flexDirection: 'column',
|
||
gap: 4,
|
||
}}>
|
||
<ClockCircleOutlined style={{ fontSize: 24 }} />
|
||
图片过期
|
||
</div>
|
||
)}
|
||
|
||
{/* 错误占位符 */}
|
||
{isError && (
|
||
<div style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: '#fef2f2',
|
||
color: '#dc2626',
|
||
fontSize: 12,
|
||
}}>
|
||
加载失败
|
||
</div>
|
||
)}
|
||
|
||
{/* 点击提示 */}
|
||
{!isExpired && !isSelectionMode && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
background: 'linear-gradient(transparent, rgba(0,0,0,0.5))',
|
||
padding: '8px',
|
||
color: '#fff',
|
||
fontSize: 12,
|
||
opacity: 0,
|
||
transition: 'opacity 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
(e.currentTarget as HTMLElement).style.opacity = '0';
|
||
}}
|
||
>
|
||
点击预览
|
||
</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>
|
||
);
|
||
};
|
||
|
||
// 下载文件
|
||
const handleDownload = (item: any) => {
|
||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.videoUrl || item.imageUrl}`;
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = item.title || item.id || 'download';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
// 预览文件
|
||
const handlePreview = (item: any) => {
|
||
setPreviewItem(item);
|
||
setPreviewVisible(true);
|
||
// 触发事件通知布局组件关闭浮动按钮
|
||
window.dispatchEvent(new Event('previewOpen'));
|
||
};
|
||
|
||
// 关闭预览并暂停视频
|
||
const handleClosePreview = () => {
|
||
// 方法1: 使用 ref
|
||
if (videoRef.current) {
|
||
videoRef.current.pause();
|
||
videoRef.current.currentTime = 0;
|
||
}
|
||
// 方法2: 直接通过 DOM 查询(备用)
|
||
const videoElements = document.querySelectorAll('video');
|
||
videoElements.forEach(video => {
|
||
video.pause();
|
||
video.currentTime = 0;
|
||
});
|
||
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 = () => {
|
||
if (selectedItems.size === 0) {
|
||
message.warning('请先选择要上传的媒体');
|
||
return;
|
||
}
|
||
|
||
const mediaList: any[] = [];
|
||
const configs: { [itemId: string]: { accountId: string; templateId: string }[] } = {};
|
||
|
||
for (const itemId of selectedItems) {
|
||
for (const group of recordlist) {
|
||
const found = group.items.find((i: any) => i.id === itemId);
|
||
if (found) {
|
||
mediaList.push(found);
|
||
configs[itemId] = [{ accountId: '', templateId: '' }];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
setSelectedMediaList(mediaList);
|
||
setSelectedMediaConfigs(configs);
|
||
setBatchUploadProgress([]);
|
||
setUploadConfigModalVisible(true);
|
||
};
|
||
|
||
const handleStartBatchUpload = async () => {
|
||
setUploading(true);
|
||
const uploadProgressMap: { [key: string]: { status: 'pending' | 'uploading' | 'success' | 'error'; message: string } } = {};
|
||
|
||
for (const itemId of selectedItems) {
|
||
uploadProgressMap[itemId] = { status: 'pending', message: '' };
|
||
}
|
||
setBatchUploadProgress(Object.entries(uploadProgressMap).map(([itemId, value]) => ({ itemId, ...value })));
|
||
|
||
try {
|
||
for (const itemId of selectedItems) {
|
||
let item: any = null;
|
||
let mediaUrl = '';
|
||
let mediaType = '';
|
||
|
||
for (const group of recordlist) {
|
||
const found = group.items.find((i: any) => i.id === itemId);
|
||
if (found) {
|
||
item = found;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!item) continue;
|
||
|
||
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] = { status: 'uploading', message: '正在上传...' };
|
||
setBatchUploadProgress(Object.entries(uploadProgressMap).map(([itemId, value]) => ({ itemId, ...value })));
|
||
|
||
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 configs = selectedMediaConfigs[itemId] || [];
|
||
for (const config of configs) {
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
form.append('media_type', mediaType);
|
||
form.append('original_id', itemId);
|
||
form.append('account_id', config.accountId);
|
||
form.append('pre_test_template_id', config.templateId);
|
||
|
||
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,
|
||
}
|
||
);
|
||
|
||
const result = await uploadResponse.json();
|
||
if (!uploadResponse.ok) {
|
||
throw new Error(result.message || '上传失败');
|
||
}
|
||
}
|
||
|
||
uploadProgressMap[itemId] = { status: 'success', message: '上传成功' };
|
||
} catch (error: any) {
|
||
console.error(`上传失败: ${itemId}`, error);
|
||
uploadProgressMap[itemId] = { status: 'error', message: error.message || '上传失败' };
|
||
}
|
||
setBatchUploadProgress(Object.entries(uploadProgressMap).map(([itemId, value]) => ({ itemId, ...value })));
|
||
}
|
||
|
||
const successCount = Object.values(uploadProgressMap).filter(p => p.status === 'success').length;
|
||
const failCount = Object.values(uploadProgressMap).filter(p => p.status === 'error').length;
|
||
|
||
if (failCount === 0) {
|
||
message.success(`成功上传 ${successCount} 个文件`);
|
||
} else {
|
||
message.warning(`上传完成:成功 ${successCount} 个,失败 ${failCount} 个`);
|
||
}
|
||
|
||
setIsSelectionMode(false);
|
||
setSelectedItems(new Set());
|
||
} catch (error) {
|
||
message.error('批量上传失败');
|
||
console.error(error);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
let parameters = '';
|
||
if (filterType === 'project') {
|
||
parameters = `?gen_type=${filterMedia}&history_source=generation_record&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||
} else {
|
||
parameters = `?gen_type=${filterMedia}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||
}
|
||
gethistory(parameters).then((res: any) => {
|
||
const data = Array.isArray(res) ? res : (res?.groups || []);
|
||
data.forEach(group => {
|
||
group.page = 1;
|
||
});
|
||
// 如果是第一页,替换数据;否则追加数据
|
||
if (Pagebreak.page === 1) {
|
||
setRecordList(data);
|
||
} else {
|
||
setRecordList(prev => [...prev, ...data]);
|
||
}
|
||
|
||
setTotalnumber(res?.totalDays || 0);
|
||
}).catch((err) => {
|
||
if (Pagebreak.page === 1) {
|
||
setRecordList([]);
|
||
}
|
||
}).finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}, [filterType, filterMedia, Pagebreak.page]);
|
||
|
||
useEffect(() => {
|
||
setPreTestLoading(true);
|
||
getDefaultPreTest().then((res: any) => {
|
||
const data = res?.data || res;
|
||
setPreTestTemplates(Array.isArray(data) ? data : []);
|
||
}).catch(() => {
|
||
setPreTestTemplates([
|
||
{ id: 'template_001', name: '前测模板A' },
|
||
{ id: 'template_002', name: '前测模板B' },
|
||
{ id: 'template_003', name: '前测模板C' },
|
||
]);
|
||
}).finally(() => {
|
||
setPreTestLoading(false);
|
||
});
|
||
}, []);
|
||
|
||
// 加载更多
|
||
const handleLoadMore = () => {
|
||
if (loading) return;
|
||
setPagebreak(prev => ({
|
||
...prev,
|
||
page: prev.page + 1
|
||
}));
|
||
};
|
||
|
||
// 分组加载更多
|
||
const handleGroupLoadMore = async (time: string, date: string, page: number) => {
|
||
if (loadingGroups.has(date)) return;
|
||
setLoadingGroups(prev => new Set([...prev, date]));
|
||
|
||
const addpage = page + 1;
|
||
|
||
let parameters = ``;
|
||
|
||
if (filterType === 'project') {
|
||
parameters = `${time}?gen_type=${filterMedia}&history_source=generation_record&page=${addpage}&page_size=${Pagebreak.pageSize}`;
|
||
} else {
|
||
parameters = `${time}?gen_type=${filterMedia}&page=${addpage}&page_size=${Pagebreak.pageSize}`;
|
||
}
|
||
|
||
try {
|
||
const res: any = await gethistoryItems(parameters);
|
||
|
||
// gethistoryItems 返回数组,直接使用
|
||
const newItems: any[] = res.items || [];
|
||
|
||
if (newItems && newItems.length > 0) {
|
||
setRecordList(prev => prev.map(group => {
|
||
if (group.generatedDate === time) {
|
||
return {
|
||
...group,
|
||
items: [...group.items, ...newItems],
|
||
page: addpage
|
||
};
|
||
}
|
||
return group;
|
||
}));
|
||
}
|
||
} catch (err) {
|
||
} finally {
|
||
setLoadingGroups(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(date);
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
|
||
// 当筛选条件改变时,重置页码
|
||
useEffect(() => {
|
||
setPagebreak(prev => ({
|
||
...prev,
|
||
page: 1
|
||
}));
|
||
}, [filterType, filterMedia]);
|
||
|
||
return (
|
||
<div>
|
||
{/* Header */}
|
||
{/* <div style={{ marginBottom: 20 }}>
|
||
<Typography.Title level={3} style={{ margin: '0 0 4px', color: '#1a1a2e', fontWeight: 700 }}>
|
||
生成历史
|
||
</Typography.Title>
|
||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>
|
||
查看所有生成的视频和图片记录
|
||
</Typography.Text>
|
||
</div> */}
|
||
|
||
{/* 操作栏:筛选 + 上传按钮 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 16,
|
||
padding: '12px 20px',
|
||
borderRadius: 12,
|
||
background: '#fff',
|
||
border: '1px solid #f0f0f5',
|
||
justifyContent: 'space-between',
|
||
}}>
|
||
<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: 视频 / 图片 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 24,
|
||
padding: '12px 20px',
|
||
borderRadius: 12,
|
||
background: '#fff',
|
||
border: '1px solid #f0f0f5',
|
||
}}>
|
||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>媒体类型:</Typography.Text>
|
||
<Space>
|
||
<Button
|
||
type={filterMedia === 'video' ? 'primary' : 'default'}
|
||
onClick={() => setFilterMedia('video')}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: filterMedia === 'video'
|
||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||
: '#f8f9fc',
|
||
border: filterMedia === 'video' ? 'none' : '1px solid #e2e8f0',
|
||
color: filterMedia === 'video' ? '#fff' : '#64748b',
|
||
fontWeight: 600,
|
||
}}
|
||
icon={<VideoCameraOutlined />}
|
||
>
|
||
视频
|
||
</Button>
|
||
<Button
|
||
type={filterMedia === 'image' ? 'primary' : 'default'}
|
||
onClick={() => setFilterMedia('image')}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: filterMedia === 'image'
|
||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||
: '#f8f9fc',
|
||
border: filterMedia === 'image' ? 'none' : '1px solid #e2e8f0',
|
||
color: filterMedia === 'image' ? '#fff' : '#64748b',
|
||
fontWeight: 600,
|
||
}}
|
||
icon={<PictureOutlined />}
|
||
>
|
||
图片
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
{/* Content area */}
|
||
{recordlist.length === 0 ? (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="暂无生成记录"
|
||
style={{ padding: '60px 0' }}
|
||
/>
|
||
) : (
|
||
<div style={{ padding: '0 4px' }}>
|
||
{recordlist.map((group: any, index: number) => (
|
||
<div key={index} style={{ marginBottom: 32 }}>
|
||
{/* Date label */}
|
||
<div style={{
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: '#64748b',
|
||
marginBottom: 12,
|
||
paddingLeft: 8,
|
||
}}>
|
||
{group.generatedDate}
|
||
</div>
|
||
{/* Media grid */}
|
||
<div style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
}}>
|
||
{group.items.map((item: any) => (
|
||
<LazyMedia
|
||
key={item.id}
|
||
item={item}
|
||
mediaType={filterMedia}
|
||
onClick={() => isSelectionMode ? handleToggleSelect(item.id) : handlePreview(item)}
|
||
isSelected={selectedItems.has(item.id)}
|
||
onToggleSelect={handleToggleSelect}
|
||
isSelectionMode={isSelectionMode}
|
||
/>
|
||
))}
|
||
</div>
|
||
{/* 分组内加载更多 */}
|
||
{group.total && group.total > group.items.length && (
|
||
<div style={{ padding: '12px 0', textAlign: 'left' }}>
|
||
<Button
|
||
onClick={() => handleGroupLoadMore(group.generatedDate, group.items, group.page)}
|
||
loading={loadingGroups.has(group.generatedDate)}
|
||
disabled={loadingGroups.has(group.generatedDate)}
|
||
size="small"
|
||
style={{
|
||
borderRadius: 6,
|
||
background: 'transparent',
|
||
border: '1px dashed #cbd5e1',
|
||
color: '#64748b',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{loadingGroups.has(group.date) ? '加载中...' : `查看全部 (${group.total})`}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{/* 加载更多按钮 */}
|
||
{recordlist.length > 0 && Totalnumber > recordlist.length && (
|
||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||
<Button
|
||
onClick={handleLoadMore}
|
||
loading={loading}
|
||
disabled={loading}
|
||
style={{
|
||
borderRadius: 8,
|
||
background: '#f8f9fc',
|
||
border: '1px solid #e2e8f0',
|
||
color: '#64748b',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{loading ? '加载中...' : '加载更多'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</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>
|
||
|
||
{/* 上传配置弹窗 */}
|
||
<Modal
|
||
title="批量上传配置"
|
||
open={uploadConfigModalVisible}
|
||
onCancel={() => {
|
||
setUploadConfigModalVisible(false);
|
||
setSelectedMediaList([]);
|
||
setSelectedMediaConfigs({});
|
||
setBatchUploadProgress([]);
|
||
}}
|
||
footer={null}
|
||
width={800}
|
||
>
|
||
<div style={{ padding: '16px 0' }}>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 16, display: 'block' }}>
|
||
已选择 {selectedMediaList.length} 个媒体,请为每个媒体配置账户ID和对应的前测模板ID
|
||
</Typography.Text>
|
||
|
||
{/* 选中媒体列表 */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
{selectedMediaList.map((media, mediaIndex) => {
|
||
const configs = selectedMediaConfigs[media.id] || [];
|
||
const mediaUrl = filterMedia === 'video'
|
||
? `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.videoUrl}`
|
||
: `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${media.imageUrl}`;
|
||
|
||
return (
|
||
<div
|
||
key={media.id}
|
||
style={{
|
||
marginBottom: 20,
|
||
padding: 16,
|
||
border: '1px solid #e8e8e8',
|
||
borderRadius: 12,
|
||
}}
|
||
>
|
||
{/* 媒体信息 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 16,
|
||
marginBottom: 16,
|
||
paddingBottom: 12,
|
||
borderBottom: '1px dashed #e8e8e8',
|
||
}}>
|
||
<div style={{
|
||
width: 64,
|
||
height: 64,
|
||
borderRadius: 8,
|
||
overflow: 'hidden',
|
||
backgroundColor: '#f5f5f5',
|
||
flexShrink: 0,
|
||
}}>
|
||
{filterMedia === 'video' ? (
|
||
<video
|
||
src={mediaUrl}
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
controls={false}
|
||
muted
|
||
/>
|
||
) : (
|
||
<img
|
||
src={mediaUrl}
|
||
alt={media.title}
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e', display: 'block' }}>
|
||
{media.title || `媒体 ${mediaIndex + 1}`}
|
||
</Typography.Text>
|
||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginTop: 4, display: 'block' }}>
|
||
ID: {media.id} | 类型: {filterMedia === 'video' ? '视频' : '图片'}
|
||
</Typography.Text>
|
||
</div>
|
||
<div style={{ width: 24, height: 24, borderRadius: 50, background: '#6366f1', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 600 }}>
|
||
{mediaIndex + 1}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 该媒体的配置列表 */}
|
||
<div>
|
||
<Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 8, display: 'block' }}>
|
||
配置(可添加多条账户-模板组合)
|
||
</Typography.Text>
|
||
{configs.map((config, configIndex) => (
|
||
<div
|
||
key={configIndex}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
<div style={{ flex: 1 }}>
|
||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', marginBottom: 2, display: 'block' }}>账户ID</Typography.Text>
|
||
<Input
|
||
value={config.accountId}
|
||
onChange={(e) => {
|
||
const newConfigs = { ...selectedMediaConfigs };
|
||
newConfigs[media.id] = [...configs];
|
||
newConfigs[media.id][configIndex].accountId = e.target.value;
|
||
setSelectedMediaConfigs(newConfigs);
|
||
}}
|
||
placeholder="请输入账户ID"
|
||
style={{ borderRadius: 6 }}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', marginBottom: 2, display: 'block' }}>前测模板</Typography.Text>
|
||
<Select
|
||
value={config.templateId}
|
||
onChange={(value) => {
|
||
const newConfigs = { ...selectedMediaConfigs };
|
||
newConfigs[media.id] = [...configs];
|
||
newConfigs[media.id][configIndex].templateId = value;
|
||
setSelectedMediaConfigs(newConfigs);
|
||
}}
|
||
placeholder="请选择前测模板"
|
||
loading={preTestLoading}
|
||
style={{ width: '100%', borderRadius: 6 }}
|
||
options={preTestTemplates.map((template: any) => ({
|
||
value: template.id,
|
||
label: template.name || template.id,
|
||
}))}
|
||
/>
|
||
</div>
|
||
<Button
|
||
icon={<XOutlined />}
|
||
onClick={() => {
|
||
if (configs.length > 1) {
|
||
const newConfigs = { ...selectedMediaConfigs };
|
||
newConfigs[media.id] = configs.filter((_, i) => i !== configIndex);
|
||
setSelectedMediaConfigs(newConfigs);
|
||
}
|
||
}}
|
||
disabled={configs.length === 1}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#999',
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
{/* 该媒体的添加配置按钮 */}
|
||
<Button
|
||
type="dashed"
|
||
onClick={() => {
|
||
const newConfigs = { ...selectedMediaConfigs };
|
||
newConfigs[media.id] = [...configs, { accountId: '', templateId: '' }];
|
||
setSelectedMediaConfigs(newConfigs);
|
||
}}
|
||
icon={<PlusOutlined />}
|
||
size="small"
|
||
style={{
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
添加配置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 上传进度区域 */}
|
||
{batchUploadProgress.length > 0 && (
|
||
<div style={{
|
||
marginBottom: 16,
|
||
padding: 12,
|
||
border: '1px solid #e8e8e8',
|
||
borderRadius: 8,
|
||
maxHeight: 200,
|
||
overflowY: 'auto',
|
||
}}>
|
||
<Typography.Text strong style={{ fontSize: 13, color: '#475569', marginBottom: 8, display: 'block' }}>
|
||
上传进度 ({batchUploadProgress.filter(p => p.status === 'success').length}/{batchUploadProgress.length})
|
||
</Typography.Text>
|
||
{batchUploadProgress.map((progress) => (
|
||
<div
|
||
key={progress.itemId}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: 8,
|
||
marginBottom: 6,
|
||
borderRadius: 6,
|
||
background: progress.status === 'error' ? '#fef2f2' : progress.status === 'success' ? '#f0fdf4' : '#f9fafb',
|
||
}}
|
||
>
|
||
<div style={{ width: 16, height: 16, borderRadius: 50, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
{progress.status === 'success' && <span style={{ color: '#10b981', fontSize: 12 }}>✓</span>}
|
||
{progress.status === 'error' && <span style={{ color: '#ef4444', fontSize: 12 }}>✗</span>}
|
||
{progress.status === 'uploading' && <span style={{ color: '#6366f1', fontSize: 12 }}>●</span>}
|
||
{progress.status === 'pending' && <span style={{ color: '#9ca3af', fontSize: 12 }}>○</span>}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 12, color: '#374151' }}>
|
||
{progress.itemId}
|
||
</div>
|
||
{progress.message && (
|
||
<div style={{ fontSize: 11, color: progress.status === 'error' ? '#ef4444' : '#64748b', marginTop: 2 }}>
|
||
{progress.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: 12,
|
||
marginTop: 16,
|
||
paddingTop: 16,
|
||
borderTop: '1px solid #f0f0f0',
|
||
justifyContent: 'flex-end',
|
||
}}>
|
||
<Button
|
||
onClick={() => {
|
||
setUploadConfigModalVisible(false);
|
||
setSelectedMediaList([]);
|
||
setSelectedMediaConfigs({});
|
||
setBatchUploadProgress([]);
|
||
}}
|
||
style={{ borderRadius: 8 }}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleStartBatchUpload}
|
||
loading={uploading}
|
||
disabled={uploading || !selectedMediaList.every(media => {
|
||
const configs = selectedMediaConfigs[media.id] || [];
|
||
return configs.length > 0 && configs.every(c => c.accountId && c.templateId);
|
||
})}
|
||
style={{ borderRadius: 8 }}
|
||
>
|
||
{uploading ? '上传中...' : '开始上传'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 预览弹窗 */}
|
||
{previewVisible && previewItem && (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: 'rgba(0,0,0,0.85)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1000,
|
||
padding: 16,
|
||
boxSizing: 'border-box',
|
||
overflow: 'auto',
|
||
}}
|
||
onClick={handleClosePreview}
|
||
>
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 16,
|
||
padding: 0,
|
||
width: '100%',
|
||
maxWidth: '1200px',
|
||
maxHeight: '95vh',
|
||
minHeight: '300px',
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 头部 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '12px 16px',
|
||
borderBottom: '1px solid #f0f0f0',
|
||
flexShrink: 0,
|
||
}}>
|
||
<Typography.Title level={5} style={{ margin: 0, color: '#1a1a2e', fontSize: 16 }}>
|
||
{previewItem.title || '预览'}
|
||
</Typography.Title>
|
||
<Button
|
||
icon={<XOutlined />}
|
||
onClick={handleClosePreview}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#94a3b8',
|
||
fontSize: 16,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 内容区域 */}
|
||
<div style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
height: '500px',
|
||
gap: 20,
|
||
padding: 20,
|
||
overflow: 'auto',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
}}>
|
||
{/* 媒体预览 */}
|
||
<div style={{
|
||
flex: 1,
|
||
minWidth: '280px',
|
||
maxWidth: '800px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
minHeight: '200px',
|
||
}}>
|
||
{/* 检查媒体是否过期 */}
|
||
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? (
|
||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||
<p style={{ fontSize: 16, color: '#ff4d4f', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
|
||
</div>
|
||
) : filterMedia === 'video' ? (
|
||
<video
|
||
ref={videoRef}
|
||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl}`}
|
||
controls
|
||
autoPlay
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '55vh',
|
||
borderRadius: 8,
|
||
objectFit: 'contain',
|
||
}}
|
||
/>
|
||
) : (
|
||
<img
|
||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${previewItem.imageUrl}&w=300&q=50`}
|
||
alt="预览"
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '55vh',
|
||
objectFit: 'contain',
|
||
borderRadius: 8
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 参数信息 */}
|
||
<div style={{
|
||
width: '100%',
|
||
minWidth: '280px',
|
||
maxWidth: '320px',
|
||
background: '#f8fafc',
|
||
borderRadius: 12,
|
||
padding: 20,
|
||
maxHeight: '55vh',
|
||
overflowY: 'auto',
|
||
overflowX: 'hidden',
|
||
}}>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', display: 'block', marginBottom: 16 }}>
|
||
文件信息
|
||
</Typography.Text>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{/* ID */}
|
||
{/* <div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>ID</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.id || '-'}
|
||
</span>
|
||
</div> */}
|
||
|
||
{/* 类型 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>类型</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{filterMedia === 'video' ? '视频' : '图片'}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13, flexShrink: 0, width: 40 }}>请求</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500, flex: 1, wordBreak: 'break-all' }}>
|
||
{previewItem.originalPrompt}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 分辨率 */}
|
||
{filterMedia === 'image' && (
|
||
<>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>比例</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imageProportion}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>分辨率</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imageSize}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>尺寸</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imagePx}
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
{filterMedia === 'video' && (
|
||
<>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>比例</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.aspectRatio}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>分辨率</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.resolution}
|
||
</span>
|
||
</div>
|
||
{/* <div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>尺寸</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.imagePx}
|
||
</span>
|
||
</div> */}
|
||
</>
|
||
)}
|
||
|
||
{/* 时长(视频) */}
|
||
{filterMedia === 'video' && (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>时长</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{`${previewItem.duration}秒` || '-'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 文件大小 */}
|
||
{/* <div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>文件大小</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.size ? formatFileSize(previewItem.size) : '-'}
|
||
</span>
|
||
</div> */}
|
||
|
||
{/* 创建时间 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>创建时间</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{formatDateTime(previewItem.createdAt || previewItem.generatedDate)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 生成引擎 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: '#94a3b8', fontSize: 13 }}>生成引擎</span>
|
||
<span style={{ color: '#334155', fontSize: 13, fontWeight: 500 }}>
|
||
{previewItem.engine || previewItem.engineName || '-'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分隔线 */}
|
||
<div style={{ borderTop: '1px dashed #e2e8f0', margin: '16px 0' }} />
|
||
|
||
|
||
{previewItem.mediaReferences && previewItem.mediaReferences.length > 0 && (
|
||
<>
|
||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', display: 'block', marginBottom: 12 }}>
|
||
依靠附件
|
||
</Typography.Text>
|
||
{previewItem.mediaReferences.map((item, index) => (
|
||
<div
|
||
key={item.url}
|
||
onClick={() => {
|
||
// 暂停视频
|
||
if (videoRef.current) {
|
||
videoRef.current.pause();
|
||
}
|
||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.url}`;
|
||
window.open(url, '_blank');
|
||
}}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: 8,
|
||
borderRadius: 6,
|
||
cursor: 'pointer',
|
||
backgroundColor: '#f1f5f9',
|
||
marginBottom: 4,
|
||
transition: 'background-color 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
(e.currentTarget as HTMLElement).style.backgroundColor = '#e2e8f0';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
(e.currentTarget as HTMLElement).style.backgroundColor = '#f1f5f9';
|
||
}}
|
||
>
|
||
{item.type === 'image' ? (
|
||
<PictureOutlined style={{ color: '#3b82f6', fontSize: 14 }} />
|
||
) : (
|
||
<VideoCameraOutlined style={{ color: '#f59e0b', fontSize: 14 }} />
|
||
)}
|
||
<span style={{ color: '#334155', fontSize: 13 }}>
|
||
{item.name || `媒体${index + 1}`}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{/* 操作按钮 */}
|
||
<div >
|
||
<div style={{ display: 'flex', gap: 12, marginTop: 20 }}>
|
||
<Button
|
||
type="primary"
|
||
icon={<DownloadOutlined />}
|
||
onClick={() => {
|
||
// 暂停视频
|
||
if (videoRef.current) {
|
||
videoRef.current.pause();
|
||
}
|
||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}&download=1`;
|
||
window.open(url, '_blank');
|
||
}}
|
||
style={{ flex: 1, borderRadius: 8 }}
|
||
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
|
||
>
|
||
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? '资源已过期' : '下载'}
|
||
</Button>
|
||
<Button
|
||
onClick={handleClosePreview}
|
||
style={{ flex: 1, borderRadius: 8 }}
|
||
>
|
||
关闭
|
||
</Button>
|
||
</div>
|
||
<div>
|
||
<Button
|
||
|
||
style={{ width: '100%', borderRadius: 8, marginTop: 20, color: '#4c49cc' }}
|
||
>
|
||
|
||
推送媒体后台
|
||
</Button>
|
||
</div>
|
||
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 文件大小格式化
|
||
function formatFileSize(bytes: number): string {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
// 添加旋转动画样式
|
||
const styleSheet = document.createElement('style');
|
||
styleSheet.textContent = `
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
`;
|
||
document.head.appendChild(styleSheet);
|
||
|
||
// 日期格式化(年月日时分秒)
|
||
function formatDateTime(dateString: string): string {
|
||
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');
|
||
const hours = String(date.getHours()).padStart(2, '0');
|
||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||
}
|
||
|
||
export default GeneratedRecord;
|