Files
ittoview/src/pages/ProcessPurposePracticePage.tsx
2026-05-10 13:37:39 +01:00

570 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}