// 加载动画组件 - 大猫头 + 波浪猫爪 import { useEffect, useState, useRef } from 'react'; import { useI18n } from '../i18n'; type Stage = 'analyzing' | 'generating' | 'refining' | 'rendering' | 'still-rendering'; interface LoadingSpinnerProps { stage: Stage; jobId?: string; submittedAt?: string; onCancel?: () => void; onOpenGame?: () => 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 prevStageRef = useRef(stage); const stageStartProgressRef = useRef(0); const enteredAtRef = useRef(null); useEffect(() => { if (enteredAtRef.current === null) { enteredAtRef.current = Date.now(); } if (stage !== prevStageRef.current) { prevStageRef.current = stage; enteredAtRef.current = Date.now(); stageStartProgressRef.current = Math.max(stageStartProgressRef.current, progress, STAGE_CONFIG[stage].start); } }, [stage, progress]); useEffect(() => { const id = setInterval(() => { const enteredAt = enteredAtRef.current ?? Date.now(); const elapsed = (Date.now() - enteredAt) / 1000; const { target } = STAGE_CONFIG[stage]; const start = Math.max(stageStartProgressRef.current, STAGE_CONFIG[stage].start); const range = Math.max(0, target - start); const quickGain = range * 0.72 * (1 - Math.exp(-elapsed / 5)); 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]); return Math.min(97, progress); } /** 大猫头 SVG - 眼睛灵敏转动 */ function CatHead() { const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const dx = e.clientX - centerX; const dy = e.clientY - centerY; const dist = Math.sqrt(dx * dx + dy * dy); // 灵敏算法:减小阻尼,增大范围 const limit = 6; const sensitivity = 20; // 越小越灵敏 const moveX = (dx / (dist + sensitivity)) * limit; const moveY = (dy / (dist + sensitivity)) * limit; setEyeOffset({ x: moveX, y: moveY }); }; window.addEventListener('mousemove', handleMouseMove); return () => window.removeEventListener('mousemove', handleMouseMove); }, []); 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; setY(Math.sin(t + phase) * 4); setScale(1 + Math.sin(t + phase) * 0.15); setOpacity(0.55 + Math.sin(t + phase) * 0.25); id = requestAnimationFrame(animate); }; id = requestAnimationFrame(animate); return () => cancelAnimationFrame(id); }, [index, total]); return (
); } export function LoadingSpinner({ stage, jobId, submittedAt, onCancel, onOpenGame }: LoadingSpinnerProps) { const { t } = useI18n(); const progress = usePerceivedProgress(stage); const { key } = STAGE_CONFIG[stage]; const [confirmCancelOpen, setConfirmCancelOpen] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); useEffect(() => { if (!submittedAt) { setElapsedMs(0); return; } const submittedTime = Date.parse(submittedAt); if (!Number.isFinite(submittedTime)) { setElapsedMs(0); return; } const updateElapsed = () => { setElapsedMs(Math.max(0, Date.now() - submittedTime)); }; updateElapsed(); const timer = window.setInterval(updateElapsed, 250); return () => window.clearInterval(timer); }, [submittedAt]); return (
{onOpenGame && (
{Array.from({ length: 7 }, (_, i) => )}

{t(key)}

{Math.round(progress)}%

{elapsedMs > 0 && (

{formatElapsed(elapsedMs)}

)}
{jobId && {jobId.slice(0, 8)}} {onCancel && ( )}
{confirmCancelOpen && onCancel ? (
setConfirmCancelOpen(false)} />

中止任务?

猫猫已经跑了一半了,确定要停下来吗?

) : null}
); } function formatElapsed(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; }