feat: add process purpose practice page
This commit is contained in:
569
src/pages/ProcessPurposePracticePage.tsx
Normal file
569
src/pages/ProcessPurposePracticePage.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
import { processPurposePracticeItems } from '@/data/process-purpose-practice'
|
||||
import { normalizeAnswer, announceToScreenReader } from '@/utils/practice'
|
||||
|
||||
type CharStatus = 'pending' | 'correct' | 'error'
|
||||
|
||||
type PracticeProgress = {
|
||||
order: string[]
|
||||
currentIndex: number
|
||||
completedIds: string[]
|
||||
currentInput: string[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'process-purpose-practice-progress'
|
||||
const ANSWER_VISIBLE_DURATION = 3000
|
||||
const NEXT_QUESTION_DELAY = 650
|
||||
|
||||
function shuffleItems<T>(items: T[]): T[] {
|
||||
const result = [...items]
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[result[i], result[j]] = [result[j], result[i]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createFreshProgress(): PracticeProgress {
|
||||
return {
|
||||
order: shuffleItems(processPurposePracticeItems.map((item) => item.id)),
|
||||
currentIndex: 0,
|
||||
completedIds: [],
|
||||
currentInput: [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProcessName(value: string): string {
|
||||
return normalizeAnswer(value).replace(/wbs/g, 'WBS'.toLowerCase())
|
||||
}
|
||||
|
||||
function getStoredProgress(): PracticeProgress {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) return createFreshProgress()
|
||||
|
||||
const parsed = JSON.parse(saved) as Partial<PracticeProgress>
|
||||
const validIds = new Set(processPurposePracticeItems.map((item) => item.id))
|
||||
const order = Array.isArray(parsed.order)
|
||||
? parsed.order.filter((id): id is string => validIds.has(String(id)))
|
||||
: []
|
||||
const missingIds = processPurposePracticeItems
|
||||
.map((item) => item.id)
|
||||
.filter((id) => !order.includes(id))
|
||||
const completedIds = Array.isArray(parsed.completedIds)
|
||||
? parsed.completedIds.filter((id): id is string => validIds.has(String(id)))
|
||||
: []
|
||||
|
||||
const mergedOrder = [...order, ...shuffleItems(missingIds)]
|
||||
if (mergedOrder.length === 0) return createFreshProgress()
|
||||
|
||||
return {
|
||||
order: mergedOrder,
|
||||
currentIndex: Math.min(
|
||||
Math.max(Number(parsed.currentIndex) || 0, 0),
|
||||
mergedOrder.length - 1
|
||||
),
|
||||
completedIds,
|
||||
currentInput: Array.isArray(parsed.currentInput)
|
||||
? parsed.currentInput.map((char) => String(char || ''))
|
||||
: [],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载子过程主要作用练习进度失败:', error)
|
||||
return createFreshProgress()
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProcessPurposePracticePage() {
|
||||
const [progress, setProgress] = useState<PracticeProgress>(() =>
|
||||
getStoredProgress()
|
||||
)
|
||||
const [userInput, setUserInput] = useState<string[]>([])
|
||||
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const [inputLocked, setInputLocked] = useState(false)
|
||||
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
const [showAnswer, setShowAnswer] = useState(false)
|
||||
const [correctFeedback, setCorrectFeedback] = useState(false)
|
||||
const isComposingRef = useRef(false)
|
||||
const latestInputRef = useRef<string[]>([])
|
||||
|
||||
const itemMap = useMemo(
|
||||
() => new Map(processPurposePracticeItems.map((item) => [item.id, item])),
|
||||
[]
|
||||
)
|
||||
const currentItem = itemMap.get(progress.order[progress.currentIndex])
|
||||
const totalCount = progress.order.length
|
||||
const completedCount = progress.completedIds.length
|
||||
const isFinished = completedCount >= totalCount
|
||||
|
||||
const resetInputForItem = useCallback((answer: string, input?: string[]) => {
|
||||
const nextInput = new Array(answer.length).fill('')
|
||||
if (input) {
|
||||
input.slice(0, answer.length).forEach((char, index) => {
|
||||
nextInput[index] = char
|
||||
})
|
||||
}
|
||||
latestInputRef.current = nextInput
|
||||
setUserInput(nextInput)
|
||||
setCharStatuses(new Array(answer.length).fill('pending'))
|
||||
setLastErrorTimestamp(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentItem) return
|
||||
resetInputForItem(currentItem.name, progress.currentInput)
|
||||
}, [currentItem?.id])
|
||||
|
||||
useEffect(() => {
|
||||
latestInputRef.current = userInput
|
||||
}, [userInput])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
...progress,
|
||||
currentInput: userInput,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('保存子过程主要作用练习进度失败:', error)
|
||||
}
|
||||
}, [progress, userInput])
|
||||
|
||||
const focusFirstEmptyInput = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll('.practice-input-area input')
|
||||
const firstEmptyInput = Array.from(inputs).find(
|
||||
(input) => !(input as HTMLInputElement).value
|
||||
) as HTMLInputElement | undefined
|
||||
;(firstEmptyInput || (inputs[0] as HTMLInputElement | undefined))?.focus()
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const moveToNextQuestion = useCallback(() => {
|
||||
setShowAnswer(false)
|
||||
setCorrectFeedback(false)
|
||||
setInputLocked(false)
|
||||
setProgress((prev) => {
|
||||
const nextIndex = Math.min(prev.currentIndex + 1, prev.order.length - 1)
|
||||
return {
|
||||
...prev,
|
||||
currentIndex: nextIndex,
|
||||
currentInput: [],
|
||||
}
|
||||
})
|
||||
focusFirstEmptyInput()
|
||||
}, [focusFirstEmptyInput])
|
||||
|
||||
const validateInput = useCallback(
|
||||
(input: string[]) => {
|
||||
if (!currentItem || inputLocked || isFinished) return
|
||||
|
||||
const answer = currentItem.name
|
||||
const normalizeChar = (char: string) => normalizeAnswer(char) || char
|
||||
const newCharStatuses = input.map((char, index) => {
|
||||
if (!char) return 'pending' as CharStatus
|
||||
return normalizeChar(char) === normalizeChar(answer[index] || '')
|
||||
? ('correct' as CharStatus)
|
||||
: ('error' as CharStatus)
|
||||
})
|
||||
setCharStatuses(newCharStatuses)
|
||||
|
||||
const isComplete = input.every(Boolean) && input.length === answer.length
|
||||
const isCorrect =
|
||||
isComplete && normalizeProcessName(input.join('')) === normalizeProcessName(answer)
|
||||
|
||||
if (isCorrect) {
|
||||
setCorrectFeedback(true)
|
||||
setInputLocked(true)
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
completedIds: prev.completedIds.includes(currentItem.id)
|
||||
? prev.completedIds
|
||||
: [...prev.completedIds, currentItem.id],
|
||||
currentInput: input,
|
||||
}))
|
||||
announceToScreenReader('回答正确')
|
||||
setTimeout(() => {
|
||||
const isLastQuestion = progress.currentIndex >= progress.order.length - 1
|
||||
if (isLastQuestion) {
|
||||
setInputLocked(false)
|
||||
setCorrectFeedback(false)
|
||||
} else {
|
||||
moveToNextQuestion()
|
||||
}
|
||||
}, NEXT_QUESTION_DELAY)
|
||||
} else if (isComplete) {
|
||||
setLastErrorTimestamp(Date.now())
|
||||
}
|
||||
},
|
||||
[currentItem, inputLocked, isFinished, moveToNextQuestion, progress.currentIndex, progress.order.length]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(newInput: string[]) => {
|
||||
latestInputRef.current = newInput
|
||||
setUserInput(newInput)
|
||||
if (isComposingRef.current) return
|
||||
validateInput(newInput)
|
||||
},
|
||||
[validateInput]
|
||||
)
|
||||
|
||||
const handleCompositionStart = useCallback(() => {
|
||||
isComposingRef.current = true
|
||||
setIsComposing(true)
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = useCallback(
|
||||
(index: number, value: string) => {
|
||||
isComposingRef.current = false
|
||||
setIsComposing(false)
|
||||
requestAnimationFrame(() => {
|
||||
const newInput = [...latestInputRef.current]
|
||||
const chars = value.split('')
|
||||
if (chars.length > 0) {
|
||||
for (let i = 0; i < chars.length && index + i < newInput.length; i++) {
|
||||
newInput[index + i] = chars[i]
|
||||
}
|
||||
} else {
|
||||
newInput[index] = ''
|
||||
}
|
||||
handleInputChange(newInput)
|
||||
})
|
||||
},
|
||||
[handleInputChange]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
if (!currentItem || inputLocked) return
|
||||
const nextInput = new Array(currentItem.name.length).fill('')
|
||||
e.clipboardData
|
||||
.getData('text')
|
||||
.split('')
|
||||
.slice(0, currentItem.name.length)
|
||||
.forEach((char, index) => {
|
||||
nextInput[index] = char
|
||||
})
|
||||
handleInputChange(nextInput)
|
||||
},
|
||||
[currentItem, handleInputChange, inputLocked]
|
||||
)
|
||||
|
||||
const handleShowAnswer = useCallback(() => {
|
||||
if (!currentItem) return
|
||||
setShowAnswer(true)
|
||||
setInputLocked(true)
|
||||
announceToScreenReader('答案已显示')
|
||||
|
||||
window.setTimeout(() => {
|
||||
setShowAnswer(false)
|
||||
setInputLocked(false)
|
||||
announceToScreenReader('答案已隐藏')
|
||||
focusFirstEmptyInput()
|
||||
}, ANSWER_VISIBLE_DURATION)
|
||||
}, [currentItem, focusFirstEmptyInput])
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
const fresh = createFreshProgress()
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh))
|
||||
setProgress(fresh)
|
||||
setShowAnswer(false)
|
||||
setCorrectFeedback(false)
|
||||
setInputLocked(false)
|
||||
focusFirstEmptyInput()
|
||||
}, [focusFirstEmptyInput])
|
||||
|
||||
if (!currentItem) return null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="sticky top-0 z-20 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
|
||||
<div className="mx-auto max-w-5xl px-4 py-4">
|
||||
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
子过程主要过程专项练习
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
根据主要作用写出对应的过程名称
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
进度:{completedCount} / {totalCount}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100"
|
||||
>
|
||||
重新练习
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<motion.div
|
||||
className="h-2 rounded-full bg-blue-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(completedCount / totalCount) * 100}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8">
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
第 {Math.min(progress.currentIndex + 1, totalCount)} / {totalCount} 题
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
主要作用
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xl leading-9 text-gray-900 dark:text-gray-100">
|
||||
{currentItem.purpose}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
{isFinished ? (
|
||||
<div className="flex flex-col items-center gap-4 py-10 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
已完成本轮练习
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
继续巩固 49 个项目管理过程。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
className="rounded-xl bg-blue-600 px-5 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
再练一轮
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col items-center gap-4">
|
||||
<PracticeCharInputs
|
||||
userInput={userInput}
|
||||
charStatuses={charStatuses}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
lastErrorTimestamp={lastErrorTimestamp}
|
||||
onInputChange={handleInputChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{correctFeedback && (
|
||||
<motion.div
|
||||
key="correct"
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
className="rounded-full bg-green-50 px-4 py-1.5 text-sm font-medium text-green-700 dark:bg-green-900/30 dark:text-green-200"
|
||||
>
|
||||
回答正确
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 border-t border-gray-100 pt-5 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowAnswer}
|
||||
disabled={inputLocked}
|
||||
className="rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:border-blue-400 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:border-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
显示答案
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAnswer && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
className="rounded-xl bg-gray-100 px-5 py-3 text-lg font-semibold text-gray-900 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
{currentItem.name}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div
|
||||
id="aria-live-region"
|
||||
className="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PracticeCharInputsProps {
|
||||
userInput: string[]
|
||||
charStatuses: CharStatus[]
|
||||
isComposing: boolean
|
||||
inputLocked: boolean
|
||||
lastErrorTimestamp: number | null
|
||||
onInputChange: (newInput: string[]) => void
|
||||
onCompositionStart: () => void
|
||||
onCompositionEnd: (index: number, value: string) => void
|
||||
onPaste: (e: React.ClipboardEvent) => void
|
||||
}
|
||||
|
||||
function PracticeCharInputs({
|
||||
userInput,
|
||||
charStatuses,
|
||||
isComposing,
|
||||
inputLocked,
|
||||
lastErrorTimestamp,
|
||||
onInputChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
onPaste,
|
||||
}: PracticeCharInputsProps) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isComposing || inputLocked) return
|
||||
const firstEmptyIndex = userInput.findIndex((char) => !char)
|
||||
if (firstEmptyIndex !== -1) {
|
||||
inputRefs.current[firstEmptyIndex]?.focus()
|
||||
}
|
||||
}, [userInput, isComposing, inputLocked])
|
||||
|
||||
const handleCharInput = (
|
||||
index: number,
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (inputLocked) return
|
||||
|
||||
const value = e.target.value
|
||||
const nativeIsComposing =
|
||||
typeof (e.nativeEvent as InputEvent).isComposing === 'boolean'
|
||||
? (e.nativeEvent as InputEvent).isComposing
|
||||
: false
|
||||
const composing = isComposing || nativeIsComposing
|
||||
const newInput = [...userInput]
|
||||
|
||||
if (composing) {
|
||||
newInput[index] = value
|
||||
onInputChange(newInput)
|
||||
return
|
||||
}
|
||||
|
||||
if (value.length > 1) {
|
||||
value
|
||||
.split('')
|
||||
.slice(0, userInput.length - index)
|
||||
.forEach((char, offset) => {
|
||||
newInput[index + offset] = char
|
||||
})
|
||||
onInputChange(newInput)
|
||||
inputRefs.current[Math.min(index + value.length, userInput.length - 1)]?.focus()
|
||||
} else {
|
||||
newInput[index] = value
|
||||
onInputChange(newInput)
|
||||
if (value && index < userInput.length - 1) {
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (inputLocked) return
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
const newInput = [...userInput]
|
||||
if (newInput[index]) {
|
||||
newInput[index] = ''
|
||||
onInputChange(newInput)
|
||||
} else if (index > 0) {
|
||||
newInput[index - 1] = ''
|
||||
onInputChange(newInput)
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && index > 0) {
|
||||
e.preventDefault()
|
||||
inputRefs.current[index - 1]?.focus()
|
||||
} else if (e.key === 'ArrowRight' && index < userInput.length - 1) {
|
||||
e.preventDefault()
|
||||
inputRefs.current[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="practice-input-area flex flex-wrap justify-center gap-3">
|
||||
{userInput.map((char, index) => {
|
||||
const status = charStatuses[index] || 'pending'
|
||||
const isError = status === 'error'
|
||||
const isCorrect = status === 'correct'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative"
|
||||
animate={
|
||||
isError && lastErrorTimestamp
|
||||
? {
|
||||
x: [0, -8, 8, -8, 8, 0],
|
||||
transition: { duration: 0.35 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<input
|
||||
ref={(el) => (inputRefs.current[index] = el)}
|
||||
type="text"
|
||||
value={char}
|
||||
onChange={(e) => handleCharInput(index, e)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={(e) => onCompositionEnd(index, e.currentTarget.value)}
|
||||
onPaste={onPaste}
|
||||
disabled={inputLocked}
|
||||
placeholder="_"
|
||||
className={clsx(
|
||||
'h-12 w-10 border-b-2 bg-transparent text-center text-2xl font-medium transition-all duration-200',
|
||||
'text-gray-900 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500',
|
||||
isComposing && 'border-gray-300 opacity-70 dark:border-gray-600',
|
||||
!isComposing && !char && 'border-gray-400 dark:border-gray-500',
|
||||
!isComposing && isCorrect && 'border-green-500',
|
||||
!isComposing && isError && 'border-red-500',
|
||||
inputLocked && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user