ManimCat-show / frontend /src /components /LoadingSpinner.tsx
Bin29's picture
Add bilingual docs and frontend i18n
a356243
Raw
History Blame Contribute Delete
7 kB
// 加载动画组件 - 大猫头 + 波浪猫爪
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 (
<svg width={100} height={100} viewBox="0 0 140 140" className="drop-shadow-lg">
<g transform="translate(70, 70)">
<path
d="M -70 40 C -80 0, -80 -30, -50 -60 L -20 -30 L 20 -30 L 50 -60 C 80 -30, 80 0, 70 40 C 60 70, -60 70, -70 40 Z"
fill="#455a64"
/>
<circle cx="-35" cy="-5" r="18" fill="#fff" />
<circle cx="35" cy="-5" r="18" fill="#fff" />
<circle cx="-38" cy="-5" r="6" fill="#455a64" />
<circle cx="32" cy="-5" r="6" fill="#455a64" />
</g>
</svg>
);
}
/** 浮动猫头 */
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 (
<div style={{ transform: `translateY(${y}px)` }}>
<CatHead />
</div>
);
}
/** 单个猫爪印 - 带波浪动画 */
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 (
<div
style={{
transform: `translateY(${y}px) scale(${scale})`,
opacity,
transition: 'opacity 0.1s',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24">
<ellipse cx="12" cy="15" rx="5" ry="4" className="fill-text-secondary" />
<circle cx="7" cy="9" r="2.2" className="fill-text-secondary" />
<circle cx="12" cy="7" r="2.2" className="fill-text-secondary" />
<circle cx="17" cy="9" r="2.2" className="fill-text-secondary" />
</svg>
</div>
);
}
/** 波浪猫爪行 */
function WavingPaws() {
const count = 7;
return (
<div className="flex items-center justify-center gap-2">
{Array.from({ length: count }, (_, i) => (
<WavingPaw key={i} index={i} total={count} />
))}
</div>
);
}
// ============================================================================
// 主组件
// ============================================================================
export function LoadingSpinner({ stage, jobId, onCancel }: LoadingSpinnerProps) {
const { t } = useI18n();
const progress = usePerceivedProgress(stage);
const { key } = STAGE_CONFIG[stage];
return (
<div className="flex flex-col items-center justify-center py-6">
{/* 大猫头 */}
<FloatingCat />
{/* 波浪猫爪 */}
<div className="mt-3">
<WavingPaws />
</div>
{/* 状态文字 + 百分比 */}
<div className="mt-4 text-center">
<p className="text-base text-text-primary/80">{t(key)}</p>
<p className="text-sm text-text-secondary/60 tabular-nums mt-1">
{Math.round(progress)}%
</p>
</div>
{/* Job ID + 取消 */}
<div className="flex items-center gap-3 mt-3">
{jobId && (
<span className="text-xs text-text-secondary/40 font-mono">
{jobId.slice(0, 8)}
</span>
)}
{onCancel && (
<button
onClick={onCancel}
className="text-xs text-text-secondary/40 hover:text-red-500 transition-colors"
>
{t('common.cancel')}
</button>
)}
</div>
</div>
);
}