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:
@@ -9,6 +9,7 @@ import { ProcessGraphPage } from './pages/ProcessGraphPage'
|
|||||||
import { ArtifactDetailPage } from './pages/ArtifactDetailPage'
|
import { ArtifactDetailPage } from './pages/ArtifactDetailPage'
|
||||||
import { ToolDetailPage } from './pages/ToolDetailPage'
|
import { ToolDetailPage } from './pages/ToolDetailPage'
|
||||||
import { SettingsPage } from './pages/SettingsPage'
|
import { SettingsPage } from './pages/SettingsPage'
|
||||||
|
import ProcessPracticePage from './pages/ProcessPracticePage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +23,7 @@ function App() {
|
|||||||
<Route path="/process/:id" element={<ProcessDetailPage />} />
|
<Route path="/process/:id" element={<ProcessDetailPage />} />
|
||||||
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
|
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
|
||||||
<Route path="/process-graph" element={<ProcessGraphPage />} />
|
<Route path="/process-graph" element={<ProcessGraphPage />} />
|
||||||
|
<Route path="/process-practice" element={<ProcessPracticePage />} />
|
||||||
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
|
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
|
||||||
<Route path="/tool/:id" element={<ToolDetailPage />} />
|
<Route path="/tool/:id" element={<ToolDetailPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
GraduationCap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: '首页', icon: Home },
|
{ path: '/', label: '首页', icon: Home },
|
||||||
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
|
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
|
||||||
|
{ path: '/process-practice', label: '过程背诵练习', icon: GraduationCap },
|
||||||
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
|
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
|
||||||
{ path: '/process-groups', label: '过程组', icon: Layers },
|
{ path: '/process-groups', label: '过程组', icon: Layers },
|
||||||
{ path: '/process-graph', label: '过程关系图', icon: Share2 },
|
{ path: '/process-graph', label: '过程关系图', icon: Share2 },
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
src/hooks/useLongPress.ts
Normal file
62
src/hooks/useLongPress.ts
Normal 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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
343
src/pages/ProcessPracticePage.tsx
Normal file
343
src/pages/ProcessPracticePage.tsx
Normal 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
116
src/utils/practice.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user