|
|
|
|
| import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; |
| import { motion, AnimatePresence } from 'motion/react'; |
| import { |
| PieChart, |
| CheckCircle2, |
| XCircle, |
| RotateCcw, |
| ChevronRight, |
| Check, |
| BookOpenText, |
| Loader2, |
| Sparkles, |
| } from 'lucide-react'; |
| import { cn } from '@/lib/utils'; |
| import { useI18n } from '@/lib/hooks/use-i18n'; |
| import { getCurrentModelConfig } from '@/lib/utils/model-config'; |
| import { createLogger } from '@/lib/logger'; |
|
|
| const log = createLogger('QuizView'); |
| import type { QuizQuestion } from '@/lib/types/stage'; |
| import { useDraftCache } from '@/lib/hooks/use-draft-cache'; |
| import { SpeechButton } from '@/components/audio/speech-button'; |
| import { gradeChoiceQuestions, isShortAnswer, type QuestionResult } from '@/lib/quiz/grading'; |
| import { |
| clearSubmitted, |
| draftKey, |
| readSubmittedState, |
| writeSubmittedAnswers, |
| writeSubmittedResults, |
| type SubmittedState, |
| } from '@/lib/quiz/persistence'; |
|
|
| |
|
|
| type Phase = 'not_started' | 'answering' | 'grading' | 'reviewing'; |
|
|
| interface QuizViewProps { |
| readonly questions: QuizQuestion[]; |
| readonly sceneId: string; |
| } |
|
|
| |
| async function gradeShortAnswerQuestion( |
| q: QuizQuestion, |
| userAnswer: string, |
| language: string, |
| ): Promise<QuestionResult> { |
| const pts = q.points ?? 1; |
| try { |
| const modelConfig = getCurrentModelConfig(); |
| const headers: Record<string, string> = { |
| 'Content-Type': 'application/json', |
| 'x-model': modelConfig.modelString, |
| 'x-api-key': modelConfig.apiKey, |
| }; |
| if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl; |
| if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType; |
|
|
| const res = await fetch('/api/quiz-grade', { |
| method: 'POST', |
| headers, |
| body: JSON.stringify({ |
| question: q.question, |
| userAnswer, |
| points: pts, |
| commentPrompt: q.commentPrompt, |
| language, |
| }), |
| }); |
|
|
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const data = (await res.json()) as { score: number; comment: string }; |
| const earned = Math.max(0, Math.min(pts, data.score)); |
| return { |
| questionId: q.id, |
| correct: earned >= pts * 0.8, |
| status: earned >= pts * 0.8 ? 'correct' : 'incorrect', |
| earned, |
| aiComment: data.comment, |
| }; |
| } catch (err) { |
| log.error('[quiz-view] AI grading failed for', q.id, err); |
| |
| return { |
| questionId: q.id, |
| correct: null, |
| status: 'incorrect', |
| earned: Math.round(pts * 0.5), |
| aiComment: |
| language === 'zh-CN' |
| ? 'θ―εζε‘ζζΆδΈε―η¨οΌε·²η»δΊεΊη‘εγ' |
| : 'Grading service unavailable. Base score given.', |
| }; |
| } |
| } |
|
|
| |
|
|
| function QuizCover({ |
| questionCount, |
| totalPoints, |
| onStart, |
| }: { |
| questionCount: number; |
| totalPoints: number; |
| onStart: () => void; |
| }) { |
| const { t } = useI18n(); |
|
|
| return ( |
| <div className="w-full h-full flex flex-col items-center justify-center gap-4 relative overflow-hidden"> |
| {/* Background decoration */} |
| <div className="absolute top-0 right-0 p-6 opacity-[0.03]"> |
| <PieChart className="w-52 h-52 text-violet-500" /> |
| </div> |
| <div className="absolute bottom-0 left-0 p-6 opacity-[0.02]"> |
| <BookOpenText className="w-40 h-40 text-violet-500 rotate-12" /> |
| </div> |
| |
| <motion.div |
| initial={{ scale: 0.8, opacity: 0 }} |
| animate={{ scale: 1, opacity: 1 }} |
| transition={{ type: 'spring', stiffness: 200, damping: 20 }} |
| className="w-16 h-16 bg-gradient-to-br from-violet-100 to-purple-50 dark:from-violet-900/50 dark:to-purple-900/30 rounded-2xl flex items-center justify-center shadow-lg shadow-violet-100 dark:shadow-violet-900/30 ring-1 ring-violet-200/50 dark:ring-violet-700/50" |
| > |
| <PieChart className="w-8 h-8 text-violet-500" /> |
| </motion.div> |
| |
| <motion.div |
| initial={{ y: 10, opacity: 0 }} |
| animate={{ y: 0, opacity: 1 }} |
| transition={{ delay: 0.1 }} |
| className="text-center z-10" |
| > |
| <h3 className="text-xl font-bold text-gray-800 dark:text-gray-100">{t('quiz.title')}</h3> |
| <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{t('quiz.subtitle')}</p> |
| </motion.div> |
| |
| <motion.div |
| initial={{ y: 10, opacity: 0 }} |
| animate={{ y: 0, opacity: 1 }} |
| transition={{ delay: 0.2 }} |
| className="flex gap-5 text-sm z-10" |
| > |
| <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400"> |
| <div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center"> |
| <BookOpenText className="w-3.5 h-3.5 text-violet-500" /> |
| </div> |
| <span> |
| {questionCount} {t('quiz.questionsCount')} |
| </span> |
| </div> |
| <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400"> |
| <div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center"> |
| <PieChart className="w-3.5 h-3.5 text-violet-500" /> |
| </div> |
| <span> |
| {t('quiz.totalPrefix')} {totalPoints} {t('quiz.pointsSuffix')} |
| </span> |
| </div> |
| </motion.div> |
| |
| <motion.button |
| initial={{ y: 10, opacity: 0 }} |
| animate={{ y: 0, opacity: 1 }} |
| transition={{ delay: 0.3 }} |
| whileHover={{ scale: 1.05 }} |
| whileTap={{ scale: 0.95 }} |
| onClick={onStart} |
| className="mt-1 px-8 py-2.5 bg-gradient-to-r from-violet-500 to-purple-500 text-white rounded-full font-medium shadow-lg shadow-violet-200/50 dark:shadow-violet-900/50 hover:shadow-violet-300/50 transition-shadow z-10 flex items-center gap-2" |
| > |
| {t('quiz.startQuiz')} |
| <ChevronRight className="w-4 h-4" /> |
| </motion.button> |
| </div> |
| ); |
| } |
|
|
| function SingleChoiceQuestion({ |
| question, |
| index, |
| value, |
| onChange, |
| disabled, |
| result, |
| }: { |
| question: QuizQuestion; |
| index: number; |
| value?: string; |
| onChange: (value: string) => void; |
| disabled?: boolean; |
| result?: QuestionResult; |
| }) { |
| const isReview = !!result; |
|
|
| return ( |
| <QuestionCard question={question} index={index} result={result}> |
| <div className="grid gap-2"> |
| {question.options?.map((opt) => { |
| const selected = value === opt.value; |
| const isCorrectOpt = isReview && question.answer?.includes(opt.value); |
| const isWrong = isReview && selected && result?.status === 'incorrect'; |
| |
| return ( |
| <button |
| key={opt.value} |
| disabled={disabled} |
| onClick={() => !disabled && onChange(opt.value)} |
| className={cn( |
| 'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm', |
| // Default state |
| !isReview && |
| !selected && |
| 'border-gray-200 dark:border-gray-600 hover:border-violet-200 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/30', |
| !isReview && |
| selected && |
| 'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700', |
| // Review states |
| isReview && |
| isCorrectOpt && |
| 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30', |
| isReview && |
| isWrong && |
| !isCorrectOpt && |
| 'border-red-300 bg-red-50 dark:bg-red-900/30', |
| isReview && |
| !isCorrectOpt && |
| !selected && |
| 'border-gray-100 dark:border-gray-700 opacity-60', |
| disabled && !isReview && 'cursor-default', |
| )} |
| > |
| <span |
| className={cn( |
| 'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors', |
| !isReview && |
| !selected && |
| 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400', |
| !isReview && selected && 'bg-violet-500 text-white', |
| isReview && isCorrectOpt && 'bg-emerald-500 text-white', |
| isReview && isWrong && !isCorrectOpt && 'bg-red-400 text-white', |
| isReview && |
| !isCorrectOpt && |
| !selected && |
| 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500', |
| )} |
| > |
| {opt.value} |
| </span> |
| <span |
| className={cn( |
| 'flex-1', |
| isReview && !isCorrectOpt && !selected && 'text-gray-400 dark:text-gray-500', |
| )} |
| > |
| {opt.label} |
| </span> |
| {isReview && isCorrectOpt && ( |
| <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" /> |
| )} |
| {isReview && isWrong && !isCorrectOpt && ( |
| <XCircle className="w-5 h-5 text-red-400 shrink-0" /> |
| )} |
| </button> |
| ); |
| })} |
| </div> |
| </QuestionCard> |
| ); |
| } |
|
|
| function MultipleChoiceQuestion({ |
| question, |
| index, |
| value, |
| onChange, |
| disabled, |
| result, |
| }: { |
| question: QuizQuestion; |
| index: number; |
| value?: string[]; |
| onChange: (value: string[]) => void; |
| disabled?: boolean; |
| result?: QuestionResult; |
| }) { |
| const isReview = !!result; |
| const selected = value ?? []; |
|
|
| const toggle = (optValue: string) => { |
| if (disabled) return; |
| if (selected.includes(optValue)) { |
| onChange(selected.filter((v) => v !== optValue)); |
| } else { |
| onChange([...selected, optValue]); |
| } |
| }; |
|
|
| const { t } = useI18n(); |
|
|
| return ( |
| <QuestionCard question={question} index={index} result={result}> |
| {!isReview && ( |
| <p className="text-xs text-gray-400 dark:text-gray-500 mb-2"> |
| {t('quiz.multipleChoiceHint')} |
| </p> |
| )} |
| <div className="grid gap-2"> |
| {question.options?.map((opt) => { |
| const isSelected = selected.includes(opt.value); |
| const isCorrectOpt = isReview && question.answer?.includes(opt.value); |
| const isWrong = isReview && isSelected && !isCorrectOpt; |
| |
| return ( |
| <button |
| key={opt.value} |
| disabled={disabled} |
| onClick={() => toggle(opt.value)} |
| className={cn( |
| 'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm', |
| !isReview && |
| !isSelected && |
| 'border-gray-200 dark:border-gray-600 hover:border-violet-200 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/30', |
| !isReview && |
| isSelected && |
| 'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700', |
| isReview && |
| isCorrectOpt && |
| 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30', |
| isReview && isWrong && 'border-red-300 bg-red-50 dark:bg-red-900/30', |
| isReview && |
| !isCorrectOpt && |
| !isSelected && |
| 'border-gray-100 dark:border-gray-700 opacity-60', |
| disabled && !isReview && 'cursor-default', |
| )} |
| > |
| <span |
| className={cn( |
| 'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 transition-colors', |
| !isReview && |
| !isSelected && |
| 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400', |
| !isReview && isSelected && 'bg-violet-500 text-white', |
| isReview && isCorrectOpt && 'bg-emerald-500 text-white', |
| isReview && isWrong && 'bg-red-400 text-white', |
| isReview && |
| !isCorrectOpt && |
| !isSelected && |
| 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500', |
| )} |
| > |
| {!isReview && isSelected ? <Check className="w-3.5 h-3.5" /> : opt.value} |
| </span> |
| <span |
| className={cn( |
| 'flex-1', |
| isReview && !isCorrectOpt && !isSelected && 'text-gray-400 dark:text-gray-500', |
| )} |
| > |
| {opt.label} |
| </span> |
| {isReview && isCorrectOpt && ( |
| <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" /> |
| )} |
| {isReview && isWrong && <XCircle className="w-5 h-5 text-red-400 shrink-0" />} |
| </button> |
| ); |
| })} |
| </div> |
| </QuestionCard> |
| ); |
| } |
|
|
| function ShortAnswerQuestion({ |
| question, |
| index, |
| value, |
| onChange, |
| disabled, |
| result, |
| }: { |
| question: QuizQuestion; |
| index: number; |
| value?: string; |
| onChange: (value: string) => void; |
| disabled?: boolean; |
| result?: QuestionResult; |
| }) { |
| const isReview = !!result; |
| const { t } = useI18n(); |
| |
| const valueRef = useRef(value); |
| useEffect(() => { |
| valueRef.current = value; |
| }, [value]); |
|
|
| return ( |
| <QuestionCard question={question} index={index} result={result}> |
| {!isReview ? ( |
| <div className="relative"> |
| <textarea |
| value={value ?? ''} |
| onChange={(e) => onChange(e.target.value)} |
| disabled={disabled} |
| placeholder={t('quiz.inputPlaceholder')} |
| className="w-full min-h-[100px] p-3 pb-10 rounded-xl border border-gray-200 dark:border-gray-600 text-sm resize-none focus:outline-none focus:border-violet-300 dark:focus:border-violet-600 focus:ring-2 focus:ring-violet-100 dark:focus:ring-violet-900/50 transition-all disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:text-gray-500 dark:bg-gray-800/50 dark:text-gray-200 dark:placeholder:text-gray-500" |
| /> |
| <SpeechButton |
| size="sm" |
| disabled={disabled} |
| className="absolute bottom-3 left-3" |
| onTranscription={(text) => { |
| const cur = valueRef.current ?? ''; |
| onChange(cur + (cur ? ' ' : '') + text); |
| }} |
| /> |
| <span className="absolute bottom-3 right-3 text-xs text-gray-300 dark:text-gray-600"> |
| {(value ?? '').length} {t('quiz.charCount')} |
| </span> |
| </div> |
| ) : ( |
| <div className="space-y-3"> |
| <div className="p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-300"> |
| <p className="text-xs text-gray-400 dark:text-gray-500 mb-1">{t('quiz.yourAnswer')}</p> |
| {value || ( |
| <span className="text-gray-400 dark:text-gray-500 italic"> |
| {t('quiz.notAnswered')} |
| </span> |
| )} |
| </div> |
| {result.aiComment && ( |
| <div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-violet-50 dark:bg-violet-900/30 border border-violet-100 dark:border-violet-800"> |
| <Sparkles className="w-4 h-4 text-violet-500 shrink-0 mt-0.5" /> |
| <div> |
| <p className="text-xs font-medium text-violet-600 dark:text-violet-400 mb-0.5"> |
| {t('quiz.aiComment')} |
| </p> |
| <p className="text-xs text-violet-600/80 dark:text-violet-400/80"> |
| {result.aiComment} |
| </p> |
| </div> |
| <span className="ml-auto text-xs font-bold text-violet-600 dark:text-violet-400 shrink-0"> |
| {result.earned}/{question.points ?? 1} |
| {t('quiz.pointsSuffix')} |
| </span> |
| </div> |
| )} |
| </div> |
| )} |
| </QuestionCard> |
| ); |
| } |
|
|
| function QuestionCard({ |
| question, |
| index, |
| result, |
| children, |
| }: { |
| question: QuizQuestion; |
| index: number; |
| result?: QuestionResult; |
| children: React.ReactNode; |
| }) { |
| const { t } = useI18n(); |
| const isReview = !!result; |
| const pts = question.points ?? 1; |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 12 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ delay: index * 0.05 }} |
| className={cn( |
| 'bg-white dark:bg-gray-800 rounded-2xl border p-5 relative overflow-hidden', |
| !isReview && 'border-gray-150 dark:border-gray-700 shadow-sm', |
| isReview && |
| result.status === 'correct' && |
| 'border-emerald-200 dark:border-emerald-800 shadow-sm shadow-emerald-50 dark:shadow-emerald-900/20', |
| isReview && |
| result.status === 'incorrect' && |
| 'border-red-200 dark:border-red-800 shadow-sm shadow-red-50 dark:shadow-red-900/20', |
| )} |
| > |
| {/* Left accent */} |
| <div |
| className={cn( |
| 'absolute left-0 top-0 bottom-0 w-1 rounded-l-2xl', |
| !isReview && 'bg-violet-400', |
| isReview && result.status === 'correct' && 'bg-emerald-400', |
| isReview && result.status === 'incorrect' && 'bg-red-400', |
| )} |
| /> |
| |
| {/* Header */} |
| <div className="flex items-start justify-between mb-3"> |
| <div className="flex items-start gap-3"> |
| <span |
| className={cn( |
| 'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0', |
| !isReview && |
| 'bg-violet-100 dark:bg-violet-900/50 text-violet-600 dark:text-violet-400', |
| isReview && |
| result.status === 'correct' && |
| 'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400', |
| isReview && |
| result.status === 'incorrect' && |
| 'bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400', |
| )} |
| > |
| {index + 1} |
| </span> |
| <div> |
| <p className="text-sm font-medium text-gray-800 dark:text-gray-100 leading-relaxed"> |
| {question.question} |
| </p> |
| <p className="text-xs text-gray-400 mt-0.5"> |
| {question.type === 'single' |
| ? t('quiz.singleChoice') |
| : question.type === 'multiple' |
| ? t('quiz.multipleChoice') |
| : t('quiz.shortAnswer')} |
| {' Β· '} |
| {pts} {t('quiz.pointsSuffix')} |
| </p> |
| </div> |
| </div> |
| {isReview && ( |
| <div className="shrink-0 ml-2"> |
| {result.status === 'correct' && <CheckCircle2 className="w-6 h-6 text-emerald-500" />} |
| {result.status === 'incorrect' && <XCircle className="w-6 h-6 text-red-400" />} |
| </div> |
| )} |
| </div> |
| |
| {/* Body */} |
| {children} |
| |
| {/* Analysis (review only) */} |
| {isReview && question.analysis && ( |
| <div className="mt-3 p-3 rounded-lg bg-blue-50/70 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800 text-xs text-blue-700 dark:text-blue-300 leading-relaxed"> |
| <span className="font-medium">{t('quiz.analysis')}</span> |
| {question.analysis} |
| </div> |
| )} |
| </motion.div> |
| ); |
| } |
|
|
| function ScoreBanner({ |
| score, |
| total, |
| results, |
| }: { |
| score: number; |
| total: number; |
| results: QuestionResult[]; |
| }) { |
| const { t } = useI18n(); |
| const pct = total > 0 ? Math.round((score / total) * 100) : 0; |
| const correctCount = results.filter((r) => r.status === 'correct').length; |
| const incorrectCount = results.filter((r) => r.status === 'incorrect').length; |
|
|
| const color = pct >= 80 ? 'emerald' : pct >= 60 ? 'amber' : 'red'; |
| const colorMap = { |
| emerald: { |
| bg: 'from-emerald-500 to-teal-500', |
| shadow: 'shadow-emerald-200/50 dark:shadow-emerald-900/50', |
| ring: 'bg-emerald-400/30', |
| text: t('quiz.excellent'), |
| }, |
| amber: { |
| bg: 'from-amber-500 to-yellow-500', |
| shadow: 'shadow-amber-200/50 dark:shadow-amber-900/50', |
| ring: 'bg-amber-400/30', |
| text: t('quiz.keepGoing'), |
| }, |
| red: { |
| bg: 'from-red-500 to-rose-500', |
| shadow: 'shadow-red-200/50 dark:shadow-red-900/50', |
| ring: 'bg-red-400/30', |
| text: t('quiz.needsReview'), |
| }, |
| }; |
| const c = colorMap[color]; |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, scale: 0.95 }} |
| animate={{ opacity: 1, scale: 1 }} |
| className={cn('rounded-2xl p-6 bg-gradient-to-r text-white shadow-lg', c.bg, c.shadow)} |
| > |
| <div className="flex items-center justify-between"> |
| <div> |
| <p className="text-white/80 text-sm font-medium">{c.text}</p> |
| <div className="flex items-baseline gap-1 mt-1"> |
| <span className="text-4xl font-black">{score}</span> |
| <span className="text-white/60 text-lg">/ {total}</span> |
| </div> |
| <div className="flex gap-3 mt-3 text-xs"> |
| <span className="flex items-center gap-1"> |
| <CheckCircle2 className="w-3.5 h-3.5" /> {correctCount} {t('quiz.correct')} |
| </span> |
| <span className="flex items-center gap-1"> |
| <XCircle className="w-3.5 h-3.5" /> {incorrectCount} {t('quiz.incorrect')} |
| </span> |
| </div> |
| </div> |
| |
| {/* Percentage ring */} |
| <div className="relative w-20 h-20"> |
| <svg className="w-20 h-20 -rotate-90" viewBox="0 0 80 80"> |
| <circle |
| cx="40" |
| cy="40" |
| r="34" |
| fill="none" |
| stroke="rgba(255,255,255,0.2)" |
| strokeWidth="6" |
| /> |
| <motion.circle |
| cx="40" |
| cy="40" |
| r="34" |
| fill="none" |
| stroke="white" |
| strokeWidth="6" |
| strokeLinecap="round" |
| strokeDasharray={`${2 * Math.PI * 34}`} |
| initial={{ strokeDashoffset: 2 * Math.PI * 34 }} |
| animate={{ strokeDashoffset: 2 * Math.PI * 34 * (1 - pct / 100) }} |
| transition={{ duration: 1, ease: 'easeOut', delay: 0.3 }} |
| /> |
| </svg> |
| <div className="absolute inset-0 flex items-center justify-center"> |
| <span className="text-lg font-black">{pct}%</span> |
| </div> |
| </div> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| |
|
|
| export function QuizView({ questions, sceneId }: QuizViewProps) { |
| const { t, locale } = useI18n(); |
|
|
| |
| const [initialSubmitted] = useState<SubmittedState>(() => readSubmittedState(sceneId)); |
|
|
| const [phase, setPhase] = useState<Phase>(() => { |
| if (initialSubmitted?.kind === 'reviewing') return 'reviewing'; |
| if (initialSubmitted?.kind === 'answering') return 'answering'; |
| return 'not_started'; |
| }); |
| const [answers, setAnswers] = useState<Record<string, string | string[]>>( |
| () => initialSubmitted?.answers ?? {}, |
| ); |
| const [results, setResults] = useState<QuestionResult[]>(() => |
| initialSubmitted?.kind === 'reviewing' ? initialSubmitted.results : [], |
| ); |
|
|
| |
| const { |
| cachedValue: cachedAnswers, |
| updateCache: updateAnswersCache, |
| clearCache: clearAnswersCache, |
| } = useDraftCache<Record<string, string | string[]>>({ |
| key: draftKey(sceneId), |
| }); |
|
|
| |
| const [prevCachedAnswers, setPrevCachedAnswers] = useState(cachedAnswers); |
| if (cachedAnswers !== prevCachedAnswers) { |
| setPrevCachedAnswers(cachedAnswers); |
| if ( |
| !initialSubmitted && |
| cachedAnswers && |
| Object.keys(cachedAnswers).length > 0 && |
| phase === 'not_started' |
| ) { |
| setAnswers(cachedAnswers); |
| setPhase('answering'); |
| } |
| } |
|
|
| const totalPoints = useMemo( |
| () => questions.reduce((sum, q) => sum + (q.points ?? 1), 0), |
| [questions], |
| ); |
|
|
| const allAnswered = useMemo(() => { |
| return questions.every((q) => { |
| const a = answers[q.id]; |
| if (!a) return false; |
| if (Array.isArray(a)) return a.length > 0; |
| return (a as string).trim().length > 0; |
| }); |
| }, [questions, answers]); |
|
|
| const handleSetAnswer = useCallback( |
| (questionId: string, value: string | string[]) => { |
| setAnswers((prev) => { |
| const next = { ...prev, [questionId]: value }; |
| updateAnswersCache(next); |
| return next; |
| }); |
| }, |
| [updateAnswersCache], |
| ); |
|
|
| const handleSubmit = useCallback(() => { |
| setPhase('grading'); |
| clearAnswersCache(); |
| writeSubmittedAnswers(sceneId, answers); |
| }, [clearAnswersCache, answers, sceneId]); |
|
|
| |
| useEffect(() => { |
| if (phase !== 'grading') return; |
| let cancelled = false; |
|
|
| (async () => { |
| |
| const choiceResults = gradeChoiceQuestions(questions, answers); |
|
|
| |
| const shortAnswerQs = questions.filter(isShortAnswer); |
| const aiResults = await Promise.all( |
| shortAnswerQs.map((q) => |
| gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale), |
| ), |
| ); |
|
|
| if (cancelled) return; |
|
|
| |
| const allResultsMap = new Map<string, QuestionResult>(); |
| for (const r of [...choiceResults, ...aiResults]) { |
| allResultsMap.set(r.questionId, r); |
| } |
| const ordered = questions.map((q) => allResultsMap.get(q.id)!).filter(Boolean); |
|
|
| setResults(ordered); |
| setPhase('reviewing'); |
| writeSubmittedResults(sceneId, ordered); |
| })(); |
|
|
| return () => { |
| cancelled = true; |
| }; |
| }, [phase, questions, answers, locale, sceneId]); |
|
|
| const handleRetry = useCallback(() => { |
| setPhase('not_started'); |
| setAnswers({}); |
| setResults([]); |
| clearAnswersCache(); |
| clearSubmitted(sceneId); |
| }, [clearAnswersCache, sceneId]); |
|
|
| const earnedScore = useMemo(() => results.reduce((sum, r) => sum + r.earned, 0), [results]); |
|
|
| const resultMap = useMemo(() => { |
| const map: Record<string, QuestionResult> = {}; |
| results.forEach((r) => { |
| map[r.questionId] = r; |
| }); |
| return map; |
| }, [results]); |
|
|
| return ( |
| <div className="w-full h-full bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-900 overflow-hidden flex flex-col"> |
| <AnimatePresence mode="wait"> |
| {phase === 'not_started' && ( |
| <motion.div |
| key="cover" |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0, x: -20 }} |
| className="flex-1" |
| > |
| <QuizCover |
| questionCount={questions.length} |
| totalPoints={totalPoints} |
| onStart={() => setPhase('answering')} |
| /> |
| </motion.div> |
| )} |
| |
| {phase === 'answering' && ( |
| <motion.div |
| key="answering" |
| initial={{ opacity: 0, x: 20 }} |
| animate={{ opacity: 1, x: 0 }} |
| exit={{ opacity: 0, x: -20 }} |
| className="flex-1 flex flex-col min-h-0" |
| > |
| {/* Header bar */} |
| <div className="flex items-center justify-between px-6 py-3 border-b border-gray-100 dark:border-gray-700 bg-white/80 dark:bg-gray-900/80 backdrop-blur shrink-0"> |
| <div className="flex items-center gap-2"> |
| <PieChart className="w-4 h-4 text-violet-500" /> |
| <span className="text-sm font-semibold text-gray-700 dark:text-gray-200"> |
| {t('quiz.answering')} |
| </span> |
| <span className="text-xs text-gray-400 ml-1"> |
| { |
| Object.keys(answers).filter((k) => { |
| const a = answers[k]; |
| if (Array.isArray(a)) return a.length > 0; |
| return typeof a === 'string' && a.trim().length > 0; |
| }).length |
| }{' '} |
| / {questions.length} |
| </span> |
| </div> |
| <button |
| onClick={handleSubmit} |
| disabled={!allAnswered} |
| className={cn( |
| 'px-4 py-1.5 rounded-lg text-xs font-medium transition-all', |
| allAnswered |
| ? 'bg-gradient-to-r from-violet-500 to-purple-500 text-white shadow-sm hover:shadow-md hover:shadow-violet-200/50 dark:hover:shadow-violet-900/50 active:scale-[0.97]' |
| : 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed', |
| )} |
| > |
| {t('quiz.submitAnswers')} |
| </button> |
| </div> |
| |
| {/* Questions */} |
| <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4"> |
| {questions.map((q, i) => { |
| if (q.type === 'single') { |
| return ( |
| <SingleChoiceQuestion |
| key={q.id} |
| question={q} |
| index={i} |
| value={answers[q.id] as string | undefined} |
| onChange={(v) => handleSetAnswer(q.id, v)} |
| /> |
| ); |
| } |
| if (q.type === 'multiple') { |
| return ( |
| <MultipleChoiceQuestion |
| key={q.id} |
| question={q} |
| index={i} |
| value={answers[q.id] as string[] | undefined} |
| onChange={(v) => handleSetAnswer(q.id, v)} |
| /> |
| ); |
| } |
| return ( |
| <ShortAnswerQuestion |
| key={q.id} |
| question={q} |
| index={i} |
| value={answers[q.id] as string | undefined} |
| onChange={(v) => handleSetAnswer(q.id, v)} |
| /> |
| ); |
| })} |
| </div> |
| </motion.div> |
| )} |
| |
| {phase === 'grading' && ( |
| <motion.div |
| key="grading" |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| className="flex-1 flex flex-col items-center justify-center gap-5" |
| > |
| <motion.div |
| animate={{ rotate: 360 }} |
| transition={{ repeat: Infinity, duration: 1.5, ease: 'linear' }} |
| > |
| <Loader2 className="w-10 h-10 text-violet-500" /> |
| </motion.div> |
| <div className="text-center"> |
| <p className="text-base font-semibold text-gray-700 dark:text-gray-200"> |
| {t('quiz.aiGrading')} |
| </p> |
| <p className="text-sm text-gray-400 mt-1">{t('quiz.aiGradingWait')}</p> |
| </div> |
| <div className="flex gap-1 mt-2"> |
| {[0, 1, 2].map((i) => ( |
| <motion.div |
| key={i} |
| className="w-2 h-2 rounded-full bg-violet-400" |
| animate={{ opacity: [0.3, 1, 0.3] }} |
| transition={{ |
| repeat: Infinity, |
| duration: 1.2, |
| delay: i * 0.2, |
| }} |
| /> |
| ))} |
| </div> |
| </motion.div> |
| )} |
| |
| {phase === 'reviewing' && ( |
| <motion.div |
| key="reviewing" |
| initial={{ opacity: 0, x: 20 }} |
| animate={{ opacity: 1, x: 0 }} |
| className="flex-1 flex flex-col min-h-0" |
| > |
| {/* Header bar */} |
| <div className="flex items-center justify-between px-6 py-3 border-b border-gray-100 dark:border-gray-700 bg-white/80 dark:bg-gray-900/80 backdrop-blur shrink-0"> |
| <div className="flex items-center gap-2"> |
| <CheckCircle2 className="w-4 h-4 text-emerald-500" /> |
| <span className="text-sm font-semibold text-gray-700 dark:text-gray-200"> |
| {t('quiz.quizReport')} |
| </span> |
| </div> |
| <button |
| onClick={handleRetry} |
| className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 transition-colors" |
| > |
| <RotateCcw className="w-3.5 h-3.5" /> |
| {t('quiz.retry')} |
| </button> |
| </div> |
| |
| {/* Results */} |
| <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4"> |
| <ScoreBanner score={earnedScore} total={totalPoints} results={results} /> |
| |
| {questions.map((q, i) => { |
| const r = resultMap[q.id]; |
| if (q.type === 'single') { |
| return ( |
| <SingleChoiceQuestion |
| key={q.id} |
| question={q} |
| index={i} |
| value={answers[q.id] as string | undefined} |
| onChange={() => {}} |
| disabled |
| result={r} |
| /> |
| ); |
| } |
| if (q.type === 'multiple') { |
| return ( |
| <MultipleChoiceQuestion |
| key={q.id} |
| question={q} |
| index={i} |
| value={answers[q.id] as string[] | undefined} |
| onChange={() => {}} |
| disabled |
| result={r} |
| /> |
| ); |
| } |
| return ( |
| <ShortAnswerQuestion |
| key={q.id} |
| question={q} |
| index={i} |
| value={answers[q.id] as string | undefined} |
| onChange={() => {}} |
| disabled |
| result={r} |
| /> |
| ); |
| })} |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| ); |
| } |
|
|