From a1b8f3064d6d410638d9a369ae0036bb0ac18401 Mon Sep 17 00:00:00 2001 From: ittoview Date: Sun, 10 May 2026 15:36:24 +0100 Subject: [PATCH] feat: support practice input modes --- src/pages/ProcessPurposePracticePage.tsx | 143 ++++++++++++++++++++--- 1 file changed, 125 insertions(+), 18 deletions(-) diff --git a/src/pages/ProcessPurposePracticePage.tsx b/src/pages/ProcessPurposePracticePage.tsx index c9450bd..7044e92 100644 --- a/src/pages/ProcessPurposePracticePage.tsx +++ b/src/pages/ProcessPurposePracticePage.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { processPurposePracticeItems } from '@/data/process-purpose-practice' import { InputArea } from '@/components/practice/InputArea' +import { ProficientInputArea } from '@/components/practice/ProficientInputArea' +import { useAppStore } from '@/stores/useAppStore' import { normalizeAnswer, announceToScreenReader } from '@/utils/practice' type CharStatus = 'pending' | 'correct' | 'error' @@ -85,9 +87,12 @@ export default function ProcessPurposePracticePage() { ) const [showAnswer, setShowAnswer] = useState(false) const [correctFeedback, setCorrectFeedback] = useState(false) + const [proficientInput, setProficientInput] = useState('') + const [proficientHasError, setProficientHasError] = useState(false) const isComposingRef = useRef(false) const latestInputRef = useRef([]) const answerTimerRef = useRef(null) + const { practiceMode } = useAppStore() const itemMap = useMemo( () => new Map(processPurposePracticeItems.map((item) => [item.id, item])), @@ -124,13 +129,17 @@ export default function ProcessPurposePracticePage() { if (answerTimerRef.current) { window.clearTimeout(answerTimerRef.current) } + if (practiceMode === 'proficient') { + setProficientInput('') + setProficientHasError(false) + } setShowAnswer(true) setInputLocked(true) announceToScreenReader('答案已显示') answerTimerRef.current = window.setTimeout(() => { hideAnswer() }, ANSWER_VISIBLE_DURATION) - }, [currentItem, hideAnswer, isFinished]) + }, [currentItem, hideAnswer, isFinished, practiceMode]) useEffect(() => { if (!currentItem) return @@ -146,6 +155,8 @@ export default function ProcessPurposePracticePage() { setShowAnswer(false) setCorrectFeedback(false) setInputLocked(false) + setProficientInput('') + setProficientHasError(false) }, [currentItem?.id]) useEffect(() => { @@ -172,8 +183,13 @@ export default function ProcessPurposePracticePage() { e.preventDefault() if (!showAnswer) showCurrentAnswer() } else if (e.key === 'Escape' && !inputLocked) { - setUserInput(new Array(userInput.length).fill('')) - setCharStatuses(new Array(userInput.length).fill('pending')) + if (practiceMode === 'proficient') { + setProficientInput('') + setProficientHasError(false) + } else { + setUserInput(new Array(userInput.length).fill('')) + setCharStatuses(new Array(userInput.length).fill('pending')) + } } } @@ -189,7 +205,7 @@ export default function ProcessPurposePracticePage() { window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keyup', handleKeyUp) } - }, [hideAnswer, inputLocked, showAnswer, showCurrentAnswer, userInput.length]) + }, [hideAnswer, inputLocked, practiceMode, showAnswer, showCurrentAnswer, userInput.length]) useEffect(() => { return () => { @@ -266,15 +282,64 @@ export default function ProcessPurposePracticePage() { [validateInput] ) - const handleCompositionStart = useCallback((_index: number) => { + const handleCompositionStart = useCallback((_index?: number) => { isComposingRef.current = true setIsComposing(true) }, []) + const submitProficientAnswer = useCallback( + (rawValue: string) => { + if (!currentItem || inputLocked || isFinished) return + + const normalizedValue = normalizeAnswer(rawValue) + if (!normalizedValue) return + + if (normalizedValue !== normalizeAnswer(currentItem.name)) { + setProficientHasError(true) + return + } + + setCorrectFeedback(true) + setInputLocked(true) + setProficientInput('') + setProficientHasError(false) + setProgress((prev) => ({ + ...prev, + completedIds: prev.completedIds.includes(currentItem.id) + ? prev.completedIds + : [...prev.completedIds, currentItem.id], + currentInput: [], + })) + announceToScreenReader('回答正确') + + window.setTimeout(() => { + const isLastQuestion = progress.currentIndex >= progress.order.length - 1 + if (isLastQuestion) { + setInputLocked(false) + setCorrectFeedback(false) + } else { + moveToNextQuestion() + } + }, NEXT_QUESTION_DELAY) + }, + [currentItem, inputLocked, isFinished, moveToNextQuestion, progress.currentIndex, progress.order.length] + ) + const handleCompositionEnd = useCallback( - (index: number, value: string) => { + (indexOrValue: number | string, maybeValue?: string) => { isComposingRef.current = false setIsComposing(false) + + if (practiceMode === 'proficient') { + const nextValue = + typeof indexOrValue === 'string' ? indexOrValue : maybeValue ?? '' + setProficientInput(nextValue) + setProficientHasError(false) + return + } + + const index = indexOrValue as number + const value = maybeValue ?? '' requestAnimationFrame(() => { const newInput = [...latestInputRef.current] const chars = value.split('') @@ -288,7 +353,7 @@ export default function ProcessPurposePracticePage() { handleInputChange(newInput) }) }, - [handleInputChange] + [handleInputChange, practiceMode] ) const handlePaste = useCallback( @@ -308,6 +373,31 @@ export default function ProcessPurposePracticePage() { [currentItem, handleInputChange, inputLocked] ) + useEffect(() => { + if (practiceMode !== 'proficient' || isComposing || inputLocked || isFinished) return + if (!normalizeAnswer(proficientInput)) return + + const timer = window.setTimeout(() => { + submitProficientAnswer(proficientInput) + }, 800) + + return () => window.clearTimeout(timer) + }, [ + isComposing, + inputLocked, + isFinished, + practiceMode, + proficientInput, + submitProficientAnswer, + ]) + + useEffect(() => { + setProficientInput('') + setProficientHasError(false) + setShowAnswer(false) + setInputLocked(false) + }, [practiceMode]) + const handleRestart = useCallback(() => { const fresh = createFreshProgress() localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)) @@ -315,6 +405,8 @@ export default function ProcessPurposePracticePage() { setShowAnswer(false) setCorrectFeedback(false) setInputLocked(false) + setProficientInput('') + setProficientHasError(false) focusFirstEmptyInput() }, [focusFirstEmptyInput]) @@ -396,17 +488,32 @@ export default function ProcessPurposePracticePage() {
- + {practiceMode === 'proficient' ? ( + { + setProficientInput(value) + setProficientHasError(false) + }} + onCompositionStart={() => handleCompositionStart()} + onCompositionEnd={(value) => handleCompositionEnd(value)} + /> + ) : ( + handleCompositionEnd(index, value)} + onPaste={handlePaste} + /> + )}