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:
84
src/components/practice/HintInfo.tsx
Normal file
84
src/components/practice/HintInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
149
src/components/practice/InputArea.tsx
Normal file
149
src/components/practice/InputArea.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
src/components/practice/KnowledgeAreaCell.tsx
Normal file
87
src/components/practice/KnowledgeAreaCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
src/components/practice/PracticeMatrix.tsx
Normal file
131
src/components/practice/PracticeMatrix.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/components/practice/ProcessCell.tsx
Normal file
88
src/components/practice/ProcessCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user