feat(练习): 添加完成练习时的庆祝动画
- 创建 CelebrationAnimation 组件,使用 CSS 动画实现彩带效果 - 矩阵练习完成最后一个格子时显示庆祝动画 - 过程详情练习完成所有条目时显示庆祝动画 - 动画持续2秒后自动消失 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
65
src/components/practice/CelebrationAnimation.tsx
Normal file
65
src/components/practice/CelebrationAnimation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user