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

@@ -9,6 +9,7 @@ import { ProcessGraphPage } from './pages/ProcessGraphPage'
import { ArtifactDetailPage } from './pages/ArtifactDetailPage'
import { ToolDetailPage } from './pages/ToolDetailPage'
import { SettingsPage } from './pages/SettingsPage'
import ProcessPracticePage from './pages/ProcessPracticePage'
function App() {
return (
@@ -22,6 +23,7 @@ function App() {
<Route path="/process/:id" element={<ProcessDetailPage />} />
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
<Route path="/process-graph" element={<ProcessGraphPage />} />
<Route path="/process-practice" element={<ProcessPracticePage />} />
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
<Route path="/tool/:id" element={<ToolDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />

View File

@@ -10,11 +10,13 @@ import {
Settings,
ChevronLeft,
ChevronRight,
GraduationCap,
} from 'lucide-react'
const navItems = [
{ path: '/', label: '首页', icon: Home },
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
{ path: '/process-practice', label: '过程背诵练习', icon: GraduationCap },
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
{ path: '/process-groups', label: '过程组', icon: Layers },
{ path: '/process-graph', label: '过程关系图', icon: Share2 },

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

62
src/hooks/useLongPress.ts Normal file
View File

@@ -0,0 +1,62 @@
import { useCallback, useRef } from 'react'
interface UseLongPressOptions {
onLongPress: (cellId: string) => void
onLongPressEnd: () => void
delay?: number
}
/**
* 自定义长按 Hook
* 支持触摸、鼠标和键盘(空格键)
*/
export function useLongPress(
cellId: string,
{ onLongPress, onLongPressEnd, delay = 600 }: UseLongPressOptions
) {
const timerRef = useRef<number>()
const isLongPressRef = useRef(false)
const start = useCallback(() => {
isLongPressRef.current = false
timerRef.current = window.setTimeout(() => {
isLongPressRef.current = true
onLongPress(cellId)
}, delay)
}, [cellId, onLongPress, delay])
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
// 只有在长按成功触发后才调用 onLongPressEnd
if (isLongPressRef.current) {
onLongPressEnd()
}
isLongPressRef.current = false
}, [onLongPressEnd])
return {
onPointerDown: start,
onPointerUp: cancel,
onPointerLeave: cancel,
onPointerCancel: cancel,
onContextMenu: (e: React.MouseEvent) => {
e.preventDefault()
cancel()
},
// 键盘支持(空格键长按)
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === ' ' && !e.repeat) {
e.preventDefault()
start()
}
},
onKeyUp: (e: React.KeyboardEvent) => {
if (e.key === ' ') {
e.preventDefault()
cancel()
}
},
}
}

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } 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())
// 答题状态
const [answeredCells, setAnsweredCells] = useState<Map<string, boolean>>(
new Map()
)
const [currentCellId, setCurrentCellId] = useState<string | null>(
cellSequence[0]?.id || null
)
// 输入状态
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)
// 初始化输入框
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])
// 切换到指定格子
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' })
// 聚焦格子,使键盘长按生效
setTimeout(() => {
element?.focus()
}, 100)
})
},
[]
)
// 移动到下一个格子
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 handleInputChange = useCallback(
(newInput: string[]) => {
setUserInput(newInput)
// 等待输入法确认后再验证
if (isComposing) return
const currentCell = cellSequence.find((c) => c.id === currentCellId)
if (!currentCell || !currentCellId) return
// 使用原始答案长度渲染横线,使用标准化答案验证
const originalAnswer = currentCell.answer
const normalizedInput = normalizeAnswer(
newInput.join(''),
currentCell.type === 'knowledge-area'
)
const normalizedAnswer = currentCell.normalizedAnswer
// 逐字符验证状态(基于原始答案)
const newCharStatuses = newInput.map((char, i) => {
if (!char) return 'pending' as CharStatus
// 对比时使用标准化后的字符
const normalizedChar = normalizeAnswer(char, false)
const expectedChar = normalizedAnswer[i]
return normalizedChar === expectedChar
? ('correct' as CharStatus)
: ('error' as CharStatus)
})
setCharStatuses(newCharStatuses)
// 完整答案验证
const isComplete =
newInput.every((c) => c !== '') &&
newInput.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())
}
},
[isComposing, currentCellId, cellSequence, moveToNextCell]
)
// 输入法状态管理
const handleCompositionStart = useCallback(() => {
setIsComposing(true)
}, [])
const handleCompositionEnd = useCallback(() => {
setIsComposing(false)
// 输入法确认后立即验证(使用当前完整输入)
requestAnimationFrame(() => {
handleInputChange(userInput)
})
}, [userInput, handleInputChange])
// 批量粘贴处理
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('答案已隐藏')
}
}, [showAnswerForCell])
// 自动过期检查
useEffect(() => {
if (!showAnswerForCell) return
const remainingTime = showAnswerForCell.expiresAt - Date.now()
if (remainingTime <= 0) {
setShowAnswerForCell(null)
setInputLocked(false)
return
}
const timer = setTimeout(() => {
setShowAnswerForCell(null)
setInputLocked(false)
announceToScreenReader('答案已自动隐藏')
}, 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
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="text-sm text-gray-600 dark:text-gray-400">
{answeredCount} / {totalCount}
</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="max-w-7xl mx-auto py-8">
<PracticeMatrix
cellSequence={cellSequence}
answeredCells={answeredCells}
currentCellId={currentCellId}
showAnswerForCell={showAnswerForCell}
onLongPress={handleLongPress}
onLongPressEnd={handleLongPressEnd}
onCellClick={handleCellClick}
getCellTabIndex={getCellTabIndex}
/>
</div>
{/* 输入区域(固定在屏幕中下部) */}
<div className="fixed bottom-32 left-0 right-0 z-10">
<InputArea
userInput={userInput}
charStatuses={charStatuses}
isComposing={isComposing}
inputLocked={inputLocked}
lastErrorTimestamp={lastErrorTimestamp}
onInputChange={handleInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handlePaste}
/>
</div>
{/* 辅助信息区域(固定在底部) */}
<div className="fixed bottom-0 left-0 right-0 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 py-4 z-10">
<HintInfo currentCell={currentCell} />
</div>
{/* 无障碍通告区域 */}
<div
id="aria-live-region"
className="sr-only"
aria-live="polite"
aria-atomic="true"
/>
</div>
)
}

116
src/utils/practice.ts Normal file
View File

@@ -0,0 +1,116 @@
import {
knowledgeAreas,
processGroups,
processes,
} from '@/data'
import type { Process } from '@/types/itto'
export interface CellInfo {
id: string // 格子唯一标识
type: 'knowledge-area' | 'process'
knowledgeAreaId: string
processGroupId?: string // 知识领域格子无此字段
processId?: string // 过程格子才有
answer: string // 正确答案(原始)
normalizedAnswer: string // 标准化答案(用于比对)
order: number // 全局顺序
}
/**
* 答案标准化函数
* @param str 原始字符串
* @param isKnowledgeArea 是否为知识领域(只对知识领域去除"项目"前缀)
*/
export function normalizeAnswer(
str: string,
isKnowledgeArea: boolean = false
): string {
let normalized = str
.replace(/\s+/g, '') // 去除空格
.toLowerCase() // 转小写(如有英文)
.replace(/[,。、;:""''()【】]/g, '') // 去除中文标点
// 只对知识领域去除"项目"前缀
if (isKnowledgeArea) {
normalized = normalized.replace(/^项目/, '')
}
return normalized
}
/**
* 生成格子顺序列表
* 顺序KA01 → P1.1 → P1.2 → ... → P1.7 → KA02 → P2.1 → ...
*/
export function generateCellSequence(): CellInfo[] {
const sequence: CellInfo[] = []
let order = 0
// 确保数据源按 order 字段排序
const sortedKAs = [...knowledgeAreas].sort((a, b) => a.order - b.order)
const sortedPGs = [...processGroups].sort((a, b) => a.order - b.order)
sortedKAs.forEach((ka) => {
// 1. 添加知识领域格子
sequence.push({
id: `ka-${ka.id}`,
type: 'knowledge-area',
knowledgeAreaId: ka.id,
answer: ka.name.replace('项目', ''), // "项目整合管理" -> "整合管理"
normalizedAnswer: normalizeAnswer(ka.name, true), // 知识领域去除"项目"
order: order++,
})
// 2. 添加该知识领域下的所有过程格子(按过程组顺序)
sortedPGs.forEach((pg) => {
const kaProcesses = processes
.filter((p) => p.knowledgeAreaId === ka.id && p.processGroupId === pg.id)
.sort((a, b) => a.order - b.order)
kaProcesses.forEach((p) => {
sequence.push({
id: `process-${p.id}`,
type: 'process',
knowledgeAreaId: ka.id,
processGroupId: pg.id,
processId: p.id,
answer: p.name,
normalizedAnswer: normalizeAnswer(p.name, false), // 过程不去除"项目"
order: order++,
})
})
})
})
return sequence
}
/**
* 获取指定知识领域和过程组下的所有过程
*/
export function getProcessesByKaAndPg(
knowledgeAreaId: string,
processGroupId: string
): Process[] {
return processes
.filter(
(p) =>
p.knowledgeAreaId === knowledgeAreaId &&
p.processGroupId === processGroupId
)
.sort((a, b) => a.order - b.order)
}
/**
* 无障碍通告函数
*/
export function announceToScreenReader(message: string): void {
const liveRegion = document.getElementById('aria-live-region')
if (liveRegion) {
liveRegion.textContent = message
// 清空,以便下次通告
setTimeout(() => {
liveRegion.textContent = ''
}, 1000)
}
}