Files
ittoview/src/pages/ProcessPracticePage.tsx
ittoview 5d97c70e06 fix(练习): 修复输入法组合期间焦点跳转导致字母分散问题
- 组合输入期间禁止useEffect自动聚焦到下一个空输入框
- onCompositionEnd直接传入index和value,不再扫描数组查找
- 确保拼音字母留在同一输入框,确认后正确分散成中文

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 01:34:31 +00:00

473 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'
type CharStatus = 'pending' | 'correct' | 'error'
export default function ProcessPracticePage() {
// 生成格子顺序
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 isComposingRef = useRef(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[]>([])
// 初始化输入框
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])
// 恢复焦点到第一个空输入框
const restoreFocus = useCallback(() => {
setTimeout(() => {
const inputs = document.querySelectorAll('.practice-input-area input')
const firstEmptyInput = Array.from(inputs).find(
(input) => !(input as HTMLInputElement).value
) as HTMLInputElement
if (firstEmptyInput) {
firstEmptyInput.focus()
} else {
// 如果所有输入框都有值,聚焦到第一个
(inputs[0] as HTMLInputElement)?.focus()
}
}, 100)
}, [])
// 切换到指定格子
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 (isComposingRef.current) return
validateInput(newInput)
},
[validateInput]
)
// 输入法状态管理
const handleCompositionStart = useCallback((_index: number) => {
isComposingRef.current = true
setIsComposing(true)
}, [])
const handleCompositionEnd = useCallback(
(index: number, value: string) => {
isComposingRef.current = false
setIsComposing(false)
// 输入法确认后,需要将当前输入框中的所有字符分散到后续输入框
requestAnimationFrame(() => {
const currentInput = latestInputRef.current
const composedText = value
const newInput = [...currentInput]
if (composedText) {
const chars = composedText.split('')
for (
let i = 0;
i < chars.length && index + i < newInput.length;
i++
) {
newInput[index + i] = chars[i]
}
} else {
newInput[index] = ''
}
latestInputRef.current = newInput
setUserInput(newInput)
validateInput(newInput)
})
},
[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('答案已隐藏')
restoreFocus()
}
}, [showAnswerForCell, restoreFocus])
// 自动过期检查
useEffect(() => {
if (!showAnswerForCell) return
const remainingTime = showAnswerForCell.expiresAt - Date.now()
if (remainingTime <= 0) {
setShowAnswerForCell(null)
setInputLocked(false)
restoreFocus()
return
}
const timer = setTimeout(() => {
setShowAnswerForCell(null)
setInputLocked(false)
announceToScreenReader('答案已自动隐藏')
restoreFocus()
}, remainingTime)
return () => clearTimeout(timer)
}, [showAnswerForCell, restoreFocus])
// 点击格子切换(允许回顾已答对的格子)
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 flex flex-col">
{/* 顶部进度条 */}
<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 className="flex-1 overflow-y-auto pb-10">
{/* 矩阵区域 */}
<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 className="sticky bottom-0 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md border-t border-gray-200 dark:border-gray-700 z-10">
<div className="max-w-7xl mx-auto px-6">
{/* 输入区域 */}
<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>
)
}