Files
ittoview/src/pages/PerformanceDomainPracticePage.tsx
2026-05-16 12:19:01 +01:00

697 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}