357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { LoadingOutlined, PlayCircleFilled, WarningOutlined } from '@ant-design/icons';
|
|
|
|
export interface GenerationTaskResourceItem {
|
|
id?: string;
|
|
genType?: string;
|
|
status?: string;
|
|
displayStatus?: string | null;
|
|
pipelineStage?: string | null;
|
|
imageUrl?: string | null;
|
|
videoUrl?: string | null;
|
|
videoCoverUrl?: string | null;
|
|
errorMessage?: string | null;
|
|
generationIndex?: number | null;
|
|
createdAt?: string;
|
|
}
|
|
|
|
export interface GenerationTaskResourceGroup extends GenerationTaskResourceItem {
|
|
generationCount?: number | null;
|
|
childItems?: GenerationTaskResourceItem[] | null;
|
|
}
|
|
|
|
interface Props {
|
|
task: GenerationTaskResourceGroup;
|
|
onPreview: (url: string, type: 'image' | 'video') => void;
|
|
resolveUrl?: (url?: string | null) => string;
|
|
}
|
|
|
|
const spanByCount = (count: number, index: number): number => {
|
|
if (count <= 1) return 6;
|
|
if (count === 2 || count === 4) return 3;
|
|
if (count === 3) return 3;
|
|
return 2;
|
|
};
|
|
|
|
const statusText = (item: GenerationTaskResourceItem): string => {
|
|
const status = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
|
const labels: Record<string, string> = {
|
|
pending: '待处理', queued: '排队中', preparing: '准备中', generating: '生成中',
|
|
creating_provider_task: '创建任务中', waiting_remote: '等待生成', polling: '轮询中',
|
|
result_ready: '结果就绪', download_queued: '等待下载', downloading: '下载中',
|
|
retry_waiting: '等待重试', completed: '已完成', failed: '生成失败',
|
|
download_failed: '下载失败', deleted: '已删除',
|
|
};
|
|
return labels[status] || status;
|
|
};
|
|
|
|
const isPending = (item: GenerationTaskResourceItem): boolean => {
|
|
const status = item.displayStatus || item.pipelineStage || item.status;
|
|
return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
|
|
};
|
|
|
|
const MAX_DURATION_SECONDS = 300;
|
|
|
|
interface ProgressItemProps {
|
|
item: GenerationTaskResourceItem;
|
|
isPending: boolean;
|
|
isCompleted: boolean;
|
|
onAnimationComplete?: () => void;
|
|
onProgressChange?: (progress: number) => void;
|
|
}
|
|
|
|
const PROGRESS_RATE = 0.6;
|
|
|
|
const calculateProgressValue = (createdAt?: string): number => {
|
|
if (!createdAt) return 0;
|
|
const createdTime = new Date(createdAt).getTime();
|
|
if (isNaN(createdTime)) return 0;
|
|
const now = Date.now();
|
|
const elapsedSeconds = Math.max(0, (now - createdTime) / 1000);
|
|
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
|
|
return 99;
|
|
}
|
|
return Math.min(99, elapsedSeconds * PROGRESS_RATE);
|
|
};
|
|
|
|
const ProgressItem: React.FC<ProgressItemProps> = ({ item, isPending: isPendingProp, isCompleted, onAnimationComplete, onProgressChange }) => {
|
|
const [progress, setProgress] = useState<number>(0);
|
|
const [displayProgress, setDisplayProgress] = useState<number>(0);
|
|
const [isFinishing, setIsFinishing] = useState(false);
|
|
const intervalRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (onProgressChange) {
|
|
onProgressChange(displayProgress);
|
|
}
|
|
}, [displayProgress, onProgressChange]);
|
|
|
|
useEffect(() => {
|
|
const newProgress = calculateProgressValue(item.createdAt);
|
|
setProgress(newProgress);
|
|
setDisplayProgress(newProgress);
|
|
}, [item]);
|
|
|
|
useEffect(() => {
|
|
if (isCompleted && !isFinishing) {
|
|
setIsFinishing(true);
|
|
return;
|
|
}
|
|
|
|
if (!isPendingProp && !isFinishing) {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const updateProgress = () => {
|
|
const newProgress = calculateProgressValue(item.createdAt);
|
|
setProgress(newProgress);
|
|
setDisplayProgress(newProgress);
|
|
};
|
|
|
|
intervalRef.current = window.setInterval(updateProgress, 1000);
|
|
|
|
return () => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
};
|
|
}, [item.createdAt, isPendingProp, isCompleted, isFinishing, item.id]);
|
|
|
|
useEffect(() => {
|
|
if (isFinishing) {
|
|
const targetProgress = 100;
|
|
const currentProgress = displayProgress;
|
|
const duration = 800;
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
const newProgress = currentProgress + (targetProgress - currentProgress) * easeProgress;
|
|
|
|
setDisplayProgress(newProgress);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
setTimeout(() => {
|
|
onAnimationComplete?.();
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(animate);
|
|
}
|
|
}, [isFinishing, displayProgress, onAnimationComplete]);
|
|
|
|
if (!isPendingProp && !isFinishing) return null;
|
|
|
|
return (
|
|
<div style={{
|
|
marginTop: 4,
|
|
}}>
|
|
<div style={{
|
|
textAlign: 'center',
|
|
fontSize: 13,
|
|
color: '#8b5cf6',
|
|
fontWeight: 700,
|
|
textShadow: '0 0 8px rgba(139, 92, 246, 0.3)',
|
|
transition: 'all 0.3s ease-out',
|
|
}}>
|
|
{Math.round(displayProgress)}%
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveUrl = (url) => url || '' }) => {
|
|
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
|
|
const children = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
|
|
const items: GenerationTaskResourceItem[] = children.length > 0
|
|
? children
|
|
: (count > 1 ? Array.from({ length: count }, (_, index) => ({
|
|
id: `${task.id || 'task'}-placeholder-${index + 1}`,
|
|
genType: task.genType,
|
|
status: task.status,
|
|
displayStatus: task.displayStatus,
|
|
pipelineStage: task.pipelineStage,
|
|
generationIndex: index + 1,
|
|
errorMessage: task.errorMessage,
|
|
createdAt: task.createdAt,
|
|
})) : [task]);
|
|
|
|
const [finishingItems, setFinishingItems] = useState<Set<string>>(new Set());
|
|
const [fullProgressItems, setFullProgressItems] = useState<Set<string>>(new Set());
|
|
const processedItems = useRef<Set<string>>(new Set());
|
|
|
|
useEffect(() => {
|
|
const styleId = 'gen-task-resource-grid-animations';
|
|
if (document.getElementById(styleId)) return;
|
|
const style = document.createElement('style');
|
|
style.id = styleId;
|
|
style.textContent = `
|
|
.gen-task-loading-bg {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: linear-gradient(135deg, #faf8ff 0%, #f5f3ff 100%);
|
|
}
|
|
.gen-task-loading-bg::before,
|
|
.gen-task-loading-bg::after,
|
|
.gen-task-loading-bg .gen-task-aurora-3 {
|
|
content: '';
|
|
position: absolute;
|
|
border-radius: 50%;
|
|
filter: blur(30px);
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
.gen-task-loading-bg::before {
|
|
width: 220px;
|
|
height: 220px;
|
|
background: radial-gradient(circle, rgba(139, 92, 246, 0.45) 0%, rgba(168, 85, 247, 0.25) 50%, transparent 100%);
|
|
animation: genTaskAurora1 6s linear infinite;
|
|
}
|
|
.gen-task-loading-bg::after {
|
|
width: 240px;
|
|
height: 240px;
|
|
background: radial-gradient(circle, rgba(168, 85, 247, 0.35) 0%, rgba(192, 132, 252, 0.2) 50%, transparent 100%);
|
|
animation: genTaskAurora2 7.5s linear infinite;
|
|
}
|
|
.gen-task-loading-bg .gen-task-aurora-3 {
|
|
width: 180px;
|
|
height: 180px;
|
|
background: radial-gradient(circle, rgba(99, 102, 241, 0.3) 0%, rgba(139, 92, 246, 0.15) 50%, transparent 100%);
|
|
animation: genTaskAurora3 5s linear infinite;
|
|
}
|
|
.gen-task-loading-bg > * { position: relative; z-index: 1; }
|
|
@keyframes genTaskAurora1 {
|
|
0% { transform: translate(-100px, -80px); }
|
|
25% { transform: translate(100px, -60px); }
|
|
50% { transform: translate(120px, 80px); }
|
|
75% { transform: translate(-60px, 100px); }
|
|
100% { transform: translate(-100px, -80px); }
|
|
}
|
|
@keyframes genTaskAurora2 {
|
|
0% { transform: translate(120px, 100px); }
|
|
25% { transform: translate(-80px, 120px); }
|
|
50% { transform: translate(-100px, -60px); }
|
|
75% { transform: translate(80px, -80px); }
|
|
100% { transform: translate(120px, 100px); }
|
|
}
|
|
@keyframes genTaskAurora3 {
|
|
0% { transform: translate(60px, -40px); }
|
|
25% { transform: translate(-40px, 60px); }
|
|
50% { transform: translate(40px, 100px); }
|
|
75% { transform: translate(100px, -20px); }
|
|
100% { transform: translate(60px, -40px); }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}, []);
|
|
|
|
const handleProgressChange = (itemId: string, progress: number) => {
|
|
if (progress >= 100) {
|
|
setFullProgressItems(prev => {
|
|
if (prev.has(itemId)) return prev;
|
|
const next = new Set(prev);
|
|
next.add(itemId);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
items.slice(0, 5).forEach((item, index) => {
|
|
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
|
const pending = isPending(item);
|
|
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
|
|
const itemId = item.id || `${index}`;
|
|
if (completed && !processedItems.current.has(itemId) && pending === false) {
|
|
processedItems.current.add(itemId);
|
|
setFinishingItems(prev => {
|
|
const next = new Set(prev);
|
|
next.add(itemId);
|
|
return next;
|
|
});
|
|
}
|
|
});
|
|
}, [items]);
|
|
|
|
const handleFinishAnimation = (itemId: string) => {
|
|
setFinishingItems(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(itemId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 6 : 0 }}>
|
|
{items.slice(0, 5).map((item, index) => {
|
|
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
|
const imageUrl = resolveUrl(`/static${item.imageUrl}&w=300&q=50`);
|
|
const videoUrl = resolveUrl(item.videoUrl);
|
|
const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`);
|
|
const isVideo = (item.genType || task.genType) === 'video';
|
|
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
|
|
const pending = isPending(item);
|
|
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
|
|
const itemId = item.id || `${index}`;
|
|
const isFinishing = finishingItems.has(itemId);
|
|
const isFullProgress = fullProgressItems.has(itemId);
|
|
const shouldShowContent = completed && !isFinishing;
|
|
const statusLabel = isFinishing ? '加载中' : statusText(item);
|
|
|
|
return (
|
|
<div
|
|
key={item.id || `${index}`}
|
|
style={{
|
|
gridColumn: `span ${spanByCount(items.length, index)}`,
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
borderRadius: items.length === 1 ? 12 : 8,
|
|
background: '#ffffff',
|
|
border: '1px solid #E7EAF0',
|
|
}}
|
|
>
|
|
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && shouldShowContent ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
|
|
style={{ width: '100%', height: '100%', border: 0, padding: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}
|
|
>
|
|
{isVideo ? (
|
|
coverUrl ? <img src={coverUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
|
: <video src={videoUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
|
) : (
|
|
<img src={imageUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
|
)}
|
|
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', fontSize: items.length > 2 ? 28 : 46, color: 'rgba(255,255,255,.92)', filter: 'drop-shadow(0 4px 10px rgba(0,0,0,.28))' }} /> : null}
|
|
</button>
|
|
) : (
|
|
<div className={pending || isFinishing ? 'gen-task-loading-bg' : ''} style={{ width: '100%', height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 7, padding: 8, textAlign: 'center' }}>
|
|
{pending || isFinishing ? <span className="gen-task-aurora-3" /> : null}
|
|
{pending || isFinishing ? <LoadingOutlined spin style={{ color: '#8b5cf6', fontSize: items.length > 2 ? 20 : 34 }} /> : <WarningOutlined style={{ color: displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B', fontSize: items.length > 2 ? 20 : 34 }} />}
|
|
<span style={{ fontSize: items.length > 2 ? 10 : 12, color: pending || isFinishing ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusLabel}</span>
|
|
<ProgressItem item={item} isPending={pending || isFinishing} isCompleted={completed} onAnimationComplete={() => handleFinishAnimation(itemId)} onProgressChange={(progress) => handleProgressChange(itemId, progress)} />
|
|
{!pending && item.errorMessage && items.length <= 2 ? <span style={{ fontSize: 10, color: '#A45B5B', lineHeight: 1.3, maxHeight: 28, overflow: 'hidden' }}>{item.errorMessage}</span> : null}
|
|
</div>
|
|
)}
|
|
{items.length > 1 ? <span style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, padding: '1px 6px', borderRadius: 10, background: 'rgba(17,24,39,.58)', color: '#fff', fontSize: 10 }}>#{item.generationIndex || index + 1}</span> : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default GenerationTaskResourceGrid;
|