Files
ittoview/src/pages/ProcessPracticePage.tsx
ittoview 08bd8dd4dc fix(练习): 修复底部区域布局和焦点问题
- 动态计算底部固定区域高度,避免固定值导致的空白或遮挡
- 底部区域适配侧边栏宽度,不再被左侧菜单遮挡
- 答案隐藏后自动恢复输入框焦点
- 增加辅助信息显示高度(max-h-48)

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-01 16:12:50 +00:00

477 lines
15 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 { 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>
)
}