Files
goal-planner/frontend/components/GenerationProgress.tsx
T
2026-11-02 16:43:15 +08:00

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;