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'; // ─── Types ────────────────────────────────────────────────────────────────── type Phase = 'not_started' | 'answering' | 'grading' | 'reviewing'; interface QuizViewProps { readonly questions: QuizQuestion[]; readonly sceneId: string; } /** Call /api/quiz-grade for a single short-answer question. */ async function gradeShortAnswerQuestion( q: QuizQuestion, userAnswer: string, language: string, ): Promise { const pts = q.points ?? 1; try { const modelConfig = getCurrentModelConfig(); const headers: Record = { '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); // Fallback: give half credit return { questionId: q.id, correct: null, status: 'incorrect', earned: Math.round(pts * 0.5), aiComment: language === 'zh-CN' ? '评分服务暂时不可用,已给予基础分。' : 'Grading service unavailable. Base score given.', }; } } // ─── Sub-components ───────────────────────────────────────────────────────── function QuizCover({ questionCount, totalPoints, onStart, }: { questionCount: number; totalPoints: number; onStart: () => void; }) { const { t } = useI18n(); return (
{/* Background decoration */}

{t('quiz.title')}

{t('quiz.subtitle')}

{questionCount} {t('quiz.questionsCount')}
{t('quiz.totalPrefix')} {totalPoints} {t('quiz.pointsSuffix')}
{t('quiz.startQuiz')}
); } 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 (
{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 ( ); })}
); } 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 ( {!isReview && (

{t('quiz.multipleChoiceHint')}

)}
{question.options?.map((opt) => { const isSelected = selected.includes(opt.value); const isCorrectOpt = isReview && question.answer?.includes(opt.value); const isWrong = isReview && isSelected && !isCorrectOpt; return ( ); })}
); } 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(); // Ref to track latest value for voice transcription append const valueRef = useRef(value); useEffect(() => { valueRef.current = value; }, [value]); return ( {!isReview ? (