| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004 |
- '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<string, string | string[]>,
- ): 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<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);
- // 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 (
- <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();
- // Ref to track latest value for voice transcription append
- 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>
- );
- }
- // ─── Main Component ─────────────────────────────────────────────────────────
- export function QuizView({ questions, sceneId }: QuizViewProps) {
- const { t, locale } = useI18n();
- const [phase, setPhase] = useState<Phase>('not_started');
- const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
- const [results, setResults] = useState<QuestionResult[]>([]);
- // Draft cache for quiz answers, keyed by sceneId to isolate across classrooms
- const {
- cachedValue: cachedAnswers,
- updateCache: updateAnswersCache,
- clearCache: clearAnswersCache,
- } = useDraftCache<Record<string, string | string[]>>({
- key: `quizDraft:${sceneId}`,
- });
- // Restore cached answers during render (derived state pattern)
- const [prevCachedAnswers, setPrevCachedAnswers] = useState(cachedAnswers);
- if (cachedAnswers !== prevCachedAnswers) {
- setPrevCachedAnswers(cachedAnswers);
- if (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();
- }, [clearAnswersCache]);
- // When entering grading phase, grade choice questions locally + call API for short-answer
- useEffect(() => {
- if (phase !== 'grading') return;
- let cancelled = false;
- (async () => {
- // 1. Grade choice questions locally (instant)
- const choiceResults = gradeChoiceQuestions(questions, answers);
- // 2. Grade short-answer questions via AI API (parallel)
- const shortAnswerQs = questions.filter(isShortAnswer);
- const aiResults = await Promise.all(
- shortAnswerQs.map((q) =>
- gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale),
- ),
- );
- if (cancelled) return;
- // 3. Merge results in original question order
- 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');
- })();
- return () => {
- cancelled = true;
- };
- }, [phase, questions, answers, locale]);
- const handleRetry = useCallback(() => {
- setPhase('not_started');
- setAnswers({});
- setResults([]);
- clearAnswersCache();
- }, [clearAnswersCache]);
- 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>
- );
- }
|