feat(练习): 新增过程背诵练习模块

- 实现知识领域和过程的背诵练习功能
- 矩阵布局:知识领域格子横跨5列,过程按过程组分列
- 动态输入框:根据答案长度自动调整横线数量
- 实时验证:逐字符验证,错误标红,正确后自动跳转
- 辅助信息:知识领域显示裁剪因素,过程显示主要作用
- 长按显示答案:支持触摸、鼠标和键盘(空格键)
- TAB键切换:按顺序切换格子,自动跳过空单元格
- 支持输入法和批量粘贴
- 完整的无障碍支持(aria-live、tabIndex、scrollIntoView)
- 进度跟踪:顶部显示答题进度条

新增文件:
- src/utils/practice.ts - 工具函数
- src/hooks/useLongPress.ts - 长按 Hook
- src/components/practice/ - 练习组件
- src/pages/ProcessPracticePage.tsx - 练习页面

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
ittoview
2026-03-01 14:28:59 +00:00
parent dd76db193c
commit cc8dd1e751
10 changed files with 1064 additions and 0 deletions

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import {
generateCellSequence,
normalizeAnswer,
announceToScreenReader,
type CellInfo,
} from '@/utils/practice'
import { PracticeMatrix } from '@/components/practice/PracticeMatrix'
import { InputArea } from '@/components/practice/InputArea'
import { HintInfo } from '@/components/practice/HintInfo'
type CharStatus = 'pending' | 'correct' | 'error'
export default function ProcessPracticePage() {
// 生成格子顺序
const [cellSequence] = useState<CellInfo[]>(() => generateCellSequence())
// 答题状态
const [answeredCells, setAnsweredCells] = useState<Map<string, boolean>>(
new Map()
)
const [currentCellId, setCurrentCellId] = useState<string | null>(
cellSequence[0]?.id || null
)
// 输入状态
const [userInput, setUserInput] = useState<string[]>([])
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
const [isComposing, setIsComposing] = useState(false)
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(
null
)
// 长按显示答案
const [showAnswerForCell, setShowAnswerForCell] = useState<{
cellId: string
answer: string
expiresAt: number
} | null>(null)
const [inputLocked, setInputLocked] = useState(false)
// 初始化输入框
useEffect(() => {
const currentCell = cellSequence.find((c) => c.id === currentCellId)
if (currentCell) {
setUserInput(new Array(currentCell.answer.length).fill(''))
setCharStatuses(new Array(currentCell.answer.length).fill('pending'))
}
}, [currentCellId, cellSequence])
// 切换到指定格子
const switchToCell = useCallback(
(cell: CellInfo) => {
setCurrentCellId(cell.id)
setUserInput(new Array(cell.answer.length).fill(''))
setCharStatuses(new Array(cell.answer.length).fill('pending'))
setLastErrorTimestamp(null)
// 滚动到可见区域并聚焦
requestAnimationFrame(() => {
const element = document.querySelector(
`[data-cell-id="${cell.id}"]`
) as HTMLElement
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 聚焦格子,使键盘长按生效
setTimeout(() => {
element?.focus()
}, 100)
})
},
[]
)
// 移动到下一个格子
const moveToNextCell = useCallback(() => {
const currentIndex = cellSequence.findIndex((c) => c.id === currentCellId)
if (currentIndex === -1 || currentIndex === cellSequence.length - 1) return
const nextCell = cellSequence[currentIndex + 1]
switchToCell(nextCell)
}, [currentCellId, cellSequence, switchToCell])
// 移动到上一个格子
const moveToPrevCell = useCallback(() => {
const currentIndex = cellSequence.findIndex((c) => c.id === currentCellId)
if (currentIndex <= 0) return
const prevCell = cellSequence[currentIndex - 1]
switchToCell(prevCell)
}, [currentCellId, cellSequence, switchToCell])
// 输入验证
const handleInputChange = useCallback(
(newInput: string[]) => {
setUserInput(newInput)
// 等待输入法确认后再验证
if (isComposing) return
const currentCell = cellSequence.find((c) => c.id === currentCellId)
if (!currentCell || !currentCellId) return
// 使用原始答案长度渲染横线,使用标准化答案验证
const originalAnswer = currentCell.answer
const normalizedInput = normalizeAnswer(
newInput.join(''),
currentCell.type === 'knowledge-area'
)
const normalizedAnswer = currentCell.normalizedAnswer
// 逐字符验证状态(基于原始答案)
const newCharStatuses = newInput.map((char, i) => {
if (!char) return 'pending' as CharStatus
// 对比时使用标准化后的字符
const normalizedChar = normalizeAnswer(char, false)
const expectedChar = normalizedAnswer[i]
return normalizedChar === expectedChar
? ('correct' as CharStatus)
: ('error' as CharStatus)
})
setCharStatuses(newCharStatuses)
// 完整答案验证
const isComplete =
newInput.every((c) => c !== '') &&
newInput.length === originalAnswer.length
const isCorrect = isComplete && normalizedInput === normalizedAnswer
if (isCorrect) {
// 答对:标记格子,延迟跳转(给用户反馈时间)
setAnsweredCells((prev) => new Map(prev).set(currentCellId, true))
setTimeout(() => {
moveToNextCell()
}, 300)
} else if (isComplete) {
// 答错:记录错误时间戳,触发红线动画
setLastErrorTimestamp(Date.now())
}
},
[isComposing, currentCellId, cellSequence, moveToNextCell]
)
// 输入法状态管理
const handleCompositionStart = useCallback(() => {
setIsComposing(true)
}, [])
const handleCompositionEnd = useCallback(() => {
setIsComposing(false)
// 输入法确认后立即验证(使用当前完整输入)
requestAnimationFrame(() => {
handleInputChange(userInput)
})
}, [userInput, handleInputChange])
// 批量粘贴处理
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault()
const pastedText = e.clipboardData.getData('text')
const currentCell = cellSequence.find((c) => c.id === currentCellId)
if (!currentCell) return
// 创建固定长度的数组,不足部分保留为空字符串
const targetLength = currentCell.answer.length
const newInput = new Array(targetLength).fill('')
const chars = pastedText.split('')
for (let i = 0; i < Math.min(chars.length, targetLength); i++) {
newInput[i] = chars[i]
}
handleInputChange(newInput)
},
[currentCellId, cellSequence, handleInputChange]
)
// 长按显示答案
const handleLongPress = useCallback(
(cellId: string) => {
const cell = cellSequence.find((c) => c.id === cellId)
if (!cell) return
// 显示答案,并设置过期时间
setShowAnswerForCell({
cellId,
answer: cell.answer,
expiresAt: Date.now() + 3000, // 3秒后自动隐藏
})
// 暂时锁定输入区域
setInputLocked(true)
// 无障碍通告
announceToScreenReader('答案已显示')
},
[cellSequence]
)
const handleLongPressEnd = useCallback(() => {
// 只有在答案仍显示时才隐藏(避免重复调用)
if (showAnswerForCell) {
setShowAnswerForCell(null)
setInputLocked(false)
announceToScreenReader('答案已隐藏')
}
}, [showAnswerForCell])
// 自动过期检查
useEffect(() => {
if (!showAnswerForCell) return
const remainingTime = showAnswerForCell.expiresAt - Date.now()
if (remainingTime <= 0) {
setShowAnswerForCell(null)
setInputLocked(false)
return
}
const timer = setTimeout(() => {
setShowAnswerForCell(null)
setInputLocked(false)
announceToScreenReader('答案已自动隐藏')
}, remainingTime)
return () => clearTimeout(timer)
}, [showAnswerForCell])
// 点击格子切换(允许回顾已答对的格子)
const handleCellClick = useCallback(
(cellId: string) => {
const cell = cellSequence.find((c) => c.id === cellId)
if (cell) {
switchToCell(cell)
}
},
[cellSequence, switchToCell]
)
// 计算 tabIndex
const getCellTabIndex = useCallback(
(cellId: string) => {
return cellId === currentCellId ? 0 : -1
},
[currentCellId]
)
// 键盘事件监听
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab') {
e.preventDefault()
if (e.shiftKey) {
moveToPrevCell()
} else {
moveToNextCell()
}
} else if (e.key === 'Escape') {
// 清空当前输入
setUserInput(new Array(userInput.length).fill(''))
setCharStatuses(new Array(userInput.length).fill('pending'))
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [moveToNextCell, moveToPrevCell, userInput.length])
const currentCell = cellSequence.find((c) => c.id === currentCellId)
const answeredCount = answeredCells.size
const totalCount = cellSequence.length
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* 顶部进度条 */}
<div className="sticky top-0 z-20 bg-white dark:bg-gray-800 shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-3">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
</h1>
<div className="text-sm text-gray-600 dark:text-gray-400">
{answeredCount} / {totalCount}
</div>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<motion.div
className="bg-blue-500 h-2 rounded-full"
initial={{ width: 0 }}
animate={{
width: `${(answeredCount / totalCount) * 100}%`,
}}
transition={{ duration: 0.3 }}
/>
</div>
</div>
</div>
{/* 矩阵区域 */}
<div className="max-w-7xl mx-auto py-8">
<PracticeMatrix
cellSequence={cellSequence}
answeredCells={answeredCells}
currentCellId={currentCellId}
showAnswerForCell={showAnswerForCell}
onLongPress={handleLongPress}
onLongPressEnd={handleLongPressEnd}
onCellClick={handleCellClick}
getCellTabIndex={getCellTabIndex}
/>
</div>
{/* 输入区域(固定在屏幕中下部) */}
<div className="fixed bottom-32 left-0 right-0 z-10">
<InputArea
userInput={userInput}
charStatuses={charStatuses}
isComposing={isComposing}
inputLocked={inputLocked}
lastErrorTimestamp={lastErrorTimestamp}
onInputChange={handleInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handlePaste}
/>
</div>
{/* 辅助信息区域(固定在底部) */}
<div className="fixed bottom-0 left-0 right-0 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 py-4 z-10">
<HintInfo currentCell={currentCell} />
</div>
{/* 无障碍通告区域 */}
<div
id="aria-live-region"
className="sr-only"
aria-live="polite"
aria-atomic="true"
/>
</div>
)
}