feat(整合): 新增十二项原则页面,支持查看表格与打字填空练习
This commit is contained in:
@@ -10,6 +10,7 @@ import { ArtifactDetailPage } from './pages/ArtifactDetailPage'
|
|||||||
import { ToolDetailPage } from './pages/ToolDetailPage'
|
import { ToolDetailPage } from './pages/ToolDetailPage'
|
||||||
import { SettingsPage } from './pages/SettingsPage'
|
import { SettingsPage } from './pages/SettingsPage'
|
||||||
import ProcessPracticePage from './pages/ProcessPracticePage'
|
import ProcessPracticePage from './pages/ProcessPracticePage'
|
||||||
|
import PrinciplesPage from './pages/PrinciplesPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +25,7 @@ function App() {
|
|||||||
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
|
<Route path="/process-matrix" element={<ProcessMatrixPage />} />
|
||||||
<Route path="/process-graph" element={<ProcessGraphPage />} />
|
<Route path="/process-graph" element={<ProcessGraphPage />} />
|
||||||
<Route path="/process-practice" element={<ProcessPracticePage />} />
|
<Route path="/process-practice" element={<ProcessPracticePage />} />
|
||||||
|
<Route path="/principles" element={<PrinciplesPage />} />
|
||||||
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
|
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
|
||||||
<Route path="/tool/:id" element={<ToolDetailPage />} />
|
<Route path="/tool/:id" element={<ToolDetailPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
|
BookMarked,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: '首页', icon: Home },
|
{ path: '/', label: '首页', icon: Home },
|
||||||
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
|
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
|
||||||
{ path: '/process-practice', label: '过程背诵练习', icon: GraduationCap },
|
{ path: '/process-practice', label: '过程背诵练习', icon: GraduationCap },
|
||||||
|
{ path: '/principles', label: '十二项原则', icon: BookMarked },
|
||||||
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
|
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
|
||||||
{ path: '/process-groups', label: '过程组', icon: Layers },
|
{ path: '/process-groups', label: '过程组', icon: Layers },
|
||||||
{ path: '/process-graph', label: '过程关系图', icon: Share2 },
|
{ path: '/process-graph', label: '过程关系图', icon: Share2 },
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
{
|
{
|
||||||
"changelogEntries": [
|
"changelogEntries": [
|
||||||
|
{
|
||||||
|
"id": "2026-03-18-principles-page",
|
||||||
|
"date": "2026-03-18",
|
||||||
|
"type": "feat",
|
||||||
|
"title": "新增十二项原则页面,支持查看表格与打字填空练习",
|
||||||
|
"scope": "整合"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "2026-03-09-practice-celebration",
|
"id": "2026-03-09-practice-celebration",
|
||||||
"date": "2026-03-09",
|
"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