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 { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||||
import { InputArea } from '@/components/practice/InputArea'
|
import { InputArea } from '@/components/practice/InputArea'
|
||||||
import { normalizeAnswer } from '@/utils/practice'
|
import { normalizeAnswer } from '@/utils/practice'
|
||||||
|
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||||||
|
|
||||||
type IttoSection = 'inputs' | 'tools' | 'outputs'
|
type IttoSection = 'inputs' | 'tools' | 'outputs'
|
||||||
type CharStatus = 'pending' | 'correct' | 'error'
|
type CharStatus = 'pending' | 'correct' | 'error'
|
||||||
@@ -102,6 +103,9 @@ export function ProcessDetailPage() {
|
|||||||
const longPressTimerRef = useRef<number | null>(null)
|
const longPressTimerRef = useRef<number | null>(null)
|
||||||
const autoAdvanceTimerRef = useRef<number | null>(null)
|
const autoAdvanceTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// 庆祝动画
|
||||||
|
const [showCelebration, setShowCelebration] = useState(false)
|
||||||
|
|
||||||
const currentPracticeItem = useMemo(
|
const currentPracticeItem = useMemo(
|
||||||
() => practiceItems.find((item) => item.id === currentPracticeId) ?? null,
|
() => practiceItems.find((item) => item.id === currentPracticeId) ?? null,
|
||||||
[practiceItems, currentPracticeId]
|
[practiceItems, currentPracticeId]
|
||||||
@@ -234,7 +238,15 @@ export function ProcessDetailPage() {
|
|||||||
setAnsweredItems((prev) => new Set(prev).add(currentPracticeItem.id))
|
setAnsweredItems((prev) => new Set(prev).add(currentPracticeItem.id))
|
||||||
clearAutoAdvanceTimer()
|
clearAutoAdvanceTimer()
|
||||||
if (currentPracticeIndex === practiceItems.length - 1) {
|
if (currentPracticeIndex === practiceItems.length - 1) {
|
||||||
autoAdvanceTimerRef.current = window.setTimeout(() => exitPractice(), 500)
|
// 最后一个条目,显示庆祝动画
|
||||||
|
autoAdvanceTimerRef.current = window.setTimeout(() => {
|
||||||
|
setShowCelebration(true)
|
||||||
|
// 庆祝动画结束后退出练习
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCelebration(false)
|
||||||
|
exitPractice()
|
||||||
|
}, 2000)
|
||||||
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
autoAdvanceTimerRef.current = window.setTimeout(() => {
|
autoAdvanceTimerRef.current = window.setTimeout(() => {
|
||||||
setCurrentPracticeId(practiceItems[currentPracticeIndex + 1].id)
|
setCurrentPracticeId(practiceItems[currentPracticeIndex + 1].id)
|
||||||
@@ -743,6 +755,11 @@ export function ProcessDetailPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 庆祝动画 */}
|
||||||
|
{showCelebration && (
|
||||||
|
<CelebrationAnimation onComplete={() => setShowCelebration(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { PracticeMatrix } from '@/components/practice/PracticeMatrix'
|
import { PracticeMatrix } from '@/components/practice/PracticeMatrix'
|
||||||
import { InputArea } from '@/components/practice/InputArea'
|
import { InputArea } from '@/components/practice/InputArea'
|
||||||
import { HintInfo } from '@/components/practice/HintInfo'
|
import { HintInfo } from '@/components/practice/HintInfo'
|
||||||
|
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||||||
|
|
||||||
type CharStatus = 'pending' | 'correct' | 'error'
|
type CharStatus = 'pending' | 'correct' | 'error'
|
||||||
|
|
||||||
@@ -62,6 +63,9 @@ export default function ProcessPracticePage() {
|
|||||||
const [inputLocked, setInputLocked] = useState(false)
|
const [inputLocked, setInputLocked] = useState(false)
|
||||||
const latestInputRef = useRef<string[]>([])
|
const latestInputRef = useRef<string[]>([])
|
||||||
|
|
||||||
|
// 庆祝动画
|
||||||
|
const [showCelebration, setShowCelebration] = useState(false)
|
||||||
|
|
||||||
// 初始化输入框
|
// 初始化输入框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentCell = cellSequence.find((c) => c.id === currentCellId)
|
const currentCell = cellSequence.find((c) => c.id === currentCellId)
|
||||||
@@ -184,9 +188,22 @@ export default function ProcessPracticePage() {
|
|||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
setAnsweredCells((prev) => new Map(prev).set(currentCellId, true))
|
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) {
|
} else if (isComplete) {
|
||||||
setLastErrorTimestamp(Date.now())
|
setLastErrorTimestamp(Date.now())
|
||||||
}
|
}
|
||||||
@@ -495,6 +512,11 @@ export default function ProcessPracticePage() {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 庆祝动画 */}
|
||||||
|
{showCelebration && (
|
||||||
|
<CelebrationAnimation onComplete={() => setShowCelebration(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user