'use client'; 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'; // ─── Types ────────────────────────────────────────────────────────────────── type Phase = 'not_started' | 'answering' | 'grading' | 'reviewing'; interface QuestionResult { questionId: string; correct: boolean | null; status: 'correct' | 'incorrect'; earned: number; aiComment?: string; } interface QuizViewProps { readonly questions: QuizQuestion[]; readonly sceneId: string; } // ─── Helpers ──────────────────────────────────────────────────────────────── function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; const sa = [...a].sort(); const sb = [...b].sort(); return sa.every((v, i) => v === sb[i]); } function toArray(v: string | string[] | undefined): string[] { if (!v) return []; return Array.isArray(v) ? v : [v]; } function isShortAnswer(q: QuizQuestion): boolean { return q.type === 'short_answer' || (!q.hasAnswer && (!q.answer || q.answer.length === 0)); } /** Grade choice questions locally. Returns results only for non-short-answer questions. */ function gradeChoiceQuestions( questions: QuizQuestion[], answers: Record, ): QuestionResult[] { return questions .filter((q) => !isShortAnswer(q)) .map((q) => { const pts = q.points ?? 1; const userAnswer = toArray(answers[q.id]); const correctAnswer = toArray(q.answer); const correct = arraysEqual(userAnswer, correctAnswer); return { questionId: q.id, correct, status: correct ? ('correct' as const) : ('incorrect' as const), earned: correct ? pts : 0, }; }); } /** 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 ? (