From f5d0e5bc0c75ac0b59137441ffa45e835203ff1a Mon Sep 17 00:00:00 2001 From: ittoview Date: Wed, 13 May 2026 18:20:35 +0100 Subject: [PATCH] feat: refine performance domain practice flow --- src/pages/PerformanceDomainPracticePage.tsx | 617 ++++++++++++++------ 1 file changed, 432 insertions(+), 185 deletions(-) diff --git a/src/pages/PerformanceDomainPracticePage.tsx b/src/pages/PerformanceDomainPracticePage.tsx index e612178..d91918a 100644 --- a/src/pages/PerformanceDomainPracticePage.tsx +++ b/src/pages/PerformanceDomainPracticePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { Link } from 'react-router-dom' import clsx from 'clsx' @@ -15,7 +15,10 @@ import { Workflow, } from 'lucide-react' import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation' -import { performanceDomains } from '@/data/performance-domains' +import { + performanceDomains, + performanceDomainMap, +} from '@/data/performance-domains' type QuestionKind = 'expectedGoal' | 'keyPoint' type PracticeScope = 'all' | QuestionKind @@ -23,21 +26,37 @@ 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 + queue: string[] + completedIds: string[] + totalCount: number correctCount: number wrongCount: number } -const STORAGE_KEY = 'performance-domain-practice-progress' +interface AnswerState { + selectedDomainId: string + correctDomainId: string + isCorrect: boolean +} + +interface CircularProgressProps { + value: number + total: number + size: number + strokeWidth: number + className?: string + children?: ReactNode +} + +const STORAGE_KEY = 'performance-domain-practice-progress-v2' +const CORRECT_DELAY = 900 +const WRONG_DELAY = 1800 const scopeOptions: Array<{ value: PracticeScope; label: string }> = [ { value: 'all', label: '全部' }, @@ -62,14 +81,14 @@ const iconMap = { } 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%]', + 'left-6 top-10', + 'left-1/2 top-1 -translate-x-1/2', + 'right-6 top-10', + 'right-1 top-1/2 -translate-y-[118%]', + 'right-6 bottom-10', + 'left-1/2 bottom-1 -translate-x-1/2', + 'left-6 bottom-10', + 'left-1 top-1/2 translate-y-[18%]', ] as const function shuffleArray(items: T[]): T[] { @@ -89,8 +108,6 @@ function buildQuestionBank(): PracticeQuestion[] { 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, })) @@ -98,8 +115,6 @@ function buildQuestionBank(): PracticeQuestion[] { 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, })) @@ -112,47 +127,68 @@ function createProgress( scope: PracticeScope, questions: PracticeQuestion[] ): PracticeProgress { - const filteredQuestions = questions.filter((question) => - scope === 'all' ? true : question.kind === scope - ) + const filteredIds = questions + .filter((question) => (scope === 'all' ? true : question.kind === scope)) + .map((question) => question.id) return { scope, - order: shuffleArray(filteredQuestions.map((question) => question.id)), - currentIndex: 0, + queue: shuffleArray(filteredIds), + completedIds: [], + totalCount: filteredIds.length, correctCount: 0, wrongCount: 0, } } function getStoredProgress(questions: PracticeQuestion[]): PracticeProgress { - const questionMap = new Map(questions.map((question) => [question.id, question])) + const questionIdSet = new Set(questions.map((question) => question.id)) 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 scope = + parsed.scope === 'all' || + parsed.scope === 'expectedGoal' || + parsed.scope === 'keyPoint' + ? 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) + const completedIds = Array.isArray(parsed.completedIds) + ? parsed.completedIds.filter( + (id, index, array): id is string => + questionIdSet.has(String(id)) && + validIdSet.has(String(id)) && + array.indexOf(id) === index + ) + : [] + + const queue = Array.isArray(parsed.queue) + ? parsed.queue.filter( + (id): id is string => + questionIdSet.has(String(id)) && + validIdSet.has(String(id)) && + !completedIds.includes(String(id)) + ) + : [] + + const missingIds = filteredIds.filter( + (id) => !completedIds.includes(id) && !queue.includes(id) + ) + const mergedQueue = [...queue, ...shuffleArray(missingIds)] return { scope, - order, - currentIndex: Math.min(Math.max(Number(parsed.currentIndex) || 0, 0), order.length), + queue: mergedQueue, + completedIds, + totalCount: filteredIds.length, correctCount: Math.max(Number(parsed.correctCount) || 0, 0), wrongCount: Math.max(Number(parsed.wrongCount) || 0, 0), } @@ -162,41 +198,97 @@ function getStoredProgress(questions: PracticeQuestion[]): PracticeProgress { } } +function CircularProgress({ + value, + total, + size, + strokeWidth, + className, + children, +}: CircularProgressProps) { + const clampedTotal = total > 0 ? total : 1 + const ratio = Math.min(Math.max(value / clampedTotal, 0), 1) + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const dashOffset = circumference * (1 - ratio) + + return ( +
+ + + + + {children && ( +
+ {children} +
+ )} +
+ ) +} + export default function PerformanceDomainPracticePage() { const questionBank = useMemo(() => buildQuestionBank(), []) const questionMap = useMemo( () => new Map(questionBank.map((question) => [question.id, question])), [questionBank] ) + const autoNextTimerRef = useRef(null) + const [progress, setProgress] = useState(() => getStoredProgress(questionBank) ) - const [selectedDomainId, setSelectedDomainId] = useState(null) + const [answerState, setAnswerState] = 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 currentQuestionId = progress.queue[0] ?? null + const currentQuestion = currentQuestionId + ? questionMap.get(currentQuestionId) ?? null + : null const currentDomain = currentQuestion - ? performanceDomains.find((domain) => domain.id === currentQuestion.domainId) ?? null + ? performanceDomainMap.get(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, - } + const selectedDomain = answerState + ? performanceDomainMap.get(answerState.selectedDomainId) ?? null : null + const isFinished = progress.totalCount > 0 && progress.queue.length === 0 + const displayCompletedCount = + progress.completedIds.length + (answerState?.isCorrect ? 1 : 0) + const remainingCount = Math.max(progress.totalCount - displayCompletedCount, 0) + const accuracyBase = progress.correctCount + progress.wrongCount + const accuracy = + accuracyBase > 0 ? Math.round((progress.correctCount / accuracyBase) * 100) : 0 useEffect(() => { try { @@ -206,24 +298,89 @@ export default function PerformanceDomainPracticePage() { } }, [progress]) + useEffect(() => { + if (isFinished) { + setShowCelebration(true) + } + }, [isFinished]) + + useEffect(() => { + return () => { + if (autoNextTimerRef.current) { + window.clearTimeout(autoNextTimerRef.current) + } + } + }, []) + + const clearAutoNextTimer = () => { + if (autoNextTimerRef.current) { + window.clearTimeout(autoNextTimerRef.current) + autoNextTimerRef.current = null + } + } + const restartPractice = (scope = progress.scope) => { + clearAutoNextTimer() setProgress(createProgress(scope, questionBank)) - setSelectedDomainId(null) + setAnswerState(null) setShowCelebration(false) } const switchScope = (scope: PracticeScope) => { if (scope === progress.scope) return - setProgress(createProgress(scope, questionBank)) - setSelectedDomainId(null) - setShowCelebration(false) + restartPractice(scope) } + const advanceToNext = (isCorrect: boolean) => { + if (!currentQuestionId) return + + clearAutoNextTimer() + setAnswerState(null) + + setProgress((prev) => { + if (prev.queue[0] !== currentQuestionId) return prev + + const [, ...restQueue] = prev.queue + + if (isCorrect) { + return { + ...prev, + queue: restQueue, + completedIds: prev.completedIds.includes(currentQuestionId) + ? prev.completedIds + : [...prev.completedIds, currentQuestionId], + } + } + + return { + ...prev, + queue: [...restQueue, currentQuestionId], + } + }) + } + + useEffect(() => { + if (!answerState) return + + clearAutoNextTimer() + autoNextTimerRef.current = window.setTimeout(() => { + advanceToNext(answerState.isCorrect) + }, answerState.isCorrect ? CORRECT_DELAY : WRONG_DELAY) + + return () => { + clearAutoNextTimer() + } + }, [answerState, currentQuestionId]) + const handleSelect = (domainId: string) => { if (!currentQuestion || answerState) return const isCorrect = domainId === currentQuestion.domainId - setSelectedDomainId(domainId) + setAnswerState({ + selectedDomainId: domainId, + correctDomainId: currentQuestion.domainId, + isCorrect, + }) setProgress((prev) => ({ ...prev, correctCount: prev.correctCount + (isCorrect ? 1 : 0), @@ -231,46 +388,35 @@ export default function PerformanceDomainPracticePage() { })) } - 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) + const domain = performanceDomainMap.get(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 + const isAnswerShown = Boolean(answerState) + const isSelected = answerState?.selectedDomainId === domain.id + const isCorrectDomain = answerState?.correctDomainId === domain.id + const isCorrectSelected = isAnswerShown && isSelected && answerState?.isCorrect + const isWrongSelected = isAnswerShown && isSelected && !answerState?.isCorrect + const shouldHighlightCorrect = isAnswerShown && isCorrectDomain return ( handleSelect(domain.id)} - disabled={Boolean(answerState)} + disabled={isAnswerShown} 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', + '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', + isAnswerShown && 'cursor-default', + !isAnswerShown && 'hover:shadow-md', + shouldHighlightCorrect && + 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-950/40', + isWrongSelected && + 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950/40', className )} > @@ -281,7 +427,7 @@ export default function PerformanceDomainPracticePage() { > -
+
{domain.name}
@@ -290,6 +436,26 @@ export default function PerformanceDomainPracticePage() {
+ + {isAnswerShown && ( +
+ {isCorrectSelected && ( + + 正确 + + )} + {isWrongSelected && ( + + 你的选择 + + )} + {shouldHighlightCorrect && !isCorrectSelected && ( + + 正确答案 + + )} +
+ )}
) } @@ -303,14 +469,19 @@ export default function PerformanceDomainPracticePage() {
-

八大绩效域练习

+

+ 八大绩效域练习 +

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

@@ -348,20 +519,30 @@ export default function PerformanceDomainPracticePage() { })}
-
-
-
练习进度
-
- {Math.min(answeredCount + (answerState ? 1 : 0), questions.length)} / {questions.length} -
+
+
+ 已完成 + + {displayCompletedCount} / {progress.totalCount} +
-
-
当前正确率
-
{accuracy}%
+
+ 剩余 + + {remainingCount} +
-
-
已答对
-
{progress.correctCount}
+
+ 正确率 + + {accuracy}% + +
+
+ 错误次数 + + {progress.wrongCount} +
@@ -378,25 +559,28 @@ export default function PerformanceDomainPracticePage() {

八大绩效域练习已完成

- 本轮共答对 {progress.correctCount} 题,答错 {progress.wrongCount} 题。 + 共完成 {progress.totalCount} 题,错误 {progress.wrongCount} 次。

总题数
-
{questions.length}
+
+ {progress.totalCount} +
正确率
- {questions.length > 0 ? Math.round((progress.correctCount / questions.length) * 100) : 0}% + {accuracy}%
当前范围
- {scopeOptions.find((option) => option.value === progress.scope)?.label ?? '全部'} + {scopeOptions.find((option) => option.value === progress.scope)?.label ?? + '全部'}
@@ -420,24 +604,29 @@ export default function PerformanceDomainPracticePage() { ) : currentQuestion ? (
-
-
- - 第 {progress.currentIndex + 1} 题 - - - {kindLabelMap[currentQuestion.kind]} - -
-
-
0 ? (progress.currentIndex / questions.length) * 100 : 0}%` }} - /> -
-
-
+
+ +
+
+ {displayCompletedCount} +
+
+ / {progress.totalCount} +
+
+
+ +
+
+ {kindLabelMap[currentQuestion.kind]} +
+
+ 剩余 {remainingCount} 题 +
+
+
+ -
- {kindLabelMap[currentQuestion.kind]} -
-

+

{currentQuestion.text}

+ + {answerState && currentDomain ? ( +
+
+
+ {answerState.isCorrect + ? '回答正确' + : `你选了 ${selectedDomain?.name ?? ''},正确答案是 ${currentDomain.name}`} +
+

+ {answerState.isCorrect + ? currentDomain.description + : '这题已放到后面,稍后会再出现。'} +

+
+ +
+ ) : ( +

+ 选中后会自动进入下一个。 +

+ )} +
+
{performanceDomains.map((domain) => renderOptionButton(domain.id))}
-
-
-
-
+
+ + +
+
+ + 已完成 {displayCompletedCount} / {progress.totalCount} + + + {kindLabelMap[currentQuestion.kind]} + +
-
-
- {kindLabelMap[currentQuestion.kind]} -
-

+

{currentQuestion.text}

+ + + {answerState && currentDomain ? ( +
+
+
+ {answerState.isCorrect + ? '回答正确' + : `你选了 ${selectedDomain?.name ?? ''},正确答案是 ${currentDomain.name}`} +
+

+ {answerState.isCorrect + ? currentDomain.description + : '这题已放到后面,稍后会再出现。'} +

+
+ +
+ ) : ( +

+ 选中后会自动进入下一个。 +

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

- {currentDomain.description} -

-
- -
- ) : ( -

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

- )} -
) : null}