From 69d27521045d28dd02fef6fe61910821b936c44a Mon Sep 17 00:00:00 2001 From: ittoview Date: Sun, 10 May 2026 13:37:39 +0100 Subject: [PATCH] feat: add process purpose practice page --- src/App.tsx | 2 + src/data/process-purpose-practice.ts | 256 ++++++++++ src/pages/ProcessPurposePracticePage.tsx | 569 +++++++++++++++++++++++ 3 files changed, 827 insertions(+) create mode 100644 src/data/process-purpose-practice.ts create mode 100644 src/pages/ProcessPurposePracticePage.tsx diff --git a/src/App.tsx b/src/App.tsx index 60f31e4..9593e09 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { ArtifactDetailPage } from './pages/ArtifactDetailPage' import { ToolDetailPage } from './pages/ToolDetailPage' import { SettingsPage } from './pages/SettingsPage' import ProcessPracticePage from './pages/ProcessPracticePage' +import ProcessPurposePracticePage from './pages/ProcessPurposePracticePage' import PrinciplesPage from './pages/PrinciplesPage' import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage' import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage' @@ -30,6 +31,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/data/process-purpose-practice.ts b/src/data/process-purpose-practice.ts new file mode 100644 index 0000000..544cf09 --- /dev/null +++ b/src/data/process-purpose-practice.ts @@ -0,0 +1,256 @@ +export interface ProcessPurposePracticeItem { + id: string + name: string + purpose: string +} + +export const processPurposePracticeItems: ProcessPurposePracticeItem[] = [ + { + id: 'P1.1', + name: '制定项目章程', + purpose: + '是正式批准项目并授权项目经理使用组织资源的文件,明确项目与组织战略目标之间的直接联系,确立项目的正式地位,并展示组织对项目的承诺。', + }, + { + id: 'P1.2', + name: '制订项目管理计划', + purpose: '生成一份综合文件,用于确定所有项目工作的基础及其执行方式。', + }, + { + id: 'P1.3', + name: '指导与管理项目工作', + purpose: '对项目工作和可交付成果开展综合管理,以提高项目成功的可能性。', + }, + { + id: 'P1.4', + name: '管理项目知识', + purpose: + '利用已有的组织知识来创造或改进项目成果,并使当前项目创造的知识可用于支持组织运营和未来的项目或阶段。', + }, + { + id: 'P1.5', + name: '监控项目工作', + purpose: + '让干系人了解项目当前情况并认可处理绩效问题的行动,同时通过成本和进度预测了解项目未来状态。', + }, + { + id: 'P1.6', + name: '实施整体变更控制', + purpose: '确保对项目中已记录在案的变更做出综合评审,避免局部变更加剧整体项目风险。', + }, + { + id: 'P1.7', + name: '结束项目或阶段', + purpose: '存档项目或阶段信息,完成计划的工作,并释放组织团队资源以展开新的工作。', + }, + { + id: 'P2.1', + name: '规划范围管理', + purpose: '在整个项目期间对如何管理范围提供指南和方向。', + }, + { + id: 'P2.2', + name: '收集需求', + purpose: '为定义产品范围和项目范围奠定基础。', + }, + { + id: 'P2.3', + name: '定义范围', + purpose: '描述产品、服务或成果的边界和验收标准。', + }, + { + id: 'P2.4', + name: '创建WBS', + purpose: '为所要交付的内容提供层级化架构。', + }, + { + id: 'P2.5', + name: '确认范围', + purpose: '使验收过程具有客观性,并通过确认每个可交付成果提高最终成果获得验收的可能性。', + }, + { + id: 'P2.6', + name: '控制范围', + purpose: '在整个项目期间保持对范围基准的维护。', + }, + { + id: 'P3.1', + name: '规划进度管理', + purpose: '为如何在整个项目期间管理项目进度提供指南和方向。', + }, + { + id: 'P3.2', + name: '定义活动', + purpose: '将工作包分解为进度活动,作为对项目工作进行进度估算、规划、执行、监督和控制的基础。', + }, + { + id: 'P3.3', + name: '排列活动顺序', + purpose: '定义工作之间的逻辑顺序,以便在既定的所有项目制约因素下获得最高的效率。', + }, + { + id: 'P3.4', + name: '估算活动持续时间', + purpose: '确定完成每个活动所需花费的时间量。', + }, + { + id: 'P3.5', + name: '制订进度计划', + purpose: '为完成项目活动而制定具有计划日期的进度模型。', + }, + { + id: 'P3.6', + name: '控制进度', + purpose: '在整个项目期间保持对进度基准的维护。', + }, + { + id: 'P4.1', + name: '规划成本管理', + purpose: '在整个项目期间为如何管理项目成本提供指南和方向。', + }, + { + id: 'P4.2', + name: '估算成本', + purpose: '确定项目所需的资金。', + }, + { + id: 'P4.3', + name: '制定预算', + purpose: '确定可用于监督和控制项目绩效的成本基准。', + }, + { + id: 'P4.4', + name: '控制成本', + purpose: '在整个项目期间保持对成本基准的维护。', + }, + { + id: 'P5.1', + name: '规划质量管理', + purpose: '为在整个项目期间如何管理和核实质量提供指南和方向。', + }, + { + id: 'P5.2', + name: '管理质量', + purpose: '提高实现质量目标的可能性,识别无效过程和导致质量低劣的原因,并展示项目的总体质量状态。', + }, + { + id: 'P5.3', + name: '控制质量', + purpose: '核实可交付成果和工作已达到主要干系人的质量要求,并确定项目输出是否达到预期目的。', + }, + { + id: 'P6.1', + name: '规划资源管理', + purpose: '根据项目类型和复杂程度确定适用于项目资源的管理方法和管理程度。', + }, + { + id: 'P6.2', + name: '估算活动资源', + purpose: '确定完成各项活动所需的团队与实物资源的类型、数量和特性,为制定进度、成本和采购计划提供可靠依据。', + }, + { + id: 'P6.3', + name: '获取资源', + purpose: '确保项目团队和其他资源在适当的时间和地点可用,以顺利完成项目工作。', + }, + { + id: 'P6.4', + name: '建设团队', + purpose: '提高团队成员的工作能力,促进团队成员之间的互动,并改善团队整体氛围,以提高项目绩效。', + }, + { + id: 'P6.5', + name: '管理团队', + purpose: '跟踪团队成员工作表现,提供反馈,解决问题,并协调各种变更,以优化项目绩效。', + }, + { + id: 'P6.6', + name: '控制资源', + purpose: '确保按计划为项目分配实物资源,并根据项目需求比较实际资源使用与计划,必要时采取纠正措施。', + }, + { + id: 'P7.1', + name: '规划沟通管理', + purpose: '及时向干系人提供相关信息,引导干系人有效参与项目,并编制书面沟通计划。', + }, + { + id: 'P7.2', + name: '管理沟通', + purpose: '促成项目团队与干系人之间的有效信息流动。', + }, + { + id: 'P7.3', + name: '监督沟通', + purpose: '按沟通管理计划和干系人参与计划的要求优化信息传递流程。', + }, + { + id: 'P8.1', + name: '规划风险管理', + purpose: '确保风险管理的水平、方法和可见度与项目风险程度,以及组织和其他干系人的重要程度相匹配。', + }, + { + id: 'P8.2', + name: '识别风险', + purpose: '记录现有的单个项目风险和整体项目风险来源,并汇总信息以便项目团队恰当地应对已识别的风险。', + }, + { + id: 'P8.3', + name: '实施定性风险分析', + purpose: '重点关注高优先级的风险。', + }, + { + id: 'P8.4', + name: '实施定量风险分析', + purpose: '量化整体项目风险暴露程度以确定实现项目目标的可能性,并提供额外的定量风险信息支持应对规划。', + }, + { + id: 'P8.5', + name: '规划风险应对', + purpose: '制定应对整体项目风险和单个项目风险的适当方法,并分配资源、补充相关活动。', + }, + { + id: 'P8.6', + name: '实施风险应对', + purpose: '确保按计划执行商定的风险应对措施,管理整体项目风险暴露,最小化威胁并最大化机会。', + }, + { + id: 'P8.7', + name: '监督风险', + purpose: '保证项目决策是在整体项目风险和单个项目风险当前信息的基础上进行。', + }, + { + id: 'P9.1', + name: '规划采购管理', + purpose: '确定是否从项目外部获取货物和服务,并明确获取的时间、方式和内容。', + }, + { + id: 'P9.2', + name: '实施采购', + purpose: '选定合格卖方并签署关于货物或服务交付的法律协议。', + }, + { + id: 'P9.3', + name: '控制采购', + purpose: '确保买卖双方履行法律协议,满足项目需求。', + }, + { + id: 'P10.1', + name: '识别干系人', + purpose: '使项目团队能够建立对每个干系人或干系人群体的适度关注。', + }, + { + id: 'P10.2', + name: '规划干系人参与', + purpose: '提供与干系人进行有效互动的可行计划。', + }, + { + id: 'P10.3', + name: '管理干系人参与', + purpose: '尽可能提高干系人的支持度,并降低干系人的抵制程度。', + }, + { + id: 'P10.4', + name: '监督干系人参与', + purpose: '随着项目进展和环境变化,维持或提升干系人参与活动的效率和效果。', + }, +] diff --git a/src/pages/ProcessPurposePracticePage.tsx b/src/pages/ProcessPurposePracticePage.tsx new file mode 100644 index 0000000..54a1225 --- /dev/null +++ b/src/pages/ProcessPurposePracticePage.tsx @@ -0,0 +1,569 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import clsx from 'clsx' +import { processPurposePracticeItems } from '@/data/process-purpose-practice' +import { normalizeAnswer, announceToScreenReader } from '@/utils/practice' + +type CharStatus = 'pending' | 'correct' | 'error' + +type PracticeProgress = { + order: string[] + currentIndex: number + completedIds: string[] + currentInput: string[] +} + +const STORAGE_KEY = 'process-purpose-practice-progress' +const ANSWER_VISIBLE_DURATION = 3000 +const NEXT_QUESTION_DELAY = 650 + +function shuffleItems(items: T[]): T[] { + const result = [...items] + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[result[i], result[j]] = [result[j], result[i]] + } + return result +} + +function createFreshProgress(): PracticeProgress { + return { + order: shuffleItems(processPurposePracticeItems.map((item) => item.id)), + currentIndex: 0, + completedIds: [], + currentInput: [], + } +} + +function normalizeProcessName(value: string): string { + return normalizeAnswer(value).replace(/wbs/g, 'WBS'.toLowerCase()) +} + +function getStoredProgress(): PracticeProgress { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (!saved) return createFreshProgress() + + const parsed = JSON.parse(saved) as Partial + const validIds = new Set(processPurposePracticeItems.map((item) => item.id)) + const order = Array.isArray(parsed.order) + ? parsed.order.filter((id): id is string => validIds.has(String(id))) + : [] + const missingIds = processPurposePracticeItems + .map((item) => item.id) + .filter((id) => !order.includes(id)) + const completedIds = Array.isArray(parsed.completedIds) + ? parsed.completedIds.filter((id): id is string => validIds.has(String(id))) + : [] + + const mergedOrder = [...order, ...shuffleItems(missingIds)] + if (mergedOrder.length === 0) return createFreshProgress() + + return { + order: mergedOrder, + currentIndex: Math.min( + Math.max(Number(parsed.currentIndex) || 0, 0), + mergedOrder.length - 1 + ), + completedIds, + currentInput: Array.isArray(parsed.currentInput) + ? parsed.currentInput.map((char) => String(char || '')) + : [], + } + } catch (error) { + console.error('加载子过程主要作用练习进度失败:', error) + return createFreshProgress() + } +} + +export default function ProcessPurposePracticePage() { + const [progress, setProgress] = useState(() => + getStoredProgress() + ) + const [userInput, setUserInput] = useState([]) + const [charStatuses, setCharStatuses] = useState([]) + const [isComposing, setIsComposing] = useState(false) + const [inputLocked, setInputLocked] = useState(false) + const [lastErrorTimestamp, setLastErrorTimestamp] = useState( + null + ) + const [showAnswer, setShowAnswer] = useState(false) + const [correctFeedback, setCorrectFeedback] = useState(false) + const isComposingRef = useRef(false) + const latestInputRef = useRef([]) + + const itemMap = useMemo( + () => new Map(processPurposePracticeItems.map((item) => [item.id, item])), + [] + ) + const currentItem = itemMap.get(progress.order[progress.currentIndex]) + const totalCount = progress.order.length + const completedCount = progress.completedIds.length + const isFinished = completedCount >= totalCount + + const resetInputForItem = useCallback((answer: string, input?: string[]) => { + const nextInput = new Array(answer.length).fill('') + if (input) { + input.slice(0, answer.length).forEach((char, index) => { + nextInput[index] = char + }) + } + latestInputRef.current = nextInput + setUserInput(nextInput) + setCharStatuses(new Array(answer.length).fill('pending')) + setLastErrorTimestamp(null) + }, []) + + useEffect(() => { + if (!currentItem) return + resetInputForItem(currentItem.name, progress.currentInput) + }, [currentItem?.id]) + + useEffect(() => { + latestInputRef.current = userInput + }, [userInput]) + + useEffect(() => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + ...progress, + currentInput: userInput, + }) + ) + } catch (error) { + console.error('保存子过程主要作用练习进度失败:', error) + } + }, [progress, userInput]) + + const focusFirstEmptyInput = useCallback(() => { + setTimeout(() => { + const inputs = document.querySelectorAll('.practice-input-area input') + const firstEmptyInput = Array.from(inputs).find( + (input) => !(input as HTMLInputElement).value + ) as HTMLInputElement | undefined + ;(firstEmptyInput || (inputs[0] as HTMLInputElement | undefined))?.focus() + }, 100) + }, []) + + const moveToNextQuestion = useCallback(() => { + setShowAnswer(false) + setCorrectFeedback(false) + setInputLocked(false) + setProgress((prev) => { + const nextIndex = Math.min(prev.currentIndex + 1, prev.order.length - 1) + return { + ...prev, + currentIndex: nextIndex, + currentInput: [], + } + }) + focusFirstEmptyInput() + }, [focusFirstEmptyInput]) + + const validateInput = useCallback( + (input: string[]) => { + if (!currentItem || inputLocked || isFinished) return + + const answer = currentItem.name + const normalizeChar = (char: string) => normalizeAnswer(char) || char + const newCharStatuses = input.map((char, index) => { + if (!char) return 'pending' as CharStatus + return normalizeChar(char) === normalizeChar(answer[index] || '') + ? ('correct' as CharStatus) + : ('error' as CharStatus) + }) + setCharStatuses(newCharStatuses) + + const isComplete = input.every(Boolean) && input.length === answer.length + const isCorrect = + isComplete && normalizeProcessName(input.join('')) === normalizeProcessName(answer) + + if (isCorrect) { + setCorrectFeedback(true) + setInputLocked(true) + setProgress((prev) => ({ + ...prev, + completedIds: prev.completedIds.includes(currentItem.id) + ? prev.completedIds + : [...prev.completedIds, currentItem.id], + currentInput: input, + })) + announceToScreenReader('回答正确') + setTimeout(() => { + const isLastQuestion = progress.currentIndex >= progress.order.length - 1 + if (isLastQuestion) { + setInputLocked(false) + setCorrectFeedback(false) + } else { + moveToNextQuestion() + } + }, NEXT_QUESTION_DELAY) + } else if (isComplete) { + setLastErrorTimestamp(Date.now()) + } + }, + [currentItem, inputLocked, isFinished, moveToNextQuestion, progress.currentIndex, progress.order.length] + ) + + const handleInputChange = useCallback( + (newInput: string[]) => { + latestInputRef.current = newInput + setUserInput(newInput) + if (isComposingRef.current) return + validateInput(newInput) + }, + [validateInput] + ) + + const handleCompositionStart = useCallback(() => { + isComposingRef.current = true + setIsComposing(true) + }, []) + + const handleCompositionEnd = useCallback( + (index: number, value: string) => { + isComposingRef.current = false + setIsComposing(false) + requestAnimationFrame(() => { + const newInput = [...latestInputRef.current] + const chars = value.split('') + if (chars.length > 0) { + for (let i = 0; i < chars.length && index + i < newInput.length; i++) { + newInput[index + i] = chars[i] + } + } else { + newInput[index] = '' + } + handleInputChange(newInput) + }) + }, + [handleInputChange] + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault() + if (!currentItem || inputLocked) return + const nextInput = new Array(currentItem.name.length).fill('') + e.clipboardData + .getData('text') + .split('') + .slice(0, currentItem.name.length) + .forEach((char, index) => { + nextInput[index] = char + }) + handleInputChange(nextInput) + }, + [currentItem, handleInputChange, inputLocked] + ) + + const handleShowAnswer = useCallback(() => { + if (!currentItem) return + setShowAnswer(true) + setInputLocked(true) + announceToScreenReader('答案已显示') + + window.setTimeout(() => { + setShowAnswer(false) + setInputLocked(false) + announceToScreenReader('答案已隐藏') + focusFirstEmptyInput() + }, ANSWER_VISIBLE_DURATION) + }, [currentItem, focusFirstEmptyInput]) + + const handleRestart = useCallback(() => { + const fresh = createFreshProgress() + localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)) + setProgress(fresh) + setShowAnswer(false) + setCorrectFeedback(false) + setInputLocked(false) + focusFirstEmptyInput() + }, [focusFirstEmptyInput]) + + if (!currentItem) return null + + return ( +
+
+
+
+
+

+ 子过程主要过程专项练习 +

+

+ 根据主要作用写出对应的过程名称 +

+
+
+
+ 进度:{completedCount} / {totalCount} +
+ +
+
+
+ +
+
+
+ +
+
+
+ + 第 {Math.min(progress.currentIndex + 1, totalCount)} / {totalCount} 题 + + + 主要作用 + +
+

+ {currentItem.purpose} +

+
+ +
+ {isFinished ? ( +
+
+ 已完成本轮练习 +
+

+ 继续巩固 49 个项目管理过程。 +

+ +
+ ) : ( + <> +
+ + + + {correctFeedback && ( + + 回答正确 + + )} + +
+ +
+ + + + {showAnswer && ( + + {currentItem.name} + + )} + +
+ + )} +
+
+ +
+
+ ) +} + +interface PracticeCharInputsProps { + userInput: string[] + charStatuses: CharStatus[] + isComposing: boolean + inputLocked: boolean + lastErrorTimestamp: number | null + onInputChange: (newInput: string[]) => void + onCompositionStart: () => void + onCompositionEnd: (index: number, value: string) => void + onPaste: (e: React.ClipboardEvent) => void +} + +function PracticeCharInputs({ + userInput, + charStatuses, + isComposing, + inputLocked, + lastErrorTimestamp, + onInputChange, + onCompositionStart, + onCompositionEnd, + onPaste, +}: PracticeCharInputsProps) { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + useEffect(() => { + if (isComposing || inputLocked) return + const firstEmptyIndex = userInput.findIndex((char) => !char) + if (firstEmptyIndex !== -1) { + inputRefs.current[firstEmptyIndex]?.focus() + } + }, [userInput, isComposing, inputLocked]) + + const handleCharInput = ( + index: number, + e: React.ChangeEvent + ) => { + if (inputLocked) return + + const value = e.target.value + const nativeIsComposing = + typeof (e.nativeEvent as InputEvent).isComposing === 'boolean' + ? (e.nativeEvent as InputEvent).isComposing + : false + const composing = isComposing || nativeIsComposing + const newInput = [...userInput] + + if (composing) { + newInput[index] = value + onInputChange(newInput) + return + } + + if (value.length > 1) { + value + .split('') + .slice(0, userInput.length - index) + .forEach((char, offset) => { + newInput[index + offset] = char + }) + onInputChange(newInput) + inputRefs.current[Math.min(index + value.length, userInput.length - 1)]?.focus() + } else { + newInput[index] = value + 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)} + onKeyDown={(e) => handleKeyDown(index, e)} + onCompositionStart={onCompositionStart} + onCompositionEnd={(e) => onCompositionEnd(index, e.currentTarget.value)} + onPaste={onPaste} + disabled={inputLocked} + placeholder="_" + className={clsx( + 'h-12 w-10 border-b-2 bg-transparent text-center text-2xl font-medium transition-all duration-200', + 'text-gray-900 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500', + isComposing && 'border-gray-300 opacity-70 dark:border-gray-600', + !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' + )} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + + ) + })} +
+ ) +}