697 lines
27 KiB
TypeScript
697 lines
27 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { motion } from 'framer-motion'
|
||
import { Link } from 'react-router-dom'
|
||
import clsx from 'clsx'
|
||
import {
|
||
AlertTriangle,
|
||
BarChart3,
|
||
CheckCircle2,
|
||
GitBranch,
|
||
GraduationCap,
|
||
Handshake,
|
||
RefreshCw,
|
||
Rocket,
|
||
Target,
|
||
Users,
|
||
Workflow,
|
||
XCircle,
|
||
} from 'lucide-react'
|
||
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||
import {
|
||
performanceDomains,
|
||
performanceDomainMap,
|
||
} from '@/data/performance-domains'
|
||
|
||
type QuestionKind = 'expectedGoal' | 'keyPoint'
|
||
type PracticeScope = 'all' | QuestionKind
|
||
|
||
interface PracticeQuestion {
|
||
id: string
|
||
domainId: string
|
||
kind: QuestionKind
|
||
text: string
|
||
}
|
||
|
||
interface PracticeProgress {
|
||
scope: PracticeScope
|
||
queue: string[]
|
||
completedIds: string[]
|
||
totalCount: number
|
||
correctCount: number
|
||
wrongCount: number
|
||
}
|
||
|
||
interface AnswerState {
|
||
selectedDomainId: string
|
||
correctDomainId: string
|
||
isCorrect: boolean
|
||
}
|
||
|
||
const STORAGE_KEY = 'performance-domain-practice-progress-v3'
|
||
const CORRECT_AUTO_NEXT_DELAY = 1000
|
||
const CHALLENGE_RESTART_DELAY = 3000
|
||
|
||
const scopeOptions: Array<{ value: PracticeScope; label: string }> = [
|
||
{ value: 'all', label: '全部' },
|
||
{ value: 'expectedGoal', label: '预期目标' },
|
||
{ value: 'keyPoint', label: '绩效要点' },
|
||
]
|
||
|
||
const kindLabelMap: Record<QuestionKind, string> = {
|
||
expectedGoal: '预期目标',
|
||
keyPoint: '绩效要点',
|
||
}
|
||
|
||
const iconMap = {
|
||
PD01: Handshake,
|
||
PD02: Users,
|
||
PD03: GitBranch,
|
||
PD04: Target,
|
||
PD05: Workflow,
|
||
PD06: Rocket,
|
||
PD07: BarChart3,
|
||
PD08: AlertTriangle,
|
||
} as const
|
||
|
||
function shuffleArray<T>(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,
|
||
kind: 'expectedGoal' as const,
|
||
text,
|
||
}))
|
||
|
||
const keyPointQuestions = detail.keyPoints.map((text, index) => ({
|
||
id: `${domain.id}-point-${index}`,
|
||
domainId: domain.id,
|
||
kind: 'keyPoint' as const,
|
||
text,
|
||
}))
|
||
|
||
return [...expectedGoalQuestions, ...keyPointQuestions]
|
||
})
|
||
}
|
||
|
||
function createProgress(
|
||
scope: PracticeScope,
|
||
questions: PracticeQuestion[]
|
||
): PracticeProgress {
|
||
const filteredIds = questions
|
||
.filter((question) => (scope === 'all' ? true : question.kind === scope))
|
||
.map((question) => question.id)
|
||
|
||
return {
|
||
scope,
|
||
queue: shuffleArray(filteredIds),
|
||
completedIds: [],
|
||
totalCount: filteredIds.length,
|
||
correctCount: 0,
|
||
wrongCount: 0,
|
||
}
|
||
}
|
||
|
||
function getStoredProgress(questions: PracticeQuestion[]): PracticeProgress {
|
||
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<PracticeProgress>
|
||
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 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)
|
||
)
|
||
|
||
return {
|
||
scope,
|
||
queue: [...queue, ...shuffleArray(missingIds)],
|
||
completedIds,
|
||
totalCount: filteredIds.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<PracticeProgress>(() =>
|
||
getStoredProgress(questionBank)
|
||
)
|
||
const [answerState, setAnswerState] = useState<AnswerState | null>(null)
|
||
const [showCelebration, setShowCelebration] = useState(false)
|
||
const [challengeMode, setChallengeMode] = useState(false)
|
||
const autoNextTimerRef = useRef<number | null>(null)
|
||
const challengeRestartTimerRef = useRef<number | null>(null)
|
||
|
||
const currentQuestionId = progress.queue[0] ?? null
|
||
const currentQuestion = currentQuestionId
|
||
? questionMap.get(currentQuestionId) ?? null
|
||
: null
|
||
const currentDomain = currentQuestion
|
||
? performanceDomainMap.get(currentQuestion.domainId) ?? null
|
||
: null
|
||
const selectedDomain = answerState
|
||
? performanceDomainMap.get(answerState.selectedDomainId) ?? null
|
||
: null
|
||
const isFinished = progress.totalCount > 0 && progress.queue.length === 0
|
||
const completedCount = progress.completedIds.length + (answerState?.isCorrect ? 1 : 0)
|
||
const remainingCount = Math.max(progress.totalCount - completedCount, 0)
|
||
const accuracyBase = progress.correctCount + progress.wrongCount
|
||
const accuracy = accuracyBase > 0 ? Math.round((progress.correctCount / accuracyBase) * 100) : 0
|
||
const progressPercent = progress.totalCount > 0 ? (completedCount / progress.totalCount) * 100 : 0
|
||
const completedIdSet = useMemo(() => new Set(progress.completedIds), [progress.completedIds])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress))
|
||
} catch (error) {
|
||
console.error('保存绩效域练习进度失败:', error)
|
||
}
|
||
}, [progress])
|
||
|
||
useEffect(() => {
|
||
if (isFinished) setShowCelebration(true)
|
||
}, [isFinished])
|
||
|
||
useEffect(() => {
|
||
if (!answerState?.isCorrect) return
|
||
|
||
if (autoNextTimerRef.current) {
|
||
window.clearTimeout(autoNextTimerRef.current)
|
||
}
|
||
|
||
autoNextTimerRef.current = window.setTimeout(() => {
|
||
advanceToNext(true)
|
||
}, CORRECT_AUTO_NEXT_DELAY)
|
||
|
||
return () => {
|
||
if (autoNextTimerRef.current) {
|
||
window.clearTimeout(autoNextTimerRef.current)
|
||
autoNextTimerRef.current = null
|
||
}
|
||
}
|
||
}, [answerState])
|
||
|
||
const restartPractice = (scope = progress.scope) => {
|
||
if (autoNextTimerRef.current) {
|
||
window.clearTimeout(autoNextTimerRef.current)
|
||
autoNextTimerRef.current = null
|
||
}
|
||
if (challengeRestartTimerRef.current) {
|
||
window.clearTimeout(challengeRestartTimerRef.current)
|
||
challengeRestartTimerRef.current = null
|
||
}
|
||
setProgress(createProgress(scope, questionBank))
|
||
setAnswerState(null)
|
||
setShowCelebration(false)
|
||
}
|
||
|
||
const switchScope = (scope: PracticeScope) => {
|
||
if (scope === progress.scope) return
|
||
restartPractice(scope)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!challengeMode || !answerState || answerState.isCorrect) return
|
||
|
||
if (challengeRestartTimerRef.current) {
|
||
window.clearTimeout(challengeRestartTimerRef.current)
|
||
}
|
||
|
||
challengeRestartTimerRef.current = window.setTimeout(() => {
|
||
restartPractice(progress.scope)
|
||
}, CHALLENGE_RESTART_DELAY)
|
||
|
||
return () => {
|
||
if (challengeRestartTimerRef.current) {
|
||
window.clearTimeout(challengeRestartTimerRef.current)
|
||
challengeRestartTimerRef.current = null
|
||
}
|
||
}
|
||
}, [answerState, challengeMode, progress.scope])
|
||
|
||
const advanceToNext = (isCorrect: boolean) => {
|
||
if (!currentQuestionId) return
|
||
|
||
if (autoNextTimerRef.current) {
|
||
window.clearTimeout(autoNextTimerRef.current)
|
||
autoNextTimerRef.current = null
|
||
}
|
||
if (challengeRestartTimerRef.current) {
|
||
window.clearTimeout(challengeRestartTimerRef.current)
|
||
challengeRestartTimerRef.current = null
|
||
}
|
||
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],
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleSelect = (domainId: string) => {
|
||
if (!currentQuestion || answerState) return
|
||
|
||
const isCorrect = domainId === currentQuestion.domainId
|
||
setAnswerState({
|
||
selectedDomainId: domainId,
|
||
correctDomainId: currentQuestion.domainId,
|
||
isCorrect,
|
||
})
|
||
setProgress((prev) => ({
|
||
...prev,
|
||
correctCount: prev.correctCount + (isCorrect ? 1 : 0),
|
||
wrongCount: prev.wrongCount + (isCorrect ? 0 : 1),
|
||
}))
|
||
}
|
||
|
||
const renderOptionButton = (domainId: string) => {
|
||
const domain = performanceDomainMap.get(domainId)
|
||
if (!domain) return null
|
||
|
||
const Icon = iconMap[domain.id as keyof typeof iconMap]
|
||
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
|
||
const getKindProgress = (kind: QuestionKind) => {
|
||
const ids = questionBank
|
||
.filter((question) => question.domainId === domain.id && question.kind === kind)
|
||
.map((question) => question.id)
|
||
const completed = ids.filter((id) => completedIdSet.has(id)).length
|
||
return {
|
||
completed,
|
||
total: ids.length,
|
||
percent: ids.length > 0 ? (completed / ids.length) * 100 : 0,
|
||
}
|
||
}
|
||
const expectedGoalProgress = getKindProgress('expectedGoal')
|
||
const keyPointProgress = getKindProgress('keyPoint')
|
||
const expectedGoalDone = expectedGoalProgress.total > 0 && expectedGoalProgress.completed >= expectedGoalProgress.total
|
||
const keyPointDone = keyPointProgress.total > 0 && keyPointProgress.completed >= keyPointProgress.total
|
||
const domainDone = expectedGoalDone && keyPointDone
|
||
const scopedDone = progress.scope === 'expectedGoal'
|
||
? expectedGoalDone
|
||
: progress.scope === 'keyPoint'
|
||
? keyPointDone
|
||
: domainDone
|
||
const shouldKeepDisabled = scopedDone && !shouldHighlightCorrect && !isWrongSelected
|
||
const optionDisabled = isAnswerShown || scopedDone
|
||
|
||
return (
|
||
<motion.button
|
||
key={domain.id}
|
||
type="button"
|
||
whileTap={!optionDisabled ? { scale: 0.98 } : undefined}
|
||
onClick={() => handleSelect(domain.id)}
|
||
disabled={optionDisabled}
|
||
className={clsx(
|
||
'relative flex h-16 items-center justify-center overflow-hidden rounded-xl border bg-white p-1 text-center shadow-sm transition-colors dark:bg-gray-800 sm:h-[112px] sm:flex-col sm:items-stretch sm:justify-start sm:gap-2 sm:p-3 sm:text-left',
|
||
'border-gray-100 dark:border-gray-700 focus:outline-none',
|
||
optionDisabled && 'cursor-default',
|
||
shouldKeepDisabled && 'bg-gray-50 opacity-55 dark:bg-gray-800/70',
|
||
shouldHighlightCorrect &&
|
||
'border-emerald-500 bg-emerald-100 text-emerald-900 shadow-[inset_0_0_0_3px_rgba(16,185,129,0.78),0_0_0_1px_rgba(16,185,129,0.55)] dark:border-emerald-400 dark:bg-emerald-900/50 dark:text-emerald-50 dark:shadow-[inset_0_0_0_3px_rgba(52,211,153,0.65),0_0_0_1px_rgba(52,211,153,0.45)] sm:bg-emerald-50 sm:shadow-[inset_0_0_0_2px_rgba(52,211,153,0.65)] sm:dark:bg-emerald-950/30 sm:dark:shadow-[inset_0_0_0_2px_rgba(16,185,129,0.45)]',
|
||
isWrongSelected &&
|
||
'border-rose-300 bg-rose-50 shadow-[inset_0_0_0_2px_rgba(251,113,133,0.65)] dark:border-rose-600 dark:bg-rose-950/30 dark:shadow-[inset_0_0_0_2px_rgba(244,63,94,0.45)]'
|
||
)}
|
||
>
|
||
<div className="hidden items-center gap-3 sm:flex">
|
||
<div
|
||
className={clsx(
|
||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-white',
|
||
shouldKeepDisabled && 'grayscale'
|
||
)}
|
||
style={{ backgroundColor: domain.color }}
|
||
>
|
||
<Icon size={18} />
|
||
</div>
|
||
<div className="min-w-0 flex-1 pr-8">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{domain.name}
|
||
</div>
|
||
<div className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
||
{domain.nameEn}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={clsx(
|
||
'text-2xl font-bold leading-none text-gray-900 dark:text-white sm:hidden',
|
||
shouldHighlightCorrect && 'text-emerald-800 dark:text-emerald-50',
|
||
isWrongSelected && 'text-rose-800 dark:text-rose-50'
|
||
)}>
|
||
{domain.name.charAt(0)}
|
||
</div>
|
||
|
||
<div className="hidden sm:block">
|
||
{(() => {
|
||
const activeProgress = progress.scope === 'expectedGoal' ? expectedGoalProgress : keyPointProgress
|
||
const activeLabel = progress.scope === 'expectedGoal' ? '目标' : '要点'
|
||
const isFull = activeProgress.total > 0 && activeProgress.completed >= activeProgress.total
|
||
|
||
if (progress.scope === 'all') {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{[
|
||
{ label: '目标', data: expectedGoalProgress },
|
||
{ label: '要点', data: keyPointProgress },
|
||
].map((item) => {
|
||
const itemFull = item.data.total > 0 && item.data.completed >= item.data.total
|
||
return (
|
||
<div key={item.label} className="min-w-0">
|
||
<div className="mb-1 flex items-center justify-between gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
<span>{item.label}</span>
|
||
<span>{item.data.completed}/{item.data.total}</span>
|
||
</div>
|
||
<div className="h-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||
<div
|
||
className={clsx(
|
||
'h-full rounded-full transition-all duration-300',
|
||
itemFull ? 'bg-gray-300 dark:bg-gray-500' : 'bg-indigo-500'
|
||
)}
|
||
style={{ width: `${item.data.percent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-1 flex items-center justify-between gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
<span>{activeLabel}</span>
|
||
<span>{activeProgress.completed}/{activeProgress.total}</span>
|
||
</div>
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||
<div
|
||
className={clsx(
|
||
'h-full rounded-full transition-all duration-300',
|
||
isFull ? 'bg-gray-300 dark:bg-gray-500' : 'bg-indigo-500'
|
||
)}
|
||
style={{ width: `${activeProgress.percent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
<div className="pointer-events-none absolute right-1 top-1 flex h-4 w-8 items-center justify-end sm:right-3 sm:top-3 sm:h-5 sm:w-12">
|
||
{isCorrectSelected && (
|
||
<CheckCircle2 className="text-emerald-600 dark:text-emerald-300" size={20} />
|
||
)}
|
||
{isWrongSelected && (
|
||
<XCircle className="text-rose-500" size={16} />
|
||
)}
|
||
{shouldHighlightCorrect && !isCorrectSelected && (
|
||
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 sm:px-2 sm:text-xs">
|
||
正确
|
||
</span>
|
||
)}
|
||
</div>
|
||
</motion.button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto max-w-6xl space-y-4">
|
||
{showCelebration && (
|
||
<CelebrationAnimation onComplete={() => setShowCelebration(false)} />
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<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>
|
||
<span>/</span>
|
||
<span className="text-gray-900 dark:text-white">练习</span>
|
||
</nav>
|
||
<div>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
aria-pressed={challengeMode}
|
||
onClick={() => setChallengeMode((value) => !value)}
|
||
className={clsx(
|
||
'rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
||
challengeMode
|
||
? 'border-indigo-500 bg-indigo-600 text-white'
|
||
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'
|
||
)}
|
||
>
|
||
挑战模式
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => restartPractice()}
|
||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
<RefreshCw size={16} />
|
||
重新开始
|
||
</button>
|
||
</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="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{scopeOptions.map((option) => {
|
||
const isActive = option.value === progress.scope
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
onClick={() => switchScope(option.value)}
|
||
className={clsx(
|
||
'rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
|
||
isActive
|
||
? 'bg-indigo-600 text-white'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-x-5 gap-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||
<span>已完成 <b className="text-gray-900 dark:text-white">{completedCount}/{progress.totalCount}</b></span>
|
||
<span>剩余 <b className="text-gray-900 dark:text-white">{remainingCount}</b></span>
|
||
<span>正确率 <b className="text-gray-900 dark:text-white">{accuracy}%</b></span>
|
||
<span>错误 <b className="text-gray-900 dark:text-white">{progress.wrongCount}</b></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||
<div
|
||
className="h-full rounded-full bg-indigo-600 transition-all duration-300"
|
||
style={{ width: `${progressPercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{isFinished ? (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
|
||
>
|
||
<div className="bg-indigo-600 px-6 py-7 text-white">
|
||
<div className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-sm">
|
||
<GraduationCap size={16} />
|
||
本轮完成
|
||
</div>
|
||
<h2 className="mt-4 text-2xl font-bold">八大绩效域练习已完成</h2>
|
||
<p className="mt-2 text-sm text-white/80">
|
||
共完成 {progress.totalCount} 题,错误 {progress.wrongCount} 次。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 px-6 py-6 md:grid-cols-3">
|
||
<div className="rounded-xl 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="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{progress.totalCount}</div>
|
||
</div>
|
||
<div className="rounded-xl 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="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{accuracy}%</div>
|
||
</div>
|
||
<div className="rounded-xl 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="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||
{scopeOptions.find((option) => option.value === progress.scope)?.label ?? '全部'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-3 px-6 pb-6">
|
||
<button
|
||
type="button"
|
||
onClick={() => restartPractice()}
|
||
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500"
|
||
>
|
||
<RefreshCw size={16} />
|
||
再来一轮
|
||
</button>
|
||
<Link
|
||
to="/performance-domains"
|
||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
返回绩效域
|
||
</Link>
|
||
</div>
|
||
</motion.div>
|
||
) : currentQuestion ? (
|
||
<div className="grid gap-4 xl:grid-cols-[1fr_1.45fr]">
|
||
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="rounded-full bg-indigo-50 px-3 py-1 text-2xl font-semibold leading-relaxed text-indigo-600 dark:bg-indigo-900/40 dark:text-indigo-300 md:text-3xl">
|
||
{kindLabelMap[currentQuestion.kind]}
|
||
</span>
|
||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||
{completedCount + 1} / {progress.totalCount}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex min-h-[180px] items-center py-6 md:min-h-[220px] xl:min-h-[300px]">
|
||
<p className="text-2xl font-semibold leading-relaxed text-gray-900 dark:text-white md:text-3xl">
|
||
{currentQuestion.text}
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
className={clsx(
|
||
'h-16 overflow-hidden rounded-xl border px-4 py-3 transition-colors sm:h-[132px]',
|
||
answerState?.isCorrect
|
||
? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30'
|
||
: answerState
|
||
? 'border-rose-200 bg-rose-50 dark:border-rose-800 dark:bg-rose-950/30'
|
||
: 'border-gray-100 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/30'
|
||
)}
|
||
>
|
||
{answerState && currentDomain ? (
|
||
<div className="flex h-full items-center justify-end sm:flex-col sm:items-stretch sm:justify-between sm:gap-3">
|
||
<div className="hidden sm:block">
|
||
<div
|
||
className={clsx(
|
||
'flex items-center gap-2 text-sm font-semibold',
|
||
answerState.isCorrect
|
||
? 'text-emerald-700 dark:text-emerald-300'
|
||
: 'text-rose-700 dark:text-rose-300'
|
||
)}
|
||
>
|
||
{answerState.isCorrect ? <CheckCircle2 size={17} /> : <XCircle size={17} />}
|
||
{answerState.isCorrect
|
||
? `正确:${currentDomain.name}`
|
||
: `错误:你选了 ${selectedDomain?.name ?? ''}`}
|
||
</div>
|
||
{!answerState.isCorrect && (
|
||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||
正确答案:{currentDomain.name}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => advanceToNext(answerState.isCorrect)}
|
||
className="self-end rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500"
|
||
>
|
||
下一个
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mb-28 grid grid-cols-4 gap-2 sm:mb-0 sm:grid-cols-2 sm:gap-3">
|
||
{performanceDomains.map((domain) => renderOptionButton(domain.id))}
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|