208 lines
8.7 KiB
TypeScript
208 lines
8.7 KiB
TypeScript
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useLanguage } from '../i18n/LanguageContext';
|
|
import { MessageKey } from '../i18n/translations';
|
|
|
|
interface GenerationProgressProps {
|
|
progress: number;
|
|
message: string;
|
|
step?: string;
|
|
}
|
|
|
|
const STEP_ICONS: Record<string, string> = {
|
|
analyzing: '🔍',
|
|
connecting: '🔗',
|
|
generating: '🤖',
|
|
parsing: '📋',
|
|
saving: '💾',
|
|
building: '🏗️',
|
|
};
|
|
|
|
const STEP_KEYS: Record<string, MessageKey> = {
|
|
analyzing: 'stepAnalyzing',
|
|
connecting: 'stepConnecting',
|
|
generating: 'stepGenerating',
|
|
parsing: 'stepParsing',
|
|
saving: 'stepSaving',
|
|
building: 'stepBuilding',
|
|
};
|
|
|
|
const TICK_MS = 200;
|
|
const SIM_CEILING = 99;
|
|
const GENERATE_DURATION_MS = 120000;
|
|
|
|
const GenerationProgress: React.FC<GenerationProgressProps> = ({ progress, message, step }) => {
|
|
const { t } = useLanguage();
|
|
const [displayProgress, setDisplayProgress] = useState(0);
|
|
const [dots, setDots] = useState('');
|
|
const realProgressRef = useRef(progress);
|
|
const stepRef = useRef(step);
|
|
const simStartRef = useRef<number>(Date.now());
|
|
const simBaseRef = useRef<number>(0);
|
|
|
|
useEffect(() => {
|
|
stepRef.current = step;
|
|
if (progress > realProgressRef.current) {
|
|
realProgressRef.current = progress;
|
|
}
|
|
}, [progress, step]);
|
|
|
|
useEffect(() => {
|
|
simStartRef.current = Date.now();
|
|
simBaseRef.current = 0;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setDisplayProgress(prev => {
|
|
const real = realProgressRef.current;
|
|
|
|
if (real >= 100) return 100;
|
|
|
|
if (prev < real) {
|
|
const diff = real - prev;
|
|
const catchUp = Math.max(diff * 0.3, 2);
|
|
return Math.min(prev + catchUp, real);
|
|
}
|
|
|
|
if (prev < SIM_CEILING) {
|
|
const elapsed = Date.now() - simStartRef.current;
|
|
const ratio = Math.min(elapsed / GENERATE_DURATION_MS, 1);
|
|
const eased = 1 - Math.pow(1 - ratio, 3);
|
|
const simulated = simBaseRef.current + (SIM_CEILING - simBaseRef.current) * eased;
|
|
return Math.min(Math.max(simulated, prev + 0.05), SIM_CEILING);
|
|
}
|
|
|
|
return prev;
|
|
});
|
|
}, TICK_MS);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setDots(prev => prev.length >= 3 ? '' : prev + '.');
|
|
}, 500);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (displayProgress >= realProgressRef.current && displayProgress < SIM_CEILING) {
|
|
simStartRef.current = Date.now();
|
|
simBaseRef.current = displayProgress;
|
|
}
|
|
}, [displayProgress]);
|
|
|
|
const steps = ['analyzing', 'connecting', 'generating', 'parsing', 'saving', 'building'];
|
|
const currentStepIndex = step ? steps.indexOf(step) : -1;
|
|
const roundedProgress = Math.round(displayProgress);
|
|
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm">
|
|
<div
|
|
className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 w-full max-w-md mx-4 overflow-hidden"
|
|
style={{ animation: 'genProgressFadeIn 0.4s ease-out' }}
|
|
>
|
|
<div className="px-6 pt-6 pb-2">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="text-3xl" style={{ animation: 'genProgressPulse 2s ease-in-out infinite' }}>
|
|
{step && STEP_ICONS[step] ? STEP_ICONS[step] : '🚀'}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-base font-bold text-slate-800 dark:text-slate-100">
|
|
{t('generatingTitle')}
|
|
</h3>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
|
{message}{dots}
|
|
</p>
|
|
</div>
|
|
<span className="text-lg font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
|
{roundedProgress}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="relative h-3 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden mb-5">
|
|
<div
|
|
className="absolute inset-y-0 left-0 rounded-full"
|
|
style={{
|
|
width: `${displayProgress}%`,
|
|
background: 'linear-gradient(90deg, #10b981, #3b82f6, #8b5cf6)',
|
|
backgroundSize: '200% 100%',
|
|
animation: 'genProgressShimmer 2s linear infinite',
|
|
transition: 'width 0.2s linear',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 pb-5">
|
|
<div className="flex items-center justify-between gap-1">
|
|
{steps.map((s, i) => {
|
|
const isCompleted = i < currentStepIndex;
|
|
const isCurrent = i === currentStepIndex;
|
|
return (
|
|
<div key={s} className="flex flex-col items-center gap-1 flex-1">
|
|
<div
|
|
className={`
|
|
w-7 h-7 rounded-full flex items-center justify-center text-xs transition-all duration-300
|
|
${isCompleted
|
|
? 'bg-emerald-500 text-white shadow-md shadow-emerald-200 dark:shadow-emerald-900/30'
|
|
: isCurrent
|
|
? 'bg-blue-500 text-white shadow-md shadow-blue-200 dark:shadow-blue-900/30 scale-110'
|
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500'
|
|
}
|
|
`}
|
|
style={isCurrent ? { animation: 'genProgressPulse 1.5s ease-in-out infinite' } : undefined}
|
|
>
|
|
{isCompleted ? '✓' : STEP_ICONS[s]}
|
|
</div>
|
|
<span
|
|
className={`text-[10px] leading-tight text-center ${
|
|
isCurrent
|
|
? 'text-blue-600 dark:text-blue-400 font-semibold'
|
|
: isCompleted
|
|
? 'text-emerald-600 dark:text-emerald-400'
|
|
: 'text-slate-400 dark:text-slate-500'
|
|
}`}
|
|
>
|
|
{t(STEP_KEYS[s])}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-1 bg-slate-50 dark:bg-slate-900/50">
|
|
<div
|
|
className="h-full"
|
|
style={{
|
|
width: `${displayProgress}%`,
|
|
background: 'linear-gradient(90deg, #10b981, #3b82f6)',
|
|
transition: 'width 0.2s linear',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<style>{`
|
|
@keyframes genProgressFadeIn {
|
|
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
}
|
|
@keyframes genProgressPulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.6; }
|
|
}
|
|
@keyframes genProgressShimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
`}</style>
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|
|
|
|
export default GenerationProgress;
|