From c5e71812d4fda8d0d342f5a5762801d2b2583d5f Mon Sep 17 00:00:00 2001 From: ittoview Date: Wed, 13 May 2026 18:02:52 +0100 Subject: [PATCH] feat: add performance domain practice page --- src/App.tsx | 2 + src/pages/PerformanceDomainPracticePage.tsx | 547 ++++++++++++++++++++ src/pages/PerformanceDomainsPage.tsx | 23 +- 3 files changed, 569 insertions(+), 3 deletions(-) create mode 100644 src/pages/PerformanceDomainPracticePage.tsx diff --git a/src/App.tsx b/src/App.tsx index 9593e09..487f9b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import ProcessPracticePage from './pages/ProcessPracticePage' import ProcessPurposePracticePage from './pages/ProcessPurposePracticePage' import PrinciplesPage from './pages/PrinciplesPage' import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage' +import PerformanceDomainPracticePage from './pages/PerformanceDomainPracticePage' import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage' import { LearningMapsPage } from './pages/LearningMapsPage' import { ApiDocPage } from './pages/ApiDocPage' @@ -34,6 +35,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/PerformanceDomainPracticePage.tsx b/src/pages/PerformanceDomainPracticePage.tsx new file mode 100644 index 0000000..e612178 --- /dev/null +++ b/src/pages/PerformanceDomainPracticePage.tsx @@ -0,0 +1,547 @@ +import { useEffect, useMemo, useState } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { Link } from 'react-router-dom' +import clsx from 'clsx' +import { + AlertTriangle, + BarChart3, + GitBranch, + GraduationCap, + Handshake, + RefreshCw, + Rocket, + Target, + Users, + Workflow, +} from 'lucide-react' +import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation' +import { performanceDomains } from '@/data/performance-domains' + +type QuestionKind = 'expectedGoal' | 'keyPoint' +type PracticeScope = 'all' | QuestionKind + +interface PracticeQuestion { + id: string + domainId: string + domainName: string + domainDescription: string + kind: QuestionKind + text: string +} + +interface PracticeProgress { + scope: PracticeScope + order: string[] + currentIndex: number + correctCount: number + wrongCount: number +} + +const STORAGE_KEY = 'performance-domain-practice-progress' + +const scopeOptions: Array<{ value: PracticeScope; label: string }> = [ + { value: 'all', label: '全部' }, + { value: 'expectedGoal', label: '预期目标' }, + { value: 'keyPoint', label: '绩效要点' }, +] + +const kindLabelMap: Record = { + expectedGoal: '预期目标', + keyPoint: '绩效要点', +} + +const iconMap = { + PD01: Handshake, + PD02: Users, + PD03: GitBranch, + PD04: Target, + PD05: Workflow, + PD06: Rocket, + PD07: BarChart3, + PD08: AlertTriangle, +} as const + +const desktopPositions = [ + 'left-10 top-12', + 'left-1/2 top-0 -translate-x-1/2', + 'right-10 top-12', + 'right-0 top-1/2 -translate-y-[120%]', + 'right-10 bottom-12', + 'left-1/2 bottom-0 -translate-x-1/2', + 'left-10 bottom-12', + 'left-0 top-1/2 translate-y-[20%]', +] as const + +function shuffleArray(items: T[]): T[] { + const result = [...items] + for (let i = result.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)) + ;[result[i], result[j]] = [result[j], result[i]] + } + return result +} + +function buildQuestionBank(): PracticeQuestion[] { + return performanceDomains.flatMap((domain) => { + const detail = domain.detail + if (!detail) return [] + + const expectedGoalQuestions = detail.expectedGoals.map((text, index) => ({ + id: `${domain.id}-goal-${index}`, + domainId: domain.id, + domainName: domain.name, + domainDescription: domain.description, + kind: 'expectedGoal' as const, + text, + })) + + const keyPointQuestions = detail.keyPoints.map((text, index) => ({ + id: `${domain.id}-point-${index}`, + domainId: domain.id, + domainName: domain.name, + domainDescription: domain.description, + kind: 'keyPoint' as const, + text, + })) + + return [...expectedGoalQuestions, ...keyPointQuestions] + }) +} + +function createProgress( + scope: PracticeScope, + questions: PracticeQuestion[] +): PracticeProgress { + const filteredQuestions = questions.filter((question) => + scope === 'all' ? true : question.kind === scope + ) + + return { + scope, + order: shuffleArray(filteredQuestions.map((question) => question.id)), + currentIndex: 0, + correctCount: 0, + wrongCount: 0, + } +} + +function getStoredProgress(questions: PracticeQuestion[]): PracticeProgress { + const questionMap = new Map(questions.map((question) => [question.id, question])) + + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (!saved) return createProgress('all', questions) + + const parsed = JSON.parse(saved) as Partial + const scope = parsed.scope === 'expectedGoal' || parsed.scope === 'keyPoint' || parsed.scope === 'all' + ? parsed.scope + : 'all' + + const filteredIds = questions + .filter((question) => (scope === 'all' ? true : question.kind === scope)) + .map((question) => question.id) + const validIdSet = new Set(filteredIds) + const storedOrder = Array.isArray(parsed.order) + ? parsed.order.filter((id): id is string => validIdSet.has(String(id)) && questionMap.has(String(id))) + : [] + const missingIds = filteredIds.filter((id) => !storedOrder.includes(id)) + const order = [...storedOrder, ...shuffleArray(missingIds)] + + if (order.length === 0) return createProgress(scope, questions) + + return { + scope, + order, + currentIndex: Math.min(Math.max(Number(parsed.currentIndex) || 0, 0), order.length), + correctCount: Math.max(Number(parsed.correctCount) || 0, 0), + wrongCount: Math.max(Number(parsed.wrongCount) || 0, 0), + } + } catch (error) { + console.error('加载绩效域练习进度失败:', error) + return createProgress('all', questions) + } +} + +export default function PerformanceDomainPracticePage() { + const questionBank = useMemo(() => buildQuestionBank(), []) + const questionMap = useMemo( + () => new Map(questionBank.map((question) => [question.id, question])), + [questionBank] + ) + const [progress, setProgress] = useState(() => + getStoredProgress(questionBank) + ) + const [selectedDomainId, setSelectedDomainId] = useState(null) + const [showCelebration, setShowCelebration] = useState(false) + + const questions = useMemo( + () => + progress.order + .map((id) => questionMap.get(id)) + .filter((question): question is PracticeQuestion => Boolean(question)), + [progress.order, questionMap] + ) + + const currentQuestion = questions[progress.currentIndex] ?? null + const currentDomain = currentQuestion + ? performanceDomains.find((domain) => domain.id === currentQuestion.domainId) ?? null + : null + const answeredCount = Math.min(progress.currentIndex, questions.length) + const isFinished = progress.currentIndex >= questions.length + const accuracy = answeredCount > 0 + ? Math.round((progress.correctCount / answeredCount) * 100) + : 0 + const answerState = currentQuestion && selectedDomainId + ? { + isCorrect: selectedDomainId === currentQuestion.domainId, + correctDomainId: currentQuestion.domainId, + } + : null + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)) + } catch (error) { + console.error('保存绩效域练习进度失败:', error) + } + }, [progress]) + + const restartPractice = (scope = progress.scope) => { + setProgress(createProgress(scope, questionBank)) + setSelectedDomainId(null) + setShowCelebration(false) + } + + const switchScope = (scope: PracticeScope) => { + if (scope === progress.scope) return + setProgress(createProgress(scope, questionBank)) + setSelectedDomainId(null) + setShowCelebration(false) + } + + const handleSelect = (domainId: string) => { + if (!currentQuestion || answerState) return + + const isCorrect = domainId === currentQuestion.domainId + setSelectedDomainId(domainId) + setProgress((prev) => ({ + ...prev, + correctCount: prev.correctCount + (isCorrect ? 1 : 0), + wrongCount: prev.wrongCount + (isCorrect ? 0 : 1), + })) + } + + const handleNext = () => { + if (!answerState) return + + setSelectedDomainId(null) + setProgress((prev) => { + const nextIndex = prev.currentIndex + 1 + if (nextIndex >= prev.order.length) { + setShowCelebration(true) + } + return { + ...prev, + currentIndex: nextIndex, + } + }) + } + + const renderOptionButton = (domainId: string, className?: string) => { + const domain = performanceDomains.find((item) => item.id === domainId) + if (!domain) return null + + const Icon = iconMap[domain.id as keyof typeof iconMap] + const isSelected = selectedDomainId === domain.id + const isCorrect = answerState?.correctDomainId === domain.id + const isWrongSelection = Boolean(answerState) && isSelected && !answerState?.isCorrect + + return ( + handleSelect(domain.id)} + disabled={Boolean(answerState)} + className={clsx( + 'group w-full rounded-2xl border bg-white/95 px-4 py-3 text-left shadow-sm transition-all backdrop-blur-sm dark:bg-gray-800/95', + 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600', + answerState && 'cursor-default', + isSelected && !answerState && 'border-indigo-400 ring-2 ring-indigo-100 dark:ring-indigo-900/50', + isCorrect && 'border-emerald-300 bg-emerald-50 shadow-md dark:border-emerald-700 dark:bg-emerald-950/40', + isWrongSelection && 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950/40', + className + )} + > +
+
+ +
+
+
+ {domain.name} +
+
+ {domain.nameEn} +
+
+
+
+ ) + } + + return ( +
+ {showCelebration && ( + setShowCelebration(false)} /> + )} + +
+
+ +
+

