feat(整合): 新增十二项原则页面,支持查看表格与打字填空练习

This commit is contained in:
ittoview
2026-03-18 15:52:39 +00:00
parent a0c38fe9d4
commit 2dbc2a5e0a
5 changed files with 787 additions and 0 deletions

View File

@@ -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() {
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
<Route path="/process-graph" element={<ProcessGraphPage />} />
<Route path="/process-practice" element={<ProcessPracticePage />} />
<Route path="/principles" element={<PrinciplesPage />} />
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
<Route path="/tool/:id" element={<ToolDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />

View File

@@ -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 },

View File

@@ -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",

145
src/data/principles.ts Normal file
View File

@@ -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<string, Principle>(
principles.map((p) => [p.id, p])
)
/** 从当前原则 id 出发,找下一个未答对的原则(环形搜索) */
export function getNextUnanswered(
currentId: string,
answered: Map<string, boolean>
): 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
}

View File

@@ -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<Map<string, boolean>>(
() => new Map()
)
const [currentPrincipleId, setCurrentPrincipleId] = useState<string | null>(
() => principles[0]?.id ?? null
)
// 输入状态
const [userInput, setUserInput] = useState<string[]>([])
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
const [isComposing, setIsComposing] = useState(false)
const isComposingRef = useRef(false)
const latestInputRef = useRef<string[]>([])
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(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<number | null>(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<string, boolean>(data.answeredCells || []),
currentPrincipleId: data.currentPrincipleId || principles[0]?.id || null,
}
}
} catch (e) {
console.error('加载原则练习进度失败:', e)
}
return {
answeredCells: new Map<string, boolean>(),
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 (
<div className="min-h-screen bg-slate-50 px-4 py-8 dark:bg-slate-900">
<div className="mx-auto max-w-7xl space-y-6">
{/* 标题栏 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-blue-600 dark:text-blue-400">
PMBOK
</p>
<h1 className="mt-1 text-2xl font-bold text-slate-900 dark:text-white">
</h1>
</div>
<div className="flex items-center gap-3">
{isPracticeMode && (
<button
type="button"
onClick={resetPractice}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition-colors hover:border-red-300 hover:text-red-600 dark:border-slate-600 dark:text-slate-300 dark:hover:text-red-400"
>
</button>
)}
<div className="flex rounded-xl bg-slate-100 p-1 dark:bg-slate-800">
<button
type="button"
onClick={() => setIsPracticeMode(false)}
className={clsx(
'rounded-lg px-4 py-1.5 text-sm font-medium transition-colors',
!isPracticeMode
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
)}
>
</button>
<button
type="button"
onClick={() => setIsPracticeMode(true)}
className={clsx(
'rounded-lg px-4 py-1.5 text-sm font-medium transition-colors',
isPracticeMode
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
)}
>
</button>
</div>
</div>
</div>
{/* 进度条 */}
{isPracticeMode && (
<div className="flex items-center gap-3">
<div className="h-2 flex-1 rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-2 rounded-full bg-blue-500 transition-all duration-300"
style={{ width: `${(answeredCount / principles.length) * 100}%` }}
/>
</div>
<span className="whitespace-nowrap text-sm text-slate-500 dark:text-slate-400">
{answeredCount} / {principles.length}
</span>
</div>
)}
{/* 原则表格 */}
<div className="overflow-x-auto rounded-2xl shadow-sm ring-1 ring-slate-200 dark:ring-slate-700">
<table className="min-w-[840px] w-full border-collapse">
<thead>
<tr>
{principleGroups.map((group) => (
<th
key={group.id}
className="bg-blue-700 px-4 py-3 text-center text-base font-bold text-white dark:bg-blue-800"
>
{group.label}
</th>
))}
</tr>
</thead>
<tbody>
{[0, 1, 2, 3].map((rowIdx) => (
<tr key={rowIdx} className="divide-x divide-slate-100 dark:divide-slate-700">
{principleGroups.map((group) => {
const principle = group.items[rowIdx]
if (!principle) {
return (
<td
key={`${group.id}-empty`}
className="bg-white dark:bg-slate-800"
/>
)
}
const isAnswered = !!answeredCells.get(principle.id)
const isCurrent =
isPracticeMode && principle.id === currentPrincipleId
const isShowingAnswer =
showAnswerForCell?.principleId === principle.id
return (
<td key={principle.id} className="p-0 align-top">
<div className="flex min-h-[80px]">
{/* 原则名称列 */}
{isPracticeMode ? (
<button
type="button"
data-principle-id={principle.id}
onClick={() => switchToPrinciple(principle)}
onPointerDown={() => handlePointerDown(principle.id)}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onPointerCancel={handlePointerUp}
tabIndex={isCurrent ? 0 : -1}
aria-current={isCurrent ? 'step' : undefined}
className={clsx(
'relative flex w-36 shrink-0 items-center justify-center border-r px-2 py-3 text-center text-sm font-semibold transition-all focus:outline-none',
'border-slate-100 dark:border-slate-700',
isAnswered &&
'bg-green-600 text-white',
!isAnswered &&
!isCurrent &&
'bg-blue-700 text-blue-300 opacity-40 dark:bg-blue-800',
isCurrent &&
!isAnswered &&
'bg-amber-400 text-amber-900 ring-2 ring-inset ring-amber-500',
isCurrent &&
isAnswered &&
'bg-green-600 text-white ring-2 ring-inset ring-amber-500'
)}
>
<span className="leading-snug">
{isAnswered
? principle.name
: isCurrent
? '作答中…'
: '  '}
</span>
{isShowingAnswer && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/80 px-2 text-sm font-bold text-white">
{showAnswerForCell.answer}
</div>
)}
</button>
) : (
<div className="flex w-36 shrink-0 items-center justify-center bg-blue-700 px-2 py-3 text-center text-sm font-bold leading-snug text-white dark:bg-blue-800">
{principle.name}
</div>
)}
{/* 描述列 */}
<div className="flex flex-1 items-center bg-white px-4 py-3 dark:bg-slate-800">
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-200">
{principle.description}
</p>
</div>
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
{/* 练习输入区 */}
{isPracticeMode && currentPrinciple && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<div className="mb-5 border-b border-slate-100 pb-5 dark:border-slate-700">
<p className="text-xs font-semibold uppercase tracking-widest text-blue-600 dark:text-blue-400">
· {currentPrinciple.categoryId === 'people' ? '人' : currentPrinciple.categoryId === 'environment' ? '环境' : '事'}
</p>
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-white">
{currentPrinciple.description}
</p>
</div>
<div className="flex justify-center">
<InputArea
userInput={userInput}
charStatuses={charStatuses}
isComposing={isComposing}
inputLocked={inputLocked}
lastErrorTimestamp={lastErrorTimestamp}
onInputChange={handleInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handlePaste}
/>
</div>
<p className="mt-5 text-center text-xs text-slate-400 dark:text-slate-500">
/ Ctrl+H · Tab / Shift+Tab · Esc
</p>
</div>
)}
</div>
{/* 无障碍播报区 */}
<div
id="aria-live-region"
className="sr-only"
aria-live="polite"
aria-atomic="true"
/>
{showCelebration && (
<CelebrationAnimation onComplete={handleCelebrationComplete} />
)}
</div>
)
}