From 2dbc2a5e0a0acf161c75d7bbef361b8190714ab5 Mon Sep 17 00:00:00 2001 From: ittoview Date: Wed, 18 Mar 2026 15:52:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E6=95=B4=E5=90=88):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8D=81=E4=BA=8C=E9=A1=B9=E5=8E=9F=E5=88=99=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9F=A5=E7=9C=8B=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E4=B8=8E=E6=89=93=E5=AD=97=E5=A1=AB=E7=A9=BA=E7=BB=83=E4=B9=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 + src/components/layout/Sidebar.tsx | 2 + src/data/changelog.json | 7 + src/data/principles.ts | 145 +++++++ src/pages/PrinciplesPage.tsx | 631 ++++++++++++++++++++++++++++++ 5 files changed, 787 insertions(+) create mode 100644 src/data/principles.ts create mode 100644 src/pages/PrinciplesPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 6f29766..dcc2627 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 PrinciplesPage from './pages/PrinciplesPage' function App() { return ( @@ -24,6 +25,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 61c5b77..291c4a3 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -11,12 +11,14 @@ import { ChevronLeft, ChevronRight, GraduationCap, + BookMarked, } from 'lucide-react' const navItems = [ { path: '/', label: '首页', icon: Home }, { path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid }, { path: '/process-practice', label: '过程背诵练习', icon: GraduationCap }, + { path: '/principles', label: '十二项原则', icon: BookMarked }, { path: '/knowledge-areas', label: '知识领域', icon: BookOpen }, { path: '/process-groups', label: '过程组', icon: Layers }, { path: '/process-graph', label: '过程关系图', icon: Share2 }, diff --git a/src/data/changelog.json b/src/data/changelog.json index 56c48c5..4b44fe0 100644 --- a/src/data/changelog.json +++ b/src/data/changelog.json @@ -1,5 +1,12 @@ { "changelogEntries": [ + { + "id": "2026-03-18-principles-page", + "date": "2026-03-18", + "type": "feat", + "title": "新增十二项原则页面,支持查看表格与打字填空练习", + "scope": "整合" + }, { "id": "2026-03-09-practice-celebration", "date": "2026-03-09", diff --git a/src/data/principles.ts b/src/data/principles.ts new file mode 100644 index 0000000..a847df1 --- /dev/null +++ b/src/data/principles.ts @@ -0,0 +1,145 @@ +export type PrincipleCategoryId = 'people' | 'environment' | 'things' + +export interface Principle { + id: string + categoryId: PrincipleCategoryId + order: number + name: string + description: string +} + +export interface PrincipleGroup { + id: PrincipleCategoryId + label: string + order: number + items: Principle[] +} + +export const principleGroups: PrincipleGroup[] = [ + { + id: 'people', + label: '人', + order: 1, + items: [ + { + id: 'people-stewardship', + categoryId: 'people', + order: 1, + name: '管家式管理', + description: '勤勉、尊重和关心他人', + }, + { + id: 'people-stakeholders', + categoryId: 'people', + order: 2, + name: '干系人', + description: '促进干系人有效参与', + }, + { + id: 'people-leadership', + categoryId: 'people', + order: 3, + name: '领导力', + description: '展现领导力行为', + }, + { + id: 'people-team', + categoryId: 'people', + order: 4, + name: '团队', + description: '营造协作的项目团队环境', + }, + ], + }, + { + id: 'environment', + label: '环境', + order: 2, + items: [ + { + id: 'env-complexity', + categoryId: 'environment', + order: 1, + name: '复杂性', + description: '驾驭复杂性', + }, + { + id: 'env-change', + categoryId: 'environment', + order: 2, + name: '变革', + description: '为实现目标而驱动变革', + }, + { + id: 'env-value', + categoryId: 'environment', + order: 3, + name: '价值', + description: '聚焦于价值', + }, + { + id: 'env-adaptability', + categoryId: 'environment', + order: 4, + name: '适应性和韧性', + description: '拥抱适应性和韧性', + }, + ], + }, + { + id: 'things', + label: '事', + order: 3, + items: [ + { + id: 'things-tailoring', + categoryId: 'things', + order: 1, + name: '裁剪', + description: '根据环境进行裁剪', + }, + { + id: 'things-risk', + categoryId: 'things', + order: 2, + name: '风险', + description: '优化风险应对', + }, + { + id: 'things-quality', + categoryId: 'things', + order: 3, + name: '质量', + description: '将质量融入到过程和成果中', + }, + { + id: 'things-system', + categoryId: 'things', + order: 4, + name: '系统交互', + description: '识别、评估和响应系统交互', + }, + ], + }, +] + +/** 所有原则的扁平数组,顺序为:人×4 → 环境×4 → 事×4 */ +export const principles: Principle[] = principleGroups.flatMap((g) => g.items) + +/** 原则 id → Principle 快速查找 */ +export const principleMap = new Map( + principles.map((p) => [p.id, p]) +) + +/** 从当前原则 id 出发,找下一个未答对的原则(环形搜索) */ +export function getNextUnanswered( + currentId: string, + answered: Map +): Principle | null { + const startIdx = principles.findIndex((p) => p.id === currentId) + for (let offset = 1; offset <= principles.length; offset++) { + const candidate = principles[(startIdx + offset) % principles.length] + if (!answered.get(candidate.id)) return candidate + } + return null +} diff --git a/src/pages/PrinciplesPage.tsx b/src/pages/PrinciplesPage.tsx new file mode 100644 index 0000000..bd4c906 --- /dev/null +++ b/src/pages/PrinciplesPage.tsx @@ -0,0 +1,631 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import clsx from 'clsx' +import { normalizeAnswer, announceToScreenReader } from '@/utils/practice' +import { InputArea } from '@/components/practice/InputArea' +import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation' +import { + principleGroups, + principles, + principleMap, + getNextUnanswered, + type Principle, +} from '@/data/principles' + +type CharStatus = 'pending' | 'correct' | 'error' + +const STORAGE_KEY = 'principles-practice-progress' + +export default function PrinciplesPage() { + const [isPracticeMode, setIsPracticeMode] = useState(false) + + // 答题进度 + const [answeredCells, setAnsweredCells] = useState>( + () => new Map() + ) + const [currentPrincipleId, setCurrentPrincipleId] = useState( + () => principles[0]?.id ?? null + ) + + // 输入状态 + const [userInput, setUserInput] = useState([]) + const [charStatuses, setCharStatuses] = useState([]) + const [isComposing, setIsComposing] = useState(false) + const isComposingRef = useRef(false) + const latestInputRef = useRef([]) + const [lastErrorTimestamp, setLastErrorTimestamp] = useState(null) + + // 显示答案 + const [showAnswerForCell, setShowAnswerForCell] = useState<{ + principleId: string + answer: string + expiresAt: number + } | null>(null) + const [inputLocked, setInputLocked] = useState(false) + + // 庆祝动画 + const [showCelebration, setShowCelebration] = useState(false) + + // 长按计时器 + const longPressTimerRef = useRef(null) + + const currentPrinciple = currentPrincipleId + ? (principleMap.get(currentPrincipleId) ?? null) + : null + + const answeredCount = principles.filter((p) => answeredCells.get(p.id)).length + + // ─── 焦点恢复 ──────────────────────────────────────────────── + const restoreFocus = useCallback(() => { + setTimeout(() => { + const inputs = document.querySelectorAll('.practice-input-area input') + const firstEmpty = Array.from(inputs).find( + (el) => !(el as HTMLInputElement).value + ) as HTMLInputElement | undefined + if (firstEmpty) { + firstEmpty.focus() + } else { + (inputs[0] as HTMLInputElement)?.focus() + } + }, 100) + }, []) + + // ─── 长按显示答案 ───────────────────────────────────────────── + const handleLongPress = useCallback((principleId: string) => { + const principle = principleMap.get(principleId) + if (!principle) return + setShowAnswerForCell({ + principleId, + answer: principle.name, + expiresAt: Date.now() + 3000, + }) + setInputLocked(true) + announceToScreenReader('答案已显示') + }, []) + + const handleLongPressEnd = useCallback(() => { + if (showAnswerForCell) { + setShowAnswerForCell(null) + setInputLocked(false) + announceToScreenReader('答案已隐藏') + restoreFocus() + } + }, [showAnswerForCell, restoreFocus]) + + // 答案自动过期 + useEffect(() => { + if (!showAnswerForCell) return + const remaining = showAnswerForCell.expiresAt - Date.now() + if (remaining <= 0) { + setShowAnswerForCell(null) + setInputLocked(false) + restoreFocus() + return + } + const timer = setTimeout(() => { + setShowAnswerForCell(null) + setInputLocked(false) + announceToScreenReader('答案已自动隐藏') + restoreFocus() + }, remaining) + return () => clearTimeout(timer) + }, [showAnswerForCell, restoreFocus]) + + // ─── 切换题目 ───────────────────────────────────────────────── + const switchToPrinciple = useCallback((principle: Principle) => { + setCurrentPrincipleId(principle.id) + setUserInput(new Array(principle.name.length).fill('')) + setCharStatuses(new Array(principle.name.length).fill('pending')) + setLastErrorTimestamp(null) + requestAnimationFrame(() => { + const el = document.querySelector( + `[data-principle-id="${principle.id}"]` + ) as HTMLElement | null + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }) + setTimeout(() => { + const firstInput = document.querySelector( + '.practice-input-area input' + ) as HTMLInputElement | null + firstInput?.focus() + }, 150) + }, []) + + const moveToNextPrinciple = useCallback(() => { + const idx = principles.findIndex((p) => p.id === currentPrincipleId) + if (idx === -1 || idx >= principles.length - 1) return + switchToPrinciple(principles[idx + 1]) + }, [currentPrincipleId, switchToPrinciple]) + + const moveToPrevPrinciple = useCallback(() => { + const idx = principles.findIndex((p) => p.id === currentPrincipleId) + if (idx <= 0) return + switchToPrinciple(principles[idx - 1]) + }, [currentPrincipleId, switchToPrinciple]) + + // ─── 进度持久化 ─────────────────────────────────────────────── + const loadProgress = useCallback(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) { + const data = JSON.parse(saved) + return { + answeredCells: new Map(data.answeredCells || []), + currentPrincipleId: data.currentPrincipleId || principles[0]?.id || null, + } + } + } catch (e) { + console.error('加载原则练习进度失败:', e) + } + return { + answeredCells: new Map(), + currentPrincipleId: principles[0]?.id ?? null, + } + }, []) + + // 进入练习模式时恢复进度 + useEffect(() => { + if (!isPracticeMode) return + const progress = loadProgress() + setAnsweredCells(progress.answeredCells) + setCurrentPrincipleId(progress.currentPrincipleId) + }, [isPracticeMode, loadProgress]) + + // 保存进度 + useEffect(() => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + answeredCells: Array.from(answeredCells.entries()), + currentPrincipleId, + }) + ) + } catch (e) { + console.error('保存原则练习进度失败:', e) + } + }, [answeredCells, currentPrincipleId]) + + // currentPrincipleId 变化时初始化输入框(安全网,兼容进度恢复场景) + useEffect(() => { + if (!isPracticeMode) return + const principle = currentPrincipleId + ? (principleMap.get(currentPrincipleId) ?? null) + : null + if (!principle) return + setUserInput(new Array(principle.name.length).fill('')) + setCharStatuses(new Array(principle.name.length).fill('pending')) + }, [currentPrincipleId, isPracticeMode]) + + // 同步输入快照 + useEffect(() => { + latestInputRef.current = userInput + }, [userInput]) + + // ─── 输入验证 ───────────────────────────────────────────────── + const validateInput = useCallback( + (input: string[]) => { + if (!currentPrinciple || !currentPrincipleId) return + + const originalAnswer = currentPrinciple.name + const normalizedInput = normalizeAnswer(input.join(''), false) + const normalizedAnswer = normalizeAnswer(originalAnswer, false) + const normalizeChar = (char: string) => normalizeAnswer(char, false) || char + + const newStatuses = input.map((char, i) => { + if (!char) return 'pending' as CharStatus + const expected = originalAnswer[i] || '' + if (!expected) return 'error' as CharStatus + return normalizeChar(char) === normalizeChar(expected) + ? ('correct' as CharStatus) + : ('error' as CharStatus) + }) + setCharStatuses(newStatuses) + + const isComplete = + input.every((c) => c !== '') && input.length === originalAnswer.length + const isCorrect = isComplete && normalizedInput === normalizedAnswer + + if (isCorrect) { + const nextAnswered = new Map(answeredCells).set(currentPrincipleId, true) + setAnsweredCells(nextAnswered) + + const allDone = principles.every((p) => nextAnswered.get(p.id)) + if (allDone) { + setTimeout(() => setShowCelebration(true), 300) + } else { + const next = getNextUnanswered(currentPrincipleId, nextAnswered) + if (next) { + setTimeout(() => switchToPrinciple(next), 300) + } + } + } else if (isComplete) { + setLastErrorTimestamp(Date.now()) + } + }, + [answeredCells, currentPrinciple, currentPrincipleId, switchToPrinciple] + ) + + // ─── 输入处理 ───────────────────────────────────────────────── + const handleInputChange = useCallback( + (newInput: string[]) => { + latestInputRef.current = newInput + setUserInput(newInput) + if (isComposingRef.current) return + validateInput(newInput) + }, + [validateInput] + ) + + const handleCompositionStart = useCallback((_index: number) => { + isComposingRef.current = true + setIsComposing(true) + }, []) + + const handleCompositionEnd = useCallback( + (index: number, value: string) => { + isComposingRef.current = false + setIsComposing(false) + requestAnimationFrame(() => { + const current = latestInputRef.current + const newInput = [...current] + if (value) { + const chars = value.split('') + for (let i = 0; i < chars.length && index + i < newInput.length; i++) { + newInput[index + i] = chars[i] + } + } else { + newInput[index] = '' + } + latestInputRef.current = newInput + setUserInput(newInput) + validateInput(newInput) + }) + }, + [validateInput] + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault() + if (!currentPrinciple) return + const pastedText = e.clipboardData.getData('text') + const targetLength = currentPrinciple.name.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) + }, + [currentPrinciple, handleInputChange] + ) + + // ─── 长按指针事件 ───────────────────────────────────────────── + const handlePointerDown = useCallback( + (principleId: string) => { + if (longPressTimerRef.current !== null) { + clearTimeout(longPressTimerRef.current) + } + longPressTimerRef.current = window.setTimeout(() => { + handleLongPress(principleId) + longPressTimerRef.current = null + }, 600) + }, + [handleLongPress] + ) + + const handlePointerUp = useCallback(() => { + if (longPressTimerRef.current !== null) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + // 长按已触发且答案正在显示时,松开指针立即隐藏(与 ProcessPracticePage 行为一致) + handleLongPressEnd() + }, [handleLongPressEnd]) + + // ─── 键盘快捷键 ─────────────────────────────────────────────── + useEffect(() => { + if (!isPracticeMode) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'h') { + e.preventDefault() + if (currentPrincipleId && !showAnswerForCell) { + handleLongPress(currentPrincipleId) + } + } else if (e.key === 'Tab') { + e.preventDefault() + if (e.shiftKey) { + moveToPrevPrinciple() + } else { + moveToNextPrinciple() + } + } else if (e.key === 'Escape' && currentPrinciple) { + setUserInput(new Array(currentPrinciple.name.length).fill('')) + setCharStatuses(new Array(currentPrinciple.name.length).fill('pending')) + setLastErrorTimestamp(null) + } + } + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Control' || e.key === 'h') { + if (showAnswerForCell) handleLongPressEnd() + } + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [ + isPracticeMode, + currentPrinciple, + currentPrincipleId, + showAnswerForCell, + handleLongPress, + handleLongPressEnd, + moveToNextPrinciple, + moveToPrevPrinciple, + ]) + + // 组件卸载时清理长按计时器 + useEffect(() => { + return () => { + if (longPressTimerRef.current !== null) { + clearTimeout(longPressTimerRef.current) + } + } + }, []) + + // ─── 重置 ───────────────────────────────────────────────────── + const resetPractice = useCallback(() => { + if (!window.confirm('确定要清除练习进度吗?')) return + setAnsweredCells(new Map()) + const first = principles[0] ?? null + setCurrentPrincipleId(first?.id ?? null) + if (first) { + setUserInput(new Array(first.name.length).fill('')) + setCharStatuses(new Array(first.name.length).fill('pending')) + } + setShowAnswerForCell(null) + setInputLocked(false) + setLastErrorTimestamp(null) + setShowCelebration(false) + localStorage.removeItem(STORAGE_KEY) + announceToScreenReader('练习进度已重置') + }, []) + + const handleCelebrationComplete = useCallback(() => { + setShowCelebration(false) + setAnsweredCells(new Map()) + const first = principles[0] ?? null + setCurrentPrincipleId(first?.id ?? null) + if (first) { + setUserInput(new Array(first.name.length).fill('')) + setCharStatuses(new Array(first.name.length).fill('pending')) + } + setShowAnswerForCell(null) + setInputLocked(false) + localStorage.removeItem(STORAGE_KEY) + }, []) + + // ─── 渲染 ───────────────────────────────────────────────────── + return ( +
+
+ + {/* 标题栏 */} +
+
+

+ PMBOK 第七版 +

+

+ 十二项原则 +

+
+ +
+ {isPracticeMode && ( + + )} +
+ + +
+
+
+ + {/* 进度条 */} + {isPracticeMode && ( +
+
+
+
+ + {answeredCount} / {principles.length} + +
+ )} + + {/* 原则表格 */} +
+ + + + {principleGroups.map((group) => ( + + ))} + + + + {[0, 1, 2, 3].map((rowIdx) => ( + + {principleGroups.map((group) => { + const principle = group.items[rowIdx] + if (!principle) { + return ( + + ) + })} + + ))} + +
+ {group.label} +
+ ) + } + + const isAnswered = !!answeredCells.get(principle.id) + const isCurrent = + isPracticeMode && principle.id === currentPrincipleId + const isShowingAnswer = + showAnswerForCell?.principleId === principle.id + + return ( + +
+ {/* 原则名称列 */} + {isPracticeMode ? ( + + ) : ( +
+ {principle.name} +
+ )} + + {/* 描述列 */} +
+

+ {principle.description} +

+
+
+
+
+ + {/* 练习输入区 */} + {isPracticeMode && currentPrinciple && ( +
+
+

+ 当前提示 · {currentPrinciple.categoryId === 'people' ? '人' : currentPrinciple.categoryId === 'environment' ? '环境' : '事'} +

+

+ {currentPrinciple.description} +

+
+ +
+ +
+ +

+ 长按单元格 / Ctrl+H 查看答案 · Tab / Shift+Tab 切换题目 · Esc 清空输入 +

+
+ )} +
+ + {/* 无障碍播报区 */} +
+ + {showCelebration && ( + + )} +
+ ) +}