diff --git a/src/App.tsx b/src/App.tsx
index c90b774..6f29766 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index ed01403..61c5b77 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -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 },
diff --git a/src/components/practice/HintInfo.tsx b/src/components/practice/HintInfo.tsx
new file mode 100644
index 0000000..6ae2e80
--- /dev/null
+++ b/src/components/practice/HintInfo.tsx
@@ -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 (
+
+
+ 敏捷裁剪因素
+
+ 暂无裁剪因素信息
+
+ )
+ }
+
+ return (
+
+
+ 敏捷裁剪因素
+
+
+ {ka.tailoringFactors.map((factor, index) => (
+
+
+ {factor.title}
+
+
+ {factor.description}
+
+
+ ))}
+
+
+ )
+ } else {
+ const process = processMap.get(currentCell.processId!)
+ const purpose = process?.purpose
+
+ return (
+
+
+ 主要作用
+
+ {purpose ? (
+
+ {purpose}
+
+ ) : (
+ 暂无主要作用说明
+ )}
+
+ )
+ }
+}
diff --git a/src/components/practice/InputArea.tsx b/src/components/practice/InputArea.tsx
new file mode 100644
index 0000000..80d3012
--- /dev/null
+++ b/src/components/practice/InputArea.tsx
@@ -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 (
+
+
+ {userInput.map((char, index) => {
+ const status = charStatuses[index] || 'pending'
+ const isError = status === 'error'
+ const isCorrect = status === 'correct'
+
+ return (
+
+ (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"
+ />
+
+ )
+ })}
+
+
+ {/* 输入锁定提示 */}
+
+ {inputLocked && (
+
+ 答案显示中,输入已锁定
+
+ )}
+
+
+ )
+}
diff --git a/src/components/practice/KnowledgeAreaCell.tsx b/src/components/practice/KnowledgeAreaCell.tsx
new file mode 100644
index 0000000..41f3e69
--- /dev/null
+++ b/src/components/practice/KnowledgeAreaCell.tsx
@@ -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 (
+ 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 && (
+
+
+ {ka.order}
+
+
+ {cellInfo.answer}
+
+
+ )}
+
+ {/* 长按显示答案 */}
+ {showAnswer && (
+
+ {showAnswer}
+
+ )}
+
+ )
+}
diff --git a/src/components/practice/PracticeMatrix.tsx b/src/components/practice/PracticeMatrix.tsx
new file mode 100644
index 0000000..b139278
--- /dev/null
+++ b/src/components/practice/PracticeMatrix.tsx
@@ -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
+ 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 (
+
+ {/* 表头 */}
+
+ {sortedPGs.map((pg) => (
+
+ {pg.name}
+
+ ))}
+
+
+ {/* 矩阵内容 */}
+ {sortedKAs.map((ka) => {
+ const kaCellId = `ka-${ka.id}`
+ const kaCellInfo = cellSequence.find((c) => c.id === kaCellId)
+
+ return (
+
+ {/* 知识领域格子(横跨5列) */}
+
+
+
+
+ {/* 过程格子(5个过程组列) */}
+ {sortedPGs.map((pg) => {
+ const processes = getProcessesByKaAndPg(ka.id, pg.id)
+
+ if (processes.length === 0) {
+ // 空单元格:置灰,不可聚焦
+ return (
+
+ )
+ }
+
+ return (
+
+ {processes.map((p) => {
+ const processCellId = `process-${p.id}`
+
+ return (
+
+ )
+ })}
+
+ )
+ })}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/practice/ProcessCell.tsx b/src/components/practice/ProcessCell.tsx
new file mode 100644
index 0000000..d1141e0
--- /dev/null
+++ b/src/components/practice/ProcessCell.tsx
@@ -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 (
+ 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 && (
+
+
+ {process.code}
+
+
+ {process.name}
+
+
+ )}
+
+ {/* 长按显示答案 */}
+ {showAnswer && (
+
+
+ {showAnswer}
+
+
+ )}
+
+ )
+}
diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts
new file mode 100644
index 0000000..69ad069
--- /dev/null
+++ b/src/hooks/useLongPress.ts
@@ -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()
+ 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()
+ }
+ },
+ }
+}
diff --git a/src/pages/ProcessPracticePage.tsx b/src/pages/ProcessPracticePage.tsx
new file mode 100644
index 0000000..39ca659
--- /dev/null
+++ b/src/pages/ProcessPracticePage.tsx
@@ -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(() => generateCellSequence())
+
+ // 答题状态
+ const [answeredCells, setAnsweredCells] = useState