feat(整合): 新增十二项原则页面,支持查看表格与打字填空练习
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
145
src/data/principles.ts
Normal 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
|
||||
}
|
||||
631
src/pages/PrinciplesPage.tsx
Normal file
631
src/pages/PrinciplesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user