feat(练习): 添加完成练习时的庆祝动画

- 创建 CelebrationAnimation 组件,使用 CSS 动画实现彩带效果
- 矩阵练习完成最后一个格子时显示庆祝动画
- 过程详情练习完成所有条目时显示庆祝动画
- 动画持续2秒后自动消失

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
ittoview
2026-03-09 14:36:53 +00:00
parent 4f76fec906
commit 0b5f35d5b9
3 changed files with 108 additions and 4 deletions

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
interface CelebrationAnimationProps {
onComplete?: () => void
}
export function CelebrationAnimation({ onComplete }: CelebrationAnimationProps) {
const [confetti] = useState(() =>
Array.from({ length: 30 }, (_, i) => ({
id: i,
left: Math.random() * 100,
delay: Math.random() * 0.5,
duration: 1.5 + Math.random() * 0.5,
color: ['#f59e0b', '#3b82f6', '#ef4444', '#10b981', '#8b5cf6'][Math.floor(Math.random() * 5)],
}))
)
useEffect(() => {
const timer = setTimeout(() => {
onComplete?.()
}, 2000)
return () => clearTimeout(timer)
}, [onComplete])
return (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
{/* 恭喜文字 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 via-pink-500 to-purple-600 animate-bounce">
🎉
</div>
</div>
{/* 彩带 */}
{confetti.map((item) => (
<div
key={item.id}
className="absolute top-0 w-2 h-8 rounded-full animate-fall"
style={{
left: `${item.left}%`,
backgroundColor: item.color,
animationDelay: `${item.delay}s`,
animationDuration: `${item.duration}s`,
}}
/>
))}
<style>{`
@keyframes fall {
0% {
transform: translateY(-100px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(360deg);
opacity: 0.3;
}
}
.animate-fall {
animation: fall linear forwards;
}
`}</style>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { getProcessDetail, processes } from '@/data'
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { InputArea } from '@/components/practice/InputArea'
import { normalizeAnswer } from '@/utils/practice'
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
type IttoSection = 'inputs' | 'tools' | 'outputs'
type CharStatus = 'pending' | 'correct' | 'error'
@@ -102,6 +103,9 @@ export function ProcessDetailPage() {
const longPressTimerRef = useRef<number | null>(null)
const autoAdvanceTimerRef = useRef<number | null>(null)
// 庆祝动画
const [showCelebration, setShowCelebration] = useState(false)
const currentPracticeItem = useMemo(
() => practiceItems.find((item) => item.id === currentPracticeId) ?? null,
[practiceItems, currentPracticeId]
@@ -234,7 +238,15 @@ export function ProcessDetailPage() {
setAnsweredItems((prev) => new Set(prev).add(currentPracticeItem.id))
clearAutoAdvanceTimer()
if (currentPracticeIndex === practiceItems.length - 1) {
autoAdvanceTimerRef.current = window.setTimeout(() => exitPractice(), 500)
// 最后一个条目,显示庆祝动画
autoAdvanceTimerRef.current = window.setTimeout(() => {
setShowCelebration(true)
// 庆祝动画结束后退出练习
setTimeout(() => {
setShowCelebration(false)
exitPractice()
}, 2000)
}, 500)
} else {
autoAdvanceTimerRef.current = window.setTimeout(() => {
setCurrentPracticeId(practiceItems[currentPracticeIndex + 1].id)
@@ -743,6 +755,11 @@ export function ProcessDetailPage() {
</Link>
) : <div />}
</motion.div>
{/* 庆祝动画 */}
{showCelebration && (
<CelebrationAnimation onComplete={() => setShowCelebration(false)} />
)}
</div>
)
}

View File

@@ -9,6 +9,7 @@ import {
import { PracticeMatrix } from '@/components/practice/PracticeMatrix'
import { InputArea } from '@/components/practice/InputArea'
import { HintInfo } from '@/components/practice/HintInfo'
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
type CharStatus = 'pending' | 'correct' | 'error'
@@ -62,6 +63,9 @@ export default function ProcessPracticePage() {
const [inputLocked, setInputLocked] = useState(false)
const latestInputRef = useRef<string[]>([])
// 庆祝动画
const [showCelebration, setShowCelebration] = useState(false)
// 初始化输入框
useEffect(() => {
const currentCell = cellSequence.find((c) => c.id === currentCellId)
@@ -184,9 +188,22 @@ export default function ProcessPracticePage() {
if (isCorrect) {
setAnsweredCells((prev) => new Map(prev).set(currentCellId, true))
setTimeout(() => {
moveToNextCell()
}, 300)
// 检查是否完成所有格子
const currentIndex = cellSequence.findIndex((c) => c.id === currentCellId)
const isLastCell = currentIndex === cellSequence.length - 1
if (isLastCell) {
// 最后一个格子,显示庆祝动画
setTimeout(() => {
setShowCelebration(true)
}, 300)
} else {
// 不是最后一个,继续下一个
setTimeout(() => {
moveToNextCell()
}, 300)
}
} else if (isComplete) {
setLastErrorTimestamp(Date.now())
}
@@ -495,6 +512,11 @@ export default function ProcessPracticePage() {
aria-live="polite"
aria-atomic="true"
/>
{/* 庆祝动画 */}
{showCelebration && (
<CelebrationAnimation onComplete={() => setShowCelebration(false)} />
)}
</div>
)
}