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:
343
src/pages/ProcessPracticePage.tsx
Normal file
343
src/pages/ProcessPracticePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user