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,84 @@
import { motion } from 'framer-motion'
import type { CellInfo } from '@/utils/practice'
import { knowledgeAreaMap, processMap } from '@/data'
interface HintInfoProps {
currentCell: CellInfo | undefined
}
export function HintInfo({ currentCell }: HintInfoProps) {
if (!currentCell) return null
if (currentCell.type === 'knowledge-area') {
const ka = knowledgeAreaMap.get(currentCell.knowledgeAreaId)
if (!ka?.tailoringFactors || ka.tailoringFactors.length === 0) {
return (
<motion.div
id="hint-info"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-3xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
</h3>
<p className="text-gray-500 dark:text-gray-400"></p>
</motion.div>
)
}
return (
<motion.div
id="hint-info"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-3xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
</h3>
<div className="space-y-3">
{ka.tailoringFactors.map((factor, index) => (
<div
key={index}
className="border-l-4 border-blue-500 pl-4 py-2"
>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{factor.title}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{factor.description}
</p>
</div>
))}
</div>
</motion.div>
)
} else {
const process = processMap.get(currentCell.processId!)
const purpose = process?.purpose
return (
<motion.div
id="hint-info"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-3xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
</h3>
{purpose ? (
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
{purpose}
</p>
) : (
<p className="text-gray-500 dark:text-gray-400"></p>
)}
</motion.div>
)
}
}