feat: refine performance domain practice flow
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user