feat: refine performance domain practice flow

This commit is contained in:
ittoview
2026-05-13 18:20:35 +01:00
parent c5e71812d4
commit f5d0e5bc0c

View File

@@ -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 { AnimatePresence, motion } from 'framer-motion'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import clsx from 'clsx' import clsx from 'clsx'
@@ -15,7 +15,10 @@ import {
Workflow, Workflow,
} from 'lucide-react' } from 'lucide-react'
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation' import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
import { performanceDomains } from '@/data/performance-domains' import {
performanceDomains,
performanceDomainMap,
} from '@/data/performance-domains'
type QuestionKind = 'expectedGoal' | 'keyPoint' type QuestionKind = 'expectedGoal' | 'keyPoint'
type PracticeScope = 'all' | QuestionKind type PracticeScope = 'all' | QuestionKind
@@ -23,21 +26,37 @@ type PracticeScope = 'all' | QuestionKind
interface PracticeQuestion { interface PracticeQuestion {
id: string id: string
domainId: string domainId: string
domainName: string
domainDescription: string
kind: QuestionKind kind: QuestionKind
text: string text: string
} }
interface PracticeProgress { interface PracticeProgress {
scope: PracticeScope scope: PracticeScope
order: string[] queue: string[]
currentIndex: number completedIds: string[]
totalCount: number
correctCount: number correctCount: number
wrongCount: 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 }> = [ const scopeOptions: Array<{ value: PracticeScope; label: string }> = [
{ value: 'all', label: '全部' }, { value: 'all', label: '全部' },
@@ -62,14 +81,14 @@ const iconMap = {
} as const } as const
const desktopPositions = [ const desktopPositions = [
'left-10 top-12', 'left-6 top-10',
'left-1/2 top-0 -translate-x-1/2', 'left-1/2 top-1 -translate-x-1/2',
'right-10 top-12', 'right-6 top-10',
'right-0 top-1/2 -translate-y-[120%]', 'right-1 top-1/2 -translate-y-[118%]',
'right-10 bottom-12', 'right-6 bottom-10',
'left-1/2 bottom-0 -translate-x-1/2', 'left-1/2 bottom-1 -translate-x-1/2',
'left-10 bottom-12', 'left-6 bottom-10',
'left-0 top-1/2 translate-y-[20%]', 'left-1 top-1/2 translate-y-[18%]',
] as const ] as const
function shuffleArray<T>(items: T[]): T[] { function shuffleArray<T>(items: T[]): T[] {
@@ -89,8 +108,6 @@ function buildQuestionBank(): PracticeQuestion[] {
const expectedGoalQuestions = detail.expectedGoals.map((text, index) => ({ const expectedGoalQuestions = detail.expectedGoals.map((text, index) => ({
id: `${domain.id}-goal-${index}`, id: `${domain.id}-goal-${index}`,
domainId: domain.id, domainId: domain.id,
domainName: domain.name,
domainDescription: domain.description,
kind: 'expectedGoal' as const, kind: 'expectedGoal' as const,
text, text,
})) }))
@@ -98,8 +115,6 @@ function buildQuestionBank(): PracticeQuestion[] {
const keyPointQuestions = detail.keyPoints.map((text, index) => ({ const keyPointQuestions = detail.keyPoints.map((text, index) => ({
id: `${domain.id}-point-${index}`, id: `${domain.id}-point-${index}`,
domainId: domain.id, domainId: domain.id,
domainName: domain.name,
domainDescription: domain.description,
kind: 'keyPoint' as const, kind: 'keyPoint' as const,
text, text,
})) }))
@@ -112,47 +127,68 @@ function createProgress(
scope: PracticeScope, scope: PracticeScope,
questions: PracticeQuestion[] questions: PracticeQuestion[]
): PracticeProgress { ): PracticeProgress {
const filteredQuestions = questions.filter((question) => const filteredIds = questions
scope === 'all' ? true : question.kind === scope .filter((question) => (scope === 'all' ? true : question.kind === scope))
) .map((question) => question.id)
return { return {
scope, scope,
order: shuffleArray(filteredQuestions.map((question) => question.id)), queue: shuffleArray(filteredIds),
currentIndex: 0, completedIds: [],
totalCount: filteredIds.length,
correctCount: 0, correctCount: 0,
wrongCount: 0, wrongCount: 0,
} }
} }
function getStoredProgress(questions: PracticeQuestion[]): PracticeProgress { 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 { try {
const saved = localStorage.getItem(STORAGE_KEY) const saved = localStorage.getItem(STORAGE_KEY)
if (!saved) return createProgress('all', questions) if (!saved) return createProgress('all', questions)
const parsed = JSON.parse(saved) as Partial<PracticeProgress> const parsed = JSON.parse(saved) as Partial<PracticeProgress>
const scope = parsed.scope === 'expectedGoal' || parsed.scope === 'keyPoint' || parsed.scope === 'all' const scope =
? parsed.scope parsed.scope === 'all' ||
: 'all' parsed.scope === 'expectedGoal' ||
parsed.scope === 'keyPoint'
? parsed.scope
: 'all'
const filteredIds = questions const filteredIds = questions
.filter((question) => (scope === 'all' ? true : question.kind === scope)) .filter((question) => (scope === 'all' ? true : question.kind === scope))
.map((question) => question.id) .map((question) => question.id)
const validIdSet = new Set(filteredIds) 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 { return {
scope, scope,
order, queue: mergedQueue,
currentIndex: Math.min(Math.max(Number(parsed.currentIndex) || 0, 0), order.length), completedIds,
totalCount: filteredIds.length,
correctCount: Math.max(Number(parsed.correctCount) || 0, 0), correctCount: Math.max(Number(parsed.correctCount) || 0, 0),
wrongCount: Math.max(Number(parsed.wrongCount) || 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 (
<div
className={clsx(
'relative flex items-center justify-center',
className
)}
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
className="text-white/70 dark:text-gray-700"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
strokeDasharray={circumference}
animate={{ strokeDashoffset: dashOffset }}
transition={{ duration: 0.35, ease: 'easeOut' }}
className="text-indigo-500 dark:text-indigo-400"
/>
</svg>
{children && (
<div className="absolute inset-0 flex items-center justify-center">
{children}
</div>
)}
</div>
)
}
export default function PerformanceDomainPracticePage() { export default function PerformanceDomainPracticePage() {
const questionBank = useMemo(() => buildQuestionBank(), []) const questionBank = useMemo(() => buildQuestionBank(), [])
const questionMap = useMemo( const questionMap = useMemo(
() => new Map(questionBank.map((question) => [question.id, question])), () => new Map(questionBank.map((question) => [question.id, question])),
[questionBank] [questionBank]
) )
const autoNextTimerRef = useRef<number | null>(null)
const [progress, setProgress] = useState<PracticeProgress>(() => const [progress, setProgress] = useState<PracticeProgress>(() =>
getStoredProgress(questionBank) getStoredProgress(questionBank)
) )
const [selectedDomainId, setSelectedDomainId] = useState<string | null>(null) const [answerState, setAnswerState] = useState<AnswerState | null>(null)
const [showCelebration, setShowCelebration] = useState(false) const [showCelebration, setShowCelebration] = useState(false)
const questions = useMemo( const currentQuestionId = progress.queue[0] ?? null
() => const currentQuestion = currentQuestionId
progress.order ? questionMap.get(currentQuestionId) ?? null
.map((id) => questionMap.get(id)) : null
.filter((question): question is PracticeQuestion => Boolean(question)),
[progress.order, questionMap]
)
const currentQuestion = questions[progress.currentIndex] ?? null
const currentDomain = currentQuestion const currentDomain = currentQuestion
? performanceDomains.find((domain) => domain.id === currentQuestion.domainId) ?? null ? performanceDomainMap.get(currentQuestion.domainId) ?? null
: null : null
const answeredCount = Math.min(progress.currentIndex, questions.length) const selectedDomain = answerState
const isFinished = progress.currentIndex >= questions.length ? performanceDomainMap.get(answerState.selectedDomainId) ?? null
const accuracy = answeredCount > 0
? Math.round((progress.correctCount / answeredCount) * 100)
: 0
const answerState = currentQuestion && selectedDomainId
? {
isCorrect: selectedDomainId === currentQuestion.domainId,
correctDomainId: currentQuestion.domainId,
}
: 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(() => { useEffect(() => {
try { try {
@@ -206,24 +298,89 @@ export default function PerformanceDomainPracticePage() {
} }
}, [progress]) }, [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) => { const restartPractice = (scope = progress.scope) => {
clearAutoNextTimer()
setProgress(createProgress(scope, questionBank)) setProgress(createProgress(scope, questionBank))
setSelectedDomainId(null) setAnswerState(null)
setShowCelebration(false) setShowCelebration(false)
} }
const switchScope = (scope: PracticeScope) => { const switchScope = (scope: PracticeScope) => {
if (scope === progress.scope) return if (scope === progress.scope) return
setProgress(createProgress(scope, questionBank)) restartPractice(scope)
setSelectedDomainId(null)
setShowCelebration(false)
} }
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) => { const handleSelect = (domainId: string) => {
if (!currentQuestion || answerState) return if (!currentQuestion || answerState) return
const isCorrect = domainId === currentQuestion.domainId const isCorrect = domainId === currentQuestion.domainId
setSelectedDomainId(domainId) setAnswerState({
selectedDomainId: domainId,
correctDomainId: currentQuestion.domainId,
isCorrect,
})
setProgress((prev) => ({ setProgress((prev) => ({
...prev, ...prev,
correctCount: prev.correctCount + (isCorrect ? 1 : 0), 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 renderOptionButton = (domainId: string, className?: string) => {
const domain = performanceDomains.find((item) => item.id === domainId) const domain = performanceDomainMap.get(domainId)
if (!domain) return null if (!domain) return null
const Icon = iconMap[domain.id as keyof typeof iconMap] const Icon = iconMap[domain.id as keyof typeof iconMap]
const isSelected = selectedDomainId === domain.id const isAnswerShown = Boolean(answerState)
const isCorrect = answerState?.correctDomainId === domain.id const isSelected = answerState?.selectedDomainId === domain.id
const isWrongSelection = Boolean(answerState) && isSelected && !answerState?.isCorrect const isCorrectDomain = answerState?.correctDomainId === domain.id
const isCorrectSelected = isAnswerShown && isSelected && answerState?.isCorrect
const isWrongSelected = isAnswerShown && isSelected && !answerState?.isCorrect
const shouldHighlightCorrect = isAnswerShown && isCorrectDomain
return ( return (
<motion.button <motion.button
key={domain.id} key={domain.id}
type="button" type="button"
whileHover={!answerState ? { y: -2 } : undefined} whileHover={!isAnswerShown ? { y: -2 } : undefined}
whileTap={!answerState ? { scale: 0.98 } : undefined} whileTap={!isAnswerShown ? { scale: 0.98 } : undefined}
onClick={() => handleSelect(domain.id)} onClick={() => handleSelect(domain.id)}
disabled={Boolean(answerState)} disabled={isAnswerShown}
className={clsx( 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', 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
answerState && 'cursor-default', isAnswerShown && 'cursor-default',
isSelected && !answerState && 'border-indigo-400 ring-2 ring-indigo-100 dark:ring-indigo-900/50', !isAnswerShown && 'hover:shadow-md',
isCorrect && 'border-emerald-300 bg-emerald-50 shadow-md dark:border-emerald-700 dark:bg-emerald-950/40', shouldHighlightCorrect &&
isWrongSelection && 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950/40', '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 className
)} )}
> >
@@ -281,7 +427,7 @@ export default function PerformanceDomainPracticePage() {
> >
<Icon size={18} /> <Icon size={18} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-gray-900 dark:text-white"> <div className="text-sm font-semibold text-gray-900 dark:text-white">
{domain.name} {domain.name}
</div> </div>
@@ -290,6 +436,26 @@ export default function PerformanceDomainPracticePage() {
</div> </div>
</div> </div>
</div> </div>
{isAnswerShown && (
<div className="mt-3 flex flex-wrap gap-2">
{isCorrectSelected && (
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
</span>
)}
{isWrongSelected && (
<span className="rounded-full bg-rose-100 px-2.5 py-1 text-xs font-medium text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">
</span>
)}
{shouldHighlightCorrect && !isCorrectSelected && (
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
</span>
)}
</div>
)}
</motion.button> </motion.button>
) )
} }
@@ -303,14 +469,19 @@ export default function PerformanceDomainPracticePage() {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> <nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Link to="/performance-domains" className="hover:text-indigo-600 dark:hover:text-indigo-400"> <Link
to="/performance-domains"
className="hover:text-indigo-600 dark:hover:text-indigo-400"
>
</Link> </Link>
<span>/</span> <span>/</span>
<span className="text-gray-900 dark:text-white"></span> <span className="text-gray-900 dark:text-white"></span>
</nav> </nav>
<div> <div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white"></h1> <h1 className="text-xl font-bold text-gray-900 dark:text-white">
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
</p> </p>
@@ -348,20 +519,30 @@ export default function PerformanceDomainPracticePage() {
})} })}
</div> </div>
<div className="grid gap-3 md:grid-cols-3"> <div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-2xl bg-white px-4 py-3 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"> <div className="text-sm text-gray-600 dark:text-gray-300">
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white"> <span className="ml-2 font-semibold text-gray-900 dark:text-white">
{Math.min(answeredCount + (answerState ? 1 : 0), questions.length)} / {questions.length} {displayCompletedCount} / {progress.totalCount}
</div> </span>
</div> </div>
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"> <div className="text-sm text-gray-600 dark:text-gray-300">
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{accuracy}%</div> <span className="ml-2 font-semibold text-gray-900 dark:text-white">
{remainingCount}
</span>
</div> </div>
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"> <div className="text-sm text-gray-600 dark:text-gray-300">
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{progress.correctCount}</div> <span className="ml-2 font-semibold text-gray-900 dark:text-white">
{accuracy}%
</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">
<span className="ml-2 font-semibold text-gray-900 dark:text-white">
{progress.wrongCount}
</span>
</div> </div>
</div> </div>
@@ -378,25 +559,28 @@ export default function PerformanceDomainPracticePage() {
</div> </div>
<h2 className="mt-4 text-3xl font-bold"></h2> <h2 className="mt-4 text-3xl font-bold"></h2>
<p className="mt-2 text-sm text-white/80"> <p className="mt-2 text-sm text-white/80">
{progress.correctCount} {progress.wrongCount} {progress.totalCount} {progress.wrongCount}
</p> </p>
</div> </div>
<div className="grid gap-4 px-6 py-6 md:grid-cols-3"> <div className="grid gap-4 px-6 py-6 md:grid-cols-3">
<div className="rounded-2xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40"> <div className="rounded-2xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400"></div> <div className="text-sm text-gray-500 dark:text-gray-400"></div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{questions.length}</div> <div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{progress.totalCount}
</div>
</div> </div>
<div className="rounded-2xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40"> <div className="rounded-2xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400"></div> <div className="text-sm text-gray-500 dark:text-gray-400"></div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white"> <div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{questions.length > 0 ? Math.round((progress.correctCount / questions.length) * 100) : 0}% {accuracy}%
</div> </div>
</div> </div>
<div className="rounded-2xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40"> <div className="rounded-2xl bg-gray-50 px-4 py-4 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400"></div> <div className="text-sm text-gray-500 dark:text-gray-400"></div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white"> <div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{scopeOptions.find((option) => option.value === progress.scope)?.label ?? '全部'} {scopeOptions.find((option) => option.value === progress.scope)?.label ??
'全部'}
</div> </div>
</div> </div>
</div> </div>
@@ -420,24 +604,29 @@ export default function PerformanceDomainPracticePage() {
</motion.div> </motion.div>
) : currentQuestion ? ( ) : currentQuestion ? (
<div className="overflow-hidden rounded-3xl bg-gradient-to-br from-indigo-50 via-white to-cyan-50 p-4 shadow-sm ring-1 ring-indigo-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 dark:ring-gray-700 lg:p-6"> <div className="overflow-hidden rounded-3xl bg-gradient-to-br from-indigo-50 via-white to-cyan-50 p-4 shadow-sm ring-1 ring-indigo-100 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 dark:ring-gray-700 lg:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="rounded-full bg-white/80 px-3 py-1 text-sm font-medium text-gray-600 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-700">
{progress.currentIndex + 1}
</span>
<span className="rounded-full bg-indigo-600 px-3 py-1 text-sm font-medium text-white">
{kindLabelMap[currentQuestion.kind]}
</span>
</div>
<div className="h-2 w-full max-w-xs overflow-hidden rounded-full bg-white/70 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-cyan-500 transition-all duration-300"
style={{ width: `${questions.length > 0 ? (progress.currentIndex / questions.length) * 100 : 0}%` }}
/>
</div>
</div>
<div className="lg:hidden"> <div className="lg:hidden">
<div className="mb-4 flex items-center gap-4 rounded-3xl bg-white/85 px-4 py-4 shadow-sm ring-1 ring-gray-100 backdrop-blur-sm dark:bg-gray-800/85 dark:ring-gray-700">
<CircularProgress value={displayCompletedCount} total={progress.totalCount} size={82} strokeWidth={8}>
<div className="text-center">
<div className="text-lg font-bold text-gray-900 dark:text-white">
{displayCompletedCount}
</div>
<div className="text-[11px] text-gray-500 dark:text-gray-400">
/ {progress.totalCount}
</div>
</div>
</CircularProgress>
<div className="space-y-2">
<div className="inline-flex rounded-full bg-indigo-600 px-3 py-1 text-sm font-medium text-white">
{kindLabelMap[currentQuestion.kind]}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{remainingCount}
</div>
</div>
</div>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={currentQuestion.id} key={currentQuestion.id}
@@ -446,27 +635,85 @@ export default function PerformanceDomainPracticePage() {
exit={{ opacity: 0, y: -16 }} exit={{ opacity: 0, y: -16 }}
className="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700" className="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
> >
<div className="text-sm font-medium text-indigo-600 dark:text-indigo-400"> <p className="text-xl font-semibold leading-9 text-gray-900 dark:text-white">
{kindLabelMap[currentQuestion.kind]}
</div>
<p className="mt-4 text-xl font-semibold leading-9 text-gray-900 dark:text-white">
{currentQuestion.text} {currentQuestion.text}
</p> </p>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
<motion.div
initial={false}
animate={{ opacity: answerState ? 1 : 0.96 }}
className={clsx(
'mt-4 rounded-2xl border px-4 py-4 shadow-sm',
answerState?.isCorrect
? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30'
: answerState
? 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30'
: 'border-white/70 bg-white/80 dark:border-gray-700 dark:bg-gray-800/80'
)}
>
{answerState && currentDomain ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<div
className={clsx(
'text-sm font-semibold',
answerState.isCorrect
? 'text-emerald-700 dark:text-emerald-300'
: 'text-amber-700 dark:text-amber-300'
)}
>
{answerState.isCorrect
? '回答正确'
: `你选了 ${selectedDomain?.name ?? ''},正确答案是 ${currentDomain.name}`}
</div>
<p className="text-sm leading-6 text-gray-600 dark:text-gray-300">
{answerState.isCorrect
? currentDomain.description
: '这题已放到后面,稍后会再出现。'}
</p>
</div>
<button
type="button"
onClick={() => advanceToNext(answerState.isCorrect)}
className="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500"
>
</button>
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-300">
</p>
)}
</motion.div>
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-4 grid gap-3 sm:grid-cols-2">
{performanceDomains.map((domain) => renderOptionButton(domain.id))} {performanceDomains.map((domain) => renderOptionButton(domain.id))}
</div> </div>
</div> </div>
<div className="hidden lg:block"> <div className="hidden lg:block">
<div className="relative mx-auto h-[36rem] max-w-6xl overflow-hidden rounded-[2rem]"> <div className="relative mx-auto h-[38rem] max-w-6xl overflow-hidden rounded-[2rem]">
<div className="absolute left-1/2 top-1/2 h-[32rem] w-[32rem] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/80 dark:border-gray-700" /> <CircularProgress
<div className="absolute left-1/2 top-1/2 h-[25rem] w-[25rem] -translate-x-1/2 -translate-y-1/2 rounded-full border border-indigo-100 dark:border-gray-700/80" /> value={displayCompletedCount}
<div className="absolute left-1/2 top-1/2 h-60 w-60 -translate-x-1/2 -translate-y-1/2 rounded-full bg-indigo-200/30 blur-3xl dark:bg-indigo-900/20" /> total={progress.totalCount}
size={430}
strokeWidth={16}
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-indigo-500"
/>
<div className="absolute left-1/2 top-1/2 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4">
<div className="mb-4 flex items-center justify-center gap-3">
<span className="rounded-full bg-white/90 px-3 py-1 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:ring-gray-700">
{displayCompletedCount} / {progress.totalCount}
</span>
<span className="rounded-full bg-indigo-600 px-3 py-1 text-sm font-medium text-white">
{kindLabelMap[currentQuestion.kind]}
</span>
</div>
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 px-4">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={currentQuestion.id} key={currentQuestion.id}
@@ -475,71 +722,71 @@ export default function PerformanceDomainPracticePage() {
exit={{ opacity: 0, scale: 0.96, y: -16 }} exit={{ opacity: 0, scale: 0.96, y: -16 }}
className="rounded-[2rem] bg-white/95 px-6 py-7 text-center shadow-xl ring-1 ring-gray-100 backdrop-blur-sm dark:bg-gray-800/95 dark:ring-gray-700" className="rounded-[2rem] bg-white/95 px-6 py-7 text-center shadow-xl ring-1 ring-gray-100 backdrop-blur-sm dark:bg-gray-800/95 dark:ring-gray-700"
> >
<div className="inline-flex rounded-full bg-indigo-50 px-3 py-1 text-sm font-medium text-indigo-600 dark:bg-indigo-900/40 dark:text-indigo-300"> <p className="text-2xl font-semibold leading-10 text-gray-900 dark:text-white">
{kindLabelMap[currentQuestion.kind]}
</div>
<p className="mt-5 text-2xl font-semibold leading-10 text-gray-900 dark:text-white">
{currentQuestion.text} {currentQuestion.text}
</p> </p>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
<motion.div
initial={false}
animate={{ opacity: answerState ? 1 : 0.96 }}
className={clsx(
'mt-4 rounded-2xl border px-4 py-4 shadow-sm',
answerState?.isCorrect
? 'border-emerald-200 bg-emerald-50/95 dark:border-emerald-800 dark:bg-emerald-950/40'
: answerState
? 'border-amber-200 bg-amber-50/95 dark:border-amber-800 dark:bg-amber-950/40'
: 'border-white/70 bg-white/85 dark:border-gray-700 dark:bg-gray-800/85'
)}
>
{answerState && currentDomain ? (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<div
className={clsx(
'text-sm font-semibold',
answerState.isCorrect
? 'text-emerald-700 dark:text-emerald-300'
: 'text-amber-700 dark:text-amber-300'
)}
>
{answerState.isCorrect
? '回答正确'
: `你选了 ${selectedDomain?.name ?? ''},正确答案是 ${currentDomain.name}`}
</div>
<p className="text-sm leading-6 text-gray-600 dark:text-gray-300">
{answerState.isCorrect
? currentDomain.description
: '这题已放到后面,稍后会再出现。'}
</p>
</div>
<button
type="button"
onClick={() => advanceToNext(answerState.isCorrect)}
className="shrink-0 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500"
>
</button>
</div>
) : (
<p className="text-center text-sm text-gray-600 dark:text-gray-300">
</p>
)}
</motion.div>
</div> </div>
{performanceDomains.map((domain, index) => ( {performanceDomains.map((domain, index) => (
<div <div
key={domain.id} key={domain.id}
className={clsx( className={clsx('absolute w-56', desktopPositions[index])}
'absolute w-56',
desktopPositions[index]
)}
> >
{renderOptionButton(domain.id)} {renderOptionButton(domain.id)}
</div> </div>
))} ))}
</div> </div>
</div> </div>
<motion.div
initial={false}
animate={{ opacity: answerState ? 1 : 0.92 }}
className={clsx(
'mt-4 rounded-2xl border px-4 py-4 shadow-sm',
answerState?.isCorrect
? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30'
: answerState
? 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30'
: 'border-white/70 bg-white/80 dark:border-gray-700 dark:bg-gray-800/80'
)}
>
{answerState && currentDomain ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className={clsx(
'text-sm font-semibold',
answerState.isCorrect
? 'text-emerald-700 dark:text-emerald-300'
: 'text-amber-700 dark:text-amber-300'
)}>
{answerState.isCorrect ? '回答正确' : `正确答案是 ${currentDomain.name}`}
</div>
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
{currentDomain.description}
</p>
</div>
<button
type="button"
onClick={handleNext}
className="rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500"
>
{progress.currentIndex === questions.length - 1 ? '查看结果' : '下一题'}
</button>
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-300">
</p>
)}
</motion.div>
</div> </div>
) : null} ) : null}
</div> </div>