// 加载动画组件 - 大猫头 + 波浪猫爪 import { useEffect, useState } from 'react'; import { useI18n } from '../i18n'; // ============================================================================ // 类型 & 配置 // ============================================================================ type Stage = 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering'; interface LoadingSpinnerProps { stage: Stage; jobId?: string; onCancel?: () => void; } const STAGE_CONFIG = { analyzing: { key: 'loading.analyzing', start: 0, target: 20 }, generating: { key: 'loading.generating', start: 20, target: 66 }, refining: { key: 'loading.refining', start: 66, target: 85 }, rendering: { key: 'loading.rendering', start: 85, target: 97 }, 'still-rendering': { key: 'loading.stillRendering', start: 85, target: 97 }, } as const; // ============================================================================ // 进度算法 - 阶段目标值 + 持续增长安慰机制 // ============================================================================ function usePerceivedProgress(stage: Stage): number { const [progress, setProgress] = useState(0); const [prevStage, setPrevStage] = useState(stage); const [stageStartProgress, setStageStartProgress] = useState(0); const [enteredAt, setEnteredAt] = useState(Date.now()); // 阶段变化时记录起点,确保进度单调递增不回退 useEffect(() => { if (stage !== prevStage) { setPrevStage(stage); setEnteredAt(Date.now()); setStageStartProgress((current) => Math.max(current, progress, STAGE_CONFIG[stage].start)); } }, [stage, prevStage, progress]); // 按阶段目标值推进:前快后慢 + 长耗时每4秒+1% useEffect(() => { const id = setInterval(() => { const elapsed = (Date.now() - enteredAt) / 1000; const { target } = STAGE_CONFIG[stage]; const start = Math.max(stageStartProgress, STAGE_CONFIG[stage].start); const range = Math.max(0, target - start); // 前段快速接近目标(不一次冲到顶) const quickGain = range * 0.72 * (1 - Math.exp(-elapsed / 5)); // 长耗时阶段:超过10秒后每4秒+1%,避免“卡死感” const comfortGain = elapsed > 10 ? Math.floor((elapsed - 10) / 4) : 0; const next = Math.min(target, start + quickGain + comfortGain); setProgress((current) => Math.max(current, next)); }, 120); return () => clearInterval(id); }, [stage, enteredAt, stageStartProgress]); // processing 阶段最高展示 97%,完成后由结果态切换 return Math.min(97, progress); } // ============================================================================ // 子组件 // ============================================================================ /** 大猫头 SVG */ function CatHead() { return ( ); } /** 浮动猫头 */ function FloatingCat() { const [y, setY] = useState(0); useEffect(() => { let t = 0; let id: number; const animate = () => { t += 0.02; setY(Math.sin(t) * 5); id = requestAnimationFrame(animate); }; id = requestAnimationFrame(animate); return () => cancelAnimationFrame(id); }, []); return (
); } /** 单个猫爪印 - 带波浪动画 */ function WavingPaw({ index, total }: { index: number; total: number }) { const [scale, setScale] = useState(1); const [y, setY] = useState(0); const [opacity, setOpacity] = useState(0.25); useEffect(() => { let t = 0; const phase = (index / total) * Math.PI * 2; // 错开相位 let id: number; const animate = () => { t += 0.04; // 波浪上下起伏 const wave = Math.sin(t + phase) * 4; setY(wave); // 大小脉动 (1.0 ~ 1.3) const pulse = 1 + Math.sin(t + phase) * 0.15; setScale(pulse); // 透明度变化 (0.3 ~ 0.8) const alpha = 0.55 + Math.sin(t + phase) * 0.25; setOpacity(alpha); id = requestAnimationFrame(animate); }; id = requestAnimationFrame(animate); return () => cancelAnimationFrame(id); }, [index, total]); return (
); } /** 波浪猫爪行 */ function WavingPaws() { const count = 7; return (
{Array.from({ length: count }, (_, i) => ( ))}
); } // ============================================================================ // 主组件 // ============================================================================ export function LoadingSpinner({ stage, jobId, onCancel }: LoadingSpinnerProps) { const { t } = useI18n(); const progress = usePerceivedProgress(stage); const { key } = STAGE_CONFIG[stage]; return (
{/* 大猫头 */} {/* 波浪猫爪 */}
{/* 状态文字 + 百分比 */}

{t(key)}

{Math.round(progress)}%

{/* Job ID + 取消 */}
{jobId && ( {jobId.slice(0, 8)} )} {onCancel && ( )}
); }