Files
video-gen/video-gen-app/src/pages/GeneratedRecord copy.tsx
T
2026-06-18 17:11:55 +08:00

1892 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;