八大绩效域练习

+

+ 根据预期目标和绩效要点,判断对应的绩效域。 +

+
+
+ + +
+ +
+ {scopeOptions.map((option) => { + const isActive = option.value === progress.scope + return ( + + ) + })} +
+ +
+
+
练习进度
+
+ {Math.min(answeredCount + (answerState ? 1 : 0), questions.length)} / {questions.length} +
+
+
+
当前正确率
+
{accuracy}%
+
+
+
已答对
+
{progress.correctCount}
+
+
+ + {isFinished ? ( + +
+
+ + 本轮完成 +
+

八大绩效域练习已完成

+

+ 本轮共答对 {progress.correctCount} 题,答错 {progress.wrongCount} 题。 +

+
+ +
+
+
总题数
+
{questions.length}
+
+
+
正确率
+
+ {questions.length > 0 ? Math.round((progress.correctCount / questions.length) * 100) : 0}% +
+
+
+
当前范围
+
+ {scopeOptions.find((option) => option.value === progress.scope)?.label ?? '全部'} +
+
+
+ +
+ + + 返回绩效域 + +
+
+ ) : currentQuestion ? ( +
+
+
+ + 第 {progress.currentIndex + 1} 题 + + + {kindLabelMap[currentQuestion.kind]} + +
+
+
0 ? (progress.currentIndex / questions.length) * 100 : 0}%` }} + /> +
+
+ +
+ + +
+ {kindLabelMap[currentQuestion.kind]} +
+

+ {currentQuestion.text} +

+
+
+ +
+ {performanceDomains.map((domain) => renderOptionButton(domain.id))} +
+
+ +
+
+
+
+
+ +
+ + +
+ {kindLabelMap[currentQuestion.kind]} +
+

+ {currentQuestion.text} +

+
+
+
+ + {performanceDomains.map((domain, index) => ( +
+ {renderOptionButton(domain.id)} +
+ ))} +
+
+ + + {answerState && currentDomain ? ( +
+
+
+ {answerState.isCorrect ? '回答正确' : `正确答案是 ${currentDomain.name}`} +
+

+ {currentDomain.description} +

+
+ +
+ ) : ( +

+ 从语义上判断这句话最贴近哪个绩效域。 +

+ )} +
+
+ ) : null} +
+ ) +} diff --git a/src/pages/PerformanceDomainsPage.tsx b/src/pages/PerformanceDomainsPage.tsx index df12323..b6f08ee 100644 --- a/src/pages/PerformanceDomainsPage.tsx +++ b/src/pages/PerformanceDomainsPage.tsx @@ -3,6 +3,7 @@ import { motion } from 'framer-motion' import { ArrowRight, GitBranch, + GraduationCap, Handshake, Rocket, Target, @@ -70,6 +71,13 @@ export function PerformanceDomainsPage() {

{selectedDomain.name}

{selectedDomain.nameEn}

+ + + 练习 +
@@ -172,9 +180,18 @@ export function PerformanceDomainsPage() { return (
-
-

绩效域

-

8大项目绩效域

+
+
+

绩效域

+

8大项目绩效域

+
+ + + 开始练习 +