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

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