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>
)
}
}

View File

@@ -0,0 +1,149 @@
import { useRef, useEffect } from 'react'
import clsx from 'clsx'
import { motion, AnimatePresence } from 'framer-motion'
type CharStatus = 'pending' | 'correct' | 'error'
interface InputAreaProps {
userInput: string[]
charStatuses: CharStatus[]
isComposing: boolean
inputLocked: boolean
lastErrorTimestamp: number | null
onInputChange: (newInput: string[]) => void
onCompositionStart: () => void
onCompositionEnd: () => void
onPaste: (e: React.ClipboardEvent) => void
}
export function InputArea({
userInput,
charStatuses,
isComposing,
inputLocked,
lastErrorTimestamp,
onInputChange,
onCompositionStart,
onCompositionEnd,
onPaste,
}: InputAreaProps) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
// 自动聚焦到第一个空输入框
useEffect(() => {
const firstEmptyIndex = userInput.findIndex((char) => !char)
if (firstEmptyIndex !== -1 && inputRefs.current[firstEmptyIndex]) {
inputRefs.current[firstEmptyIndex]?.focus()
}
}, [userInput])
const handleCharInput = (index: number, value: string) => {
if (inputLocked) return
const newInput = [...userInput]
// 只取第一个字符
newInput[index] = value.slice(0, 1)
onInputChange(newInput)
// 自动跳转到下一个输入框
if (value && index < userInput.length - 1) {
inputRefs.current[index + 1]?.focus()
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (inputLocked) return
// 退格键:清空当前输入并跳转到上一个
if (e.key === 'Backspace') {
e.preventDefault()
const newInput = [...userInput]
if (newInput[index]) {
newInput[index] = ''
onInputChange(newInput)
} else if (index > 0) {
newInput[index - 1] = ''
onInputChange(newInput)
inputRefs.current[index - 1]?.focus()
}
}
// 左箭头:跳转到上一个
else if (e.key === 'ArrowLeft' && index > 0) {
e.preventDefault()
inputRefs.current[index - 1]?.focus()
}
// 右箭头:跳转到下一个
else if (e.key === 'ArrowRight' && index < userInput.length - 1) {
e.preventDefault()
inputRefs.current[index + 1]?.focus()
}
}
return (
<div className="flex flex-col items-center gap-4">
<div className="flex gap-3">
{userInput.map((char, index) => {
const status = charStatuses[index] || 'pending'
const isError = status === 'error'
const isCorrect = status === 'correct'
return (
<motion.div
key={index}
className="relative"
animate={
isError && lastErrorTimestamp
? {
x: [0, -10, 10, -10, 10, 0],
transition: { duration: 0.4 },
}
: {}
}
>
<input
ref={(el) => (inputRefs.current[index] = el)}
type="text"
value={char}
onChange={(e) => handleCharInput(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onPaste={onPaste}
disabled={inputLocked}
className={clsx(
'w-10 h-12 text-center text-2xl font-medium',
'bg-transparent border-b-2 transition-all duration-200',
'focus:outline-none',
isComposing && 'border-gray-300 dark:border-gray-600 opacity-70',
!isComposing && !char && 'border-gray-400 dark:border-gray-500',
!isComposing && isCorrect && 'border-green-500',
!isComposing && isError && 'border-red-500',
inputLocked && 'cursor-not-allowed opacity-50'
)}
maxLength={1}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</motion.div>
)
})}
</div>
{/* 输入锁定提示 */}
<AnimatePresence>
{inputLocked && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="text-sm text-gray-500 dark:text-gray-400"
>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { motion } from 'framer-motion'
import clsx from 'clsx'
import type { KnowledgeArea } from '@/types/itto'
import type { CellInfo } from '@/utils/practice'
import { useLongPress } from '@/hooks/useLongPress'
interface KnowledgeAreaCellProps {
ka: KnowledgeArea
cellInfo?: CellInfo
isAnswered: boolean
isFocused: boolean
showAnswer?: string | null
onLongPress: (cellId: string) => void
onLongPressEnd: () => void
onClick: (cellId: string) => void
tabIndex: number
}
export function KnowledgeAreaCell({
ka,
cellInfo,
isAnswered,
isFocused,
showAnswer,
onLongPress,
onLongPressEnd,
onClick,
tabIndex,
}: KnowledgeAreaCellProps) {
const cellId = `ka-${ka.id}`
const longPressHandlers = useLongPress(cellId, {
onLongPress,
onLongPressEnd,
})
return (
<motion.div
data-cell-id={cellId}
className={clsx(
'relative rounded-lg p-4 transition-all duration-200 cursor-pointer',
'border-2 focus:outline-none',
isAnswered
? 'bg-white dark:bg-gray-800 border-solid border-gray-300 dark:border-gray-600'
: 'border-dashed border-gray-300 dark:border-gray-600',
isFocused && 'ring-2 ring-blue-500 border-blue-500',
!isAnswered && 'min-h-[60px]'
)}
onClick={() => onClick(cellId)}
tabIndex={tabIndex}
role="button"
aria-label={`知识领域:${ka.name}`}
aria-describedby="hint-info"
aria-current={isFocused ? 'true' : undefined}
{...longPressHandlers}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
{isAnswered && cellInfo && (
<div className="flex items-center gap-2">
<span
className="inline-block px-2 py-1 rounded text-white font-medium text-sm"
style={{ backgroundColor: ka.color }}
>
{ka.order}
</span>
<span className="text-gray-900 dark:text-gray-100 font-medium">
{cellInfo.answer}
</span>
</div>
)}
{/* 长按显示答案 */}
{showAnswer && (
<motion.div
className="absolute inset-0 flex items-center justify-center bg-black/80 rounded-lg z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<span className="text-white text-lg font-medium">{showAnswer}</span>
</motion.div>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,131 @@
import { knowledgeAreas, processGroups } from '@/data'
import { getProcessesByKaAndPg, type CellInfo } from '@/utils/practice'
import { KnowledgeAreaCell } from './KnowledgeAreaCell'
import { ProcessCell } from './ProcessCell'
interface PracticeMatrixProps {
cellSequence: CellInfo[]
answeredCells: Map<string, boolean>
currentCellId: string | null
showAnswerForCell: { cellId: string; answer: string; expiresAt: number } | null
onLongPress: (cellId: string) => void
onLongPressEnd: () => void
onCellClick: (cellId: string) => void
getCellTabIndex: (cellId: string) => number
}
export function PracticeMatrix({
cellSequence,
answeredCells,
currentCellId,
showAnswerForCell,
onLongPress,
onLongPressEnd,
onCellClick,
getCellTabIndex,
}: PracticeMatrixProps) {
const sortedKAs = [...knowledgeAreas].sort((a, b) => a.order - b.order)
const sortedPGs = [...processGroups].sort((a, b) => a.order - b.order)
return (
<div className="practice-matrix p-4">
{/* 表头 */}
<div
className="grid gap-3 mb-4 sticky bg-gray-50 dark:bg-gray-900 py-2 z-10"
style={{
gridTemplateColumns: 'repeat(5, 1fr)',
top: '60px', // 避开顶部进度条
}}
>
{sortedPGs.map((pg) => (
<div
key={pg.id}
className="text-center font-semibold text-sm text-gray-700 dark:text-gray-300"
>
{pg.name}
</div>
))}
</div>
{/* 矩阵内容 */}
{sortedKAs.map((ka) => {
const kaCellId = `ka-${ka.id}`
const kaCellInfo = cellSequence.find((c) => c.id === kaCellId)
return (
<div
key={ka.id}
className="ka-section mb-6"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, 1fr)',
gap: '0.75rem',
}}
>
{/* 知识领域格子横跨5列 */}
<div style={{ gridColumn: '1 / -1' }}>
<KnowledgeAreaCell
ka={ka}
cellInfo={kaCellInfo}
isAnswered={answeredCells.get(kaCellId) || false}
isFocused={currentCellId === kaCellId}
showAnswer={
showAnswerForCell?.cellId === kaCellId
? showAnswerForCell.answer
: null
}
onLongPress={onLongPress}
onLongPressEnd={onLongPressEnd}
onClick={onCellClick}
tabIndex={getCellTabIndex(kaCellId)}
/>
</div>
{/* 过程格子5个过程组列 */}
{sortedPGs.map((pg) => {
const processes = getProcessesByKaAndPg(ka.id, pg.id)
if (processes.length === 0) {
// 空单元格:置灰,不可聚焦
return (
<div
key={pg.id}
className="empty-cell bg-gray-100 dark:bg-gray-800 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg"
style={{ minHeight: '80px' }}
aria-hidden="true"
/>
)
}
return (
<div key={pg.id} className="process-column flex flex-col gap-2">
{processes.map((p) => {
const processCellId = `process-${p.id}`
return (
<ProcessCell
key={p.id}
process={p}
isAnswered={answeredCells.get(processCellId) || false}
isFocused={currentCellId === processCellId}
showAnswer={
showAnswerForCell?.cellId === processCellId
? showAnswerForCell.answer
: null
}
onLongPress={onLongPress}
onLongPressEnd={onLongPressEnd}
onClick={onCellClick}
tabIndex={getCellTabIndex(processCellId)}
/>
)
})}
</div>
)
})}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { motion } from 'framer-motion'
import clsx from 'clsx'
import type { Process } from '@/types/itto'
import { useLongPress } from '@/hooks/useLongPress'
import { knowledgeAreaMap } from '@/data'
interface ProcessCellProps {
process: Process
isAnswered: boolean
isFocused: boolean
showAnswer?: string | null
onLongPress: (cellId: string) => void
onLongPressEnd: () => void
onClick: (cellId: string) => void
tabIndex: number
}
export function ProcessCell({
process,
isAnswered,
isFocused,
showAnswer,
onLongPress,
onLongPressEnd,
onClick,
tabIndex,
}: ProcessCellProps) {
const cellId = `process-${process.id}`
const ka = knowledgeAreaMap.get(process.knowledgeAreaId)
const longPressHandlers = useLongPress(cellId, {
onLongPress,
onLongPressEnd,
})
return (
<motion.div
data-cell-id={cellId}
className={clsx(
'relative rounded-lg p-3 transition-all duration-200 cursor-pointer',
'border-2 focus:outline-none',
isAnswered
? 'bg-white dark:bg-gray-800 border-solid border-gray-300 dark:border-gray-600'
: 'border-dashed border-gray-300 dark:border-gray-600',
isFocused && 'ring-2 ring-blue-500 border-blue-500',
!isAnswered && 'min-h-[60px]'
)}
onClick={() => onClick(cellId)}
tabIndex={tabIndex}
role="button"
aria-label={`过程:${process.name}`}
aria-describedby="hint-info"
aria-current={isFocused ? 'true' : undefined}
{...longPressHandlers}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.15 }}
>
{isAnswered && (
<div className="flex items-center gap-2">
<span
className="inline-block px-1.5 py-0.5 rounded text-white font-medium shrink-0"
style={{ backgroundColor: ka?.color, fontSize: '10px' }}
>
{process.code}
</span>
<span className="text-gray-900 dark:text-gray-100 text-sm">
{process.name}
</span>
</div>
)}
{/* 长按显示答案 */}
{showAnswer && (
<motion.div
className="absolute inset-0 flex items-center justify-center bg-black/80 rounded-lg z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<span className="text-white text-sm font-medium px-2 text-center">
{showAnswer}
</span>
</motion.div>
)}
</motion.div>
)
}