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(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 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(() => getStoredProgress() ) const [userInput, setUserInput] = useState([]) const [charStatuses, setCharStatuses] = useState([]) const [isComposing, setIsComposing] = useState(false) const [inputLocked, setInputLocked] = useState(false) const [lastErrorTimestamp, setLastErrorTimestamp] = useState( null ) const [showAnswer, setShowAnswer] = useState(false) const [correctFeedback, setCorrectFeedback] = useState(false) const isComposingRef = useRef(false) const latestInputRef = useRef([]) 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 (

子过程主要过程专项练习

根据主要作用写出对应的过程名称

进度:{completedCount} / {totalCount}
第 {Math.min(progress.currentIndex + 1, totalCount)} / {totalCount} 题 主要作用

{currentItem.purpose}

{isFinished ? (
已完成本轮练习

继续巩固 49 个项目管理过程。

) : ( <>
{correctFeedback && ( 回答正确 )}
{showAnswer && ( {currentItem.name} )}
)}
) } 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 ) => { 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 (
{userInput.map((char, index) => { const status = charStatuses[index] || 'pending' const isError = status === 'error' const isCorrect = status === 'correct' return ( (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" /> ) })}
) }