Files
video-gen/video-gen-app/src/pages/GeneratedRecord.tsx
T

1123 lines
51 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 } from 'antd';
import {
SearchOutlined,
FilterOutlined,
VideoCameraOutlined,
PictureOutlined,
FolderOpenOutlined,
FileTextOutlined,
DownloadOutlined,
XOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { gethistory, gethistoryItems } 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>();
// 全局 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;
}> = ({ item, mediaType, onClick }) => {
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,
borderRadius: 4,
overflow: 'hidden',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
transition: 'transform 0.2s, box-shadow 0.2s',
position: 'relative',
backgroundColor: '#f1f5f9',
}}
onClick={onClick}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.2)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
(e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
}}
>
{/* 加载占位符 - 显示渐变背景和加载状态 */}
{!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>
)}
{/* 媒体内容 - 当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>
)}
{/* 过期占位符 */}
{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 && (
<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>
)}
</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);
};
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]);
// 加载更多
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> */}
{/* First row filter: 项目记录 / 创作记录 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 20px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}>
<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>
{/* 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={() => handlePreview(item)}
/>
))}
</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>
)}
{/* 预览弹窗 */}
{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}`;
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;