feat: add performance domain practice page
This commit is contained in:
@@ -13,6 +13,7 @@ import ProcessPracticePage from './pages/ProcessPracticePage'
|
||||
import ProcessPurposePracticePage from './pages/ProcessPurposePracticePage'
|
||||
import PrinciplesPage from './pages/PrinciplesPage'
|
||||
import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage'
|
||||
import PerformanceDomainPracticePage from './pages/PerformanceDomainPracticePage'
|
||||
import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage'
|
||||
import { LearningMapsPage } from './pages/LearningMapsPage'
|
||||
import { ApiDocPage } from './pages/ApiDocPage'
|
||||
@@ -34,6 +35,7 @@ function App() {
|
||||
<Route path="/process-purpose-practice" element={<ProcessPurposePracticePage />} />
|
||||
<Route path="/principles" element={<PrinciplesPage />} />
|
||||
<Route path="/performance-domains" element={<PerformanceDomainsPage />} />
|
||||
<Route path="/performance-domains/practice" element={<PerformanceDomainPracticePage />} />
|
||||
<Route path="/performance-domains/:id" element={<PerformanceDomainsPage />} />
|
||||
<Route path="/learning-maps" element={<LearningMapsPage />} />
|
||||
<Route path="/artifact/:id" element={<ArtifactDetailPage />} />
|
||||
|
||||
547
src/pages/PerformanceDomainPracticePage.tsx
Normal file
547
src/pages/PerformanceDomainPracticePage.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Link } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
GitBranch,
|
||||
GraduationCap,
|
||||
Handshake,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
Target,
|
||||
Users,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation'
|
||||
import { performanceDomains } from '@/data/performance-domains'
|
||||
|
||||
type QuestionKind = 'expectedGoal' | 'keyPoint'
|
||||
type PracticeScope = 'all' | QuestionKind
|
||||
|
||||
interface PracticeQuestion {
|
||||
id: string
|
||||
domainId: string
|
||||
domainName: string
|
||||
domainDescription: string
|
||||
kind: QuestionKind
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PracticeProgress {
|
||||
scope: PracticeScope
|
||||
order: string[]
|
||||
currentIndex: number
|
||||
correctCount: number
|
||||
wrongCount: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'performance-domain-practice-progress'
|
||||
|
||||
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
|
||||
|
||||
const desktopPositions = [
|
||||
'left-10 top-12',
|
||||
'left-1/2 top-0 -translate-x-1/2',
|
||||
'right-10 top-12',
|
||||
'right-0 top-1/2 -translate-y-[120%]',
|
||||
'right-10 bottom-12',
|
||||
'left-1/2 bottom-0 -translate-x-1/2',
|
||||
'left-10 bottom-12',
|
||||
'left-0 top-1/2 translate-y-[20%]',
|
||||
] 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,
|
||||
domainName: domain.name,
|
||||
domainDescription: domain.description,
|
||||
kind: 'expectedGoal' as const,
|
||||
text,
|
||||
}))
|
||||
|
||||
const keyPointQuestions = detail.keyPoints.map((text, index) => ({
|
||||
id: `${domain.id}-point-${index}`,
|
||||
domainId: domain.id,
|
||||
domainName: domain.name,
|
||||
domainDescription: domain.description,
|
||||
kind: 'keyPoint' as const,
|
||||
text,
|
||||
}))
|
||||
|
||||
return [...expectedGoalQuestions, ...keyPointQuestions]
|
||||
})
|
||||
}
|
||||
|
||||
function createProgress(
|
||||
scope: PracticeScope,
|
||||
questions: PracticeQuestion[]
|
||||
): PracticeProgress {
|
||||
const filteredQuestions = questions.filter((question) =>
|
||||
scope === 'all' ? true : question.kind === scope
|
||||
)
|
||||
|
||||
return {
|
||||
scope,
|
||||
order: shuffleArray(filteredQuestions.map((question) => question.id)),
|
||||
currentIndex: 0,
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredProgress(questions: PracticeQuestion[]): PracticeProgress {
|
||||
const questionMap = new Map(questions.map((question) => [question.id, question]))
|
||||
|
||||
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 === 'expectedGoal' || parsed.scope === 'keyPoint' || parsed.scope === 'all'
|
||||
? parsed.scope
|
||||
: 'all'
|
||||
|
||||
const filteredIds = questions
|
||||
.filter((question) => (scope === 'all' ? true : question.kind === scope))
|
||||
.map((question) => question.id)
|
||||
const validIdSet = new Set(filteredIds)
|
||||
const storedOrder = Array.isArray(parsed.order)
|
||||
? parsed.order.filter((id): id is string => validIdSet.has(String(id)) && questionMap.has(String(id)))
|
||||
: []
|
||||
const missingIds = filteredIds.filter((id) => !storedOrder.includes(id))
|
||||
const order = [...storedOrder, ...shuffleArray(missingIds)]
|
||||
|
||||
if (order.length === 0) return createProgress(scope, questions)
|
||||
|
||||
return {
|
||||
scope,
|
||||
order,
|
||||
currentIndex: Math.min(Math.max(Number(parsed.currentIndex) || 0, 0), order.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 [selectedDomainId, setSelectedDomainId] = useState<string | null>(null)
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
|
||||
const questions = useMemo(
|
||||
() =>
|
||||
progress.order
|
||||
.map((id) => questionMap.get(id))
|
||||
.filter((question): question is PracticeQuestion => Boolean(question)),
|
||||
[progress.order, questionMap]
|
||||
)
|
||||
|
||||
const currentQuestion = questions[progress.currentIndex] ?? null
|
||||
const currentDomain = currentQuestion
|
||||
? performanceDomains.find((domain) => domain.id === currentQuestion.domainId) ?? null
|
||||
: null
|
||||
const answeredCount = Math.min(progress.currentIndex, questions.length)
|
||||
const isFinished = progress.currentIndex >= questions.length
|
||||
const accuracy = answeredCount > 0
|
||||
? Math.round((progress.correctCount / answeredCount) * 100)
|
||||
: 0
|
||||
const answerState = currentQuestion && selectedDomainId
|
||||
? {
|
||||
isCorrect: selectedDomainId === currentQuestion.domainId,
|
||||
correctDomainId: currentQuestion.domainId,
|
||||
}
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress))
|
||||
} catch (error) {
|
||||
console.error('保存绩效域练习进度失败:', error)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
const restartPractice = (scope = progress.scope) => {
|
||||
setProgress(createProgress(scope, questionBank))
|
||||
setSelectedDomainId(null)
|
||||
setShowCelebration(false)
|
||||
}
|
||||
|
||||
const switchScope = (scope: PracticeScope) => {
|
||||
if (scope === progress.scope) return
|
||||
setProgress(createProgress(scope, questionBank))
|
||||
setSelectedDomainId(null)
|
||||
setShowCelebration(false)
|
||||
}
|
||||
|
||||
const handleSelect = (domainId: string) => {
|
||||
if (!currentQuestion || answerState) return
|
||||
|
||||
const isCorrect = domainId === currentQuestion.domainId
|
||||
setSelectedDomainId(domainId)
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
correctCount: prev.correctCount + (isCorrect ? 1 : 0),
|
||||
wrongCount: prev.wrongCount + (isCorrect ? 0 : 1),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (!answerState) return
|
||||
|
||||
setSelectedDomainId(null)
|
||||
setProgress((prev) => {
|
||||
const nextIndex = prev.currentIndex + 1
|
||||
if (nextIndex >= prev.order.length) {
|
||||
setShowCelebration(true)
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
currentIndex: nextIndex,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderOptionButton = (domainId: string, className?: string) => {
|
||||
const domain = performanceDomains.find((item) => item.id === domainId)
|
||||
if (!domain) return null
|
||||
|
||||
const Icon = iconMap[domain.id as keyof typeof iconMap]
|
||||
const isSelected = selectedDomainId === domain.id
|
||||
const isCorrect = answerState?.correctDomainId === domain.id
|
||||
const isWrongSelection = Boolean(answerState) && isSelected && !answerState?.isCorrect
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={domain.id}
|
||||
type="button"
|
||||
whileHover={!answerState ? { y: -2 } : undefined}
|
||||
whileTap={!answerState ? { scale: 0.98 } : undefined}
|
||||
onClick={() => handleSelect(domain.id)}
|
||||
disabled={Boolean(answerState)}
|
||||
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',
|
||||
'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
|
||||
answerState && 'cursor-default',
|
||||
isSelected && !answerState && 'border-indigo-400 ring-2 ring-indigo-100 dark:ring-indigo-900/50',
|
||||
isCorrect && 'border-emerald-300 bg-emerald-50 shadow-md dark:border-emerald-700 dark:bg-emerald-950/40',
|
||||
isWrongSelection && 'border-rose-300 bg-rose-50 dark:border-rose-700 dark:bg-rose-950/40',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
|
||||
style={{ backgroundColor: domain.color }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{domain.name}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{domain.nameEn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{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>
|
||||
|
||||
<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 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-white text-gray-600 shadow-sm ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-700 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<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-500 dark:text-gray-400">练习进度</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{Math.min(answeredCount + (answerState ? 1 : 0), questions.length)} / {questions.length}
|
||||
</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-500 dark:text-gray-400">当前正确率</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{accuracy}%</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-500 dark:text-gray-400">已答对</div>
|
||||
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{progress.correctCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFinished ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<div className="bg-gradient-to-r from-indigo-500 via-violet-500 to-cyan-500 px-6 py-8 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-3xl font-bold">八大绩效域练习已完成</h2>
|
||||
<p className="mt-2 text-sm text-white/80">
|
||||
本轮共答对 {progress.correctCount} 题,答错 {progress.wrongCount} 题。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
<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="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{questions.length > 0 ? Math.round((progress.correctCount / questions.length) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
<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="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="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">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentQuestion.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
>
|
||||
<div className="text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||||
{kindLabelMap[currentQuestion.kind]}
|
||||
</div>
|
||||
<p className="mt-4 text-xl font-semibold leading-9 text-gray-900 dark:text-white">
|
||||
{currentQuestion.text}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{performanceDomains.map((domain) => renderOptionButton(domain.id))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative mx-auto h-[36rem] 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" />
|
||||
<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" />
|
||||
<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" />
|
||||
|
||||
<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">
|
||||
<motion.div
|
||||
key={currentQuestion.id}
|
||||
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{kindLabelMap[currentQuestion.kind]}
|
||||
</div>
|
||||
<p className="mt-5 text-2xl font-semibold leading-10 text-gray-900 dark:text-white">
|
||||
{currentQuestion.text}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{performanceDomains.map((domain, index) => (
|
||||
<div
|
||||
key={domain.id}
|
||||
className={clsx(
|
||||
'absolute w-56',
|
||||
desktopPositions[index]
|
||||
)}
|
||||
>
|
||||
{renderOptionButton(domain.id)}
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowRight,
|
||||
GitBranch,
|
||||
GraduationCap,
|
||||
Handshake,
|
||||
Rocket,
|
||||
Target,
|
||||
@@ -70,6 +71,13 @@ export function PerformanceDomainsPage() {
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{selectedDomain.name}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{selectedDomain.nameEn}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/performance-domains/practice"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-white/70 transition-colors hover:bg-white dark:bg-gray-900/40 dark:text-gray-200 dark:ring-gray-700 dark:hover:bg-gray-900/70"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
练习
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -172,9 +180,18 @@ export function PerformanceDomainsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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">8大项目绩效域</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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">8大项目绩效域</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/performance-domains/practice"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-500"
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
开始练习
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
|
||||
Reference in New Issue
Block a user