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 ( + + ) + })} +
+ ) +} 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>( + new Map() + ) + const [currentCellId, setCurrentCellId] = useState( + cellSequence[0]?.id || null + ) + + // 输入状态 + const [userInput, setUserInput] = useState([]) + const [charStatuses, setCharStatuses] = useState([]) + const [isComposing, setIsComposing] = useState(false) + const [lastErrorTimestamp, setLastErrorTimestamp] = useState( + 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 ( +
+ {/* 顶部进度条 */} +
+
+
+

+ 过程背诵练习 +

+
+ 进度:{answeredCount} / {totalCount} +
+
+
+ +
+
+
+ + {/* 矩阵区域 */} +
+ +
+ + {/* 输入区域(固定在屏幕中下部) */} +
+ +
+ + {/* 辅助信息区域(固定在底部) */} +
+ +
+ + {/* 无障碍通告区域 */} +
+
+ ) +} diff --git a/src/utils/practice.ts b/src/utils/practice.ts new file mode 100644 index 0000000..3485370 --- /dev/null +++ b/src/utils/practice.ts @@ -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) + } +}