570 lines
19 KiB
TypeScript
570 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|