- 动态计算底部固定区域高度,避免固定值导致的空白或遮挡 - 底部区域适配侧边栏宽度,不再被左侧菜单遮挡 - 答案隐藏后自动恢复输入框焦点 - 增加辅助信息显示高度(max-h-48) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } 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'
|
||
import { useAppStore } from '@/stores/useAppStore'
|
||
import { clsx } from 'clsx'
|
||
|
||
type CharStatus = 'pending' | 'correct' | 'error'
|
||
|
||
export default function ProcessPracticePage() {
|
||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||
|
||
// 生成格子顺序
|
||
const [cellSequence] = useState<CellInfo[]>(() => generateCellSequence())
|
||
|
||
// 从 localStorage 加载答题进度
|
||
const loadProgress = useCallback(() => {
|
||
try {
|
||
const saved = localStorage.getItem('practice-progress')
|
||
if (saved) {
|
||
const data = JSON.parse(saved)
|
||
return {
|
||
answeredCells: new Map<string, boolean>(data.answeredCells || []),
|
||
currentCellId: data.currentCellId || cellSequence[0]?.id || null,
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('加载进度失败:', e)
|
||
}
|
||
return {
|
||
answeredCells: new Map<string, boolean>(),
|
||
currentCellId: cellSequence[0]?.id || null,
|
||
}
|
||
}, [cellSequence])
|
||
|
||
// 答题状态
|
||
const [answeredCells, setAnsweredCells] = useState<Map<string, boolean>>(
|
||
() => loadProgress().answeredCells
|
||
)
|
||
const [currentCellId, setCurrentCellId] = useState<string | null>(
|
||
() => loadProgress().currentCellId
|
||
)
|
||
|
||
// 输入状态
|
||
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)
|
||
const latestInputRef = useRef<string[]>([])
|
||
const bottomAreaRef = useRef<HTMLDivElement>(null)
|
||
const [bottomHeight, setBottomHeight] = useState(0)
|
||
|
||
// 初始化输入框
|
||
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])
|
||
|
||
// 同步当前输入快照,供输入法确认后复用
|
||
useEffect(() => {
|
||
latestInputRef.current = userInput
|
||
}, [userInput])
|
||
|
||
// 保存答题进度到 localStorage
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(
|
||
'practice-progress',
|
||
JSON.stringify({
|
||
answeredCells: Array.from(answeredCells.entries()),
|
||
currentCellId,
|
||
})
|
||
)
|
||
} catch (e) {
|
||
console.error('保存进度失败:', e)
|
||
}
|
||
}, [answeredCells, currentCellId])
|
||
|
||
// 动态计算底部固定区域高度
|
||
useEffect(() => {
|
||
const updateBottomHeight = () => {
|
||
if (bottomAreaRef.current) {
|
||
setBottomHeight(bottomAreaRef.current.offsetHeight)
|
||
}
|
||
}
|
||
|
||
updateBottomHeight()
|
||
window.addEventListener('resize', updateBottomHeight)
|
||
return () => window.removeEventListener('resize', updateBottomHeight)
|
||
}, [userInput.length]) // 当输入框数量变化时重新计算
|
||
|
||
// 切换到指定格子
|
||
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' })
|
||
})
|
||
|
||
// 延迟聚焦到第一个输入框,确保 DOM 已更新
|
||
setTimeout(() => {
|
||
const firstInput = document.querySelector(
|
||
'.practice-input-area input'
|
||
) as HTMLInputElement
|
||
firstInput?.focus()
|
||
}, 150)
|
||
},
|
||
[]
|
||
)
|
||
|
||
// 移动到下一个格子
|
||
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 validateInput = useCallback(
|
||
(input: string[]) => {
|
||
const currentCell = cellSequence.find((c) => c.id === currentCellId)
|
||
if (!currentCell || !currentCellId) return
|
||
|
||
const originalAnswer = currentCell.answer
|
||
const normalizedInput = normalizeAnswer(
|
||
input.join(''),
|
||
currentCell.type === 'knowledge-area'
|
||
)
|
||
const normalizedAnswer = currentCell.normalizedAnswer
|
||
const normalizeChar = (char: string) =>
|
||
normalizeAnswer(char, false) || char
|
||
|
||
const newCharStatuses = input.map((char, i) => {
|
||
if (!char) return 'pending' as CharStatus
|
||
const expectedChar = originalAnswer[i] || ''
|
||
if (!expectedChar) return 'error' as CharStatus
|
||
return normalizeChar(char) === normalizeChar(expectedChar)
|
||
? ('correct' as CharStatus)
|
||
: ('error' as CharStatus)
|
||
})
|
||
setCharStatuses(newCharStatuses)
|
||
|
||
const isComplete =
|
||
input.every((c) => c !== '') && input.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())
|
||
}
|
||
},
|
||
[cellSequence, currentCellId, moveToNextCell]
|
||
)
|
||
|
||
const handleInputChange = useCallback(
|
||
(newInput: string[]) => {
|
||
latestInputRef.current = newInput
|
||
setUserInput(newInput)
|
||
|
||
if (isComposing) return
|
||
|
||
validateInput(newInput)
|
||
},
|
||
[isComposing, validateInput]
|
||
)
|
||
|
||
// 输入法状态管理
|
||
const handleCompositionStart = useCallback(() => {
|
||
setIsComposing(true)
|
||
}, [])
|
||
|
||
const handleCompositionEnd = useCallback(() => {
|
||
setIsComposing(false)
|
||
// 输入法确认后根据最新快照重新验证
|
||
requestAnimationFrame(() => {
|
||
validateInput(latestInputRef.current)
|
||
})
|
||
}, [validateInput])
|
||
|
||
// 批量粘贴处理
|
||
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('答案已隐藏')
|
||
|
||
// 恢复焦点到第一个输入框
|
||
setTimeout(() => {
|
||
const firstInput = document.querySelector(
|
||
'.practice-input-area input'
|
||
) as HTMLInputElement
|
||
firstInput?.focus()
|
||
}, 100)
|
||
}
|
||
}, [showAnswerForCell])
|
||
|
||
// 自动过期检查
|
||
useEffect(() => {
|
||
if (!showAnswerForCell) return
|
||
|
||
const remainingTime = showAnswerForCell.expiresAt - Date.now()
|
||
if (remainingTime <= 0) {
|
||
setShowAnswerForCell(null)
|
||
setInputLocked(false)
|
||
|
||
// 恢复焦点
|
||
setTimeout(() => {
|
||
const firstInput = document.querySelector(
|
||
'.practice-input-area input'
|
||
) as HTMLInputElement
|
||
firstInput?.focus()
|
||
}, 100)
|
||
return
|
||
}
|
||
|
||
const timer = setTimeout(() => {
|
||
setShowAnswerForCell(null)
|
||
setInputLocked(false)
|
||
announceToScreenReader('答案已自动隐藏')
|
||
|
||
// 恢复焦点
|
||
setTimeout(() => {
|
||
const firstInput = document.querySelector(
|
||
'.practice-input-area input'
|
||
) as HTMLInputElement
|
||
firstInput?.focus()
|
||
}, 100)
|
||
}, 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
|
||
|
||
// 清除进度
|
||
const handleClearProgress = useCallback(() => {
|
||
if (confirm('确定要清除所有答题进度吗?')) {
|
||
setAnsweredCells(new Map())
|
||
setCurrentCellId(cellSequence[0]?.id || null)
|
||
localStorage.removeItem('practice-progress')
|
||
}
|
||
}, [cellSequence])
|
||
|
||
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="flex items-center gap-3">
|
||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
进度:{answeredCount} / {totalCount}
|
||
</div>
|
||
<button
|
||
onClick={handleClearProgress}
|
||
className="text-xs px-2 py-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||
>
|
||
清除进度
|
||
</button>
|
||
</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 style={{ paddingBottom: `${bottomHeight}px` }}>
|
||
{/* 矩阵区域 */}
|
||
<div className="max-w-7xl mx-auto py-4">
|
||
<PracticeMatrix
|
||
answeredCells={answeredCells}
|
||
currentCellId={currentCellId}
|
||
showAnswerForCell={showAnswerForCell}
|
||
onLongPress={handleLongPress}
|
||
onLongPressEnd={handleLongPressEnd}
|
||
onCellClick={handleCellClick}
|
||
getCellTabIndex={getCellTabIndex}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底部固定区域 */}
|
||
<div
|
||
ref={bottomAreaRef}
|
||
className={clsx(
|
||
'fixed bottom-0 left-0 right-0 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md border-t border-gray-200 dark:border-gray-700 z-10 transition-all duration-300',
|
||
sidebarOpen ? 'lg:ml-64' : 'lg:ml-20'
|
||
)}
|
||
>
|
||
<div className="max-w-7xl mx-auto">
|
||
{/* 输入区域 */}
|
||
<div className="py-4 border-b border-gray-200/50 dark:border-gray-700/50">
|
||
<div className="flex items-center justify-center gap-3">
|
||
<InputArea
|
||
userInput={userInput}
|
||
charStatuses={charStatuses}
|
||
isComposing={isComposing}
|
||
inputLocked={inputLocked}
|
||
lastErrorTimestamp={lastErrorTimestamp}
|
||
onInputChange={handleInputChange}
|
||
onCompositionStart={handleCompositionStart}
|
||
onCompositionEnd={handleCompositionEnd}
|
||
onPaste={handlePaste}
|
||
/>
|
||
|
||
{/* 查看答案按钮 */}
|
||
<button
|
||
onClick={() => currentCellId && handleLongPress(currentCellId)}
|
||
className="p-2 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||
title="查看答案(长按格子也可以)"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 辅助信息区域 */}
|
||
<div className="py-3 px-4 max-h-48 overflow-y-auto">
|
||
<HintInfo currentCell={currentCell} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 无障碍通告区域 */}
|
||
<div
|
||
id="aria-live-region"
|
||
className="sr-only"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|