quiz-view.tsx 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  1. 'use client';
  2. import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import {
  5. PieChart,
  6. CheckCircle2,
  7. XCircle,
  8. RotateCcw,
  9. ChevronRight,
  10. Check,
  11. BookOpenText,
  12. Loader2,
  13. Sparkles,
  14. } from 'lucide-react';
  15. import { cn } from '@/lib/utils';
  16. import { useI18n } from '@/lib/hooks/use-i18n';
  17. import { getCurrentModelConfig } from '@/lib/utils/model-config';
  18. import { createLogger } from '@/lib/logger';
  19. const log = createLogger('QuizView');
  20. import type { QuizQuestion } from '@/lib/types/stage';
  21. import { useDraftCache } from '@/lib/hooks/use-draft-cache';
  22. import { SpeechButton } from '@/components/audio/speech-button';
  23. // ─── Types ──────────────────────────────────────────────────────────────────
  24. type Phase = 'not_started' | 'answering' | 'grading' | 'reviewing';
  25. interface QuestionResult {
  26. questionId: string;
  27. correct: boolean | null;
  28. status: 'correct' | 'incorrect';
  29. earned: number;
  30. aiComment?: string;
  31. }
  32. interface QuizViewProps {
  33. readonly questions: QuizQuestion[];
  34. readonly sceneId: string;
  35. }
  36. // ─── Helpers ────────────────────────────────────────────────────────────────
  37. function arraysEqual(a: string[], b: string[]): boolean {
  38. if (a.length !== b.length) return false;
  39. const sa = [...a].sort();
  40. const sb = [...b].sort();
  41. return sa.every((v, i) => v === sb[i]);
  42. }
  43. function toArray(v: string | string[] | undefined): string[] {
  44. if (!v) return [];
  45. return Array.isArray(v) ? v : [v];
  46. }
  47. function isShortAnswer(q: QuizQuestion): boolean {
  48. return q.type === 'short_answer' || (!q.hasAnswer && (!q.answer || q.answer.length === 0));
  49. }
  50. /** Grade choice questions locally. Returns results only for non-short-answer questions. */
  51. function gradeChoiceQuestions(
  52. questions: QuizQuestion[],
  53. answers: Record<string, string | string[]>,
  54. ): QuestionResult[] {
  55. return questions
  56. .filter((q) => !isShortAnswer(q))
  57. .map((q) => {
  58. const pts = q.points ?? 1;
  59. const userAnswer = toArray(answers[q.id]);
  60. const correctAnswer = toArray(q.answer);
  61. const correct = arraysEqual(userAnswer, correctAnswer);
  62. return {
  63. questionId: q.id,
  64. correct,
  65. status: correct ? ('correct' as const) : ('incorrect' as const),
  66. earned: correct ? pts : 0,
  67. };
  68. });
  69. }
  70. /** Call /api/quiz-grade for a single short-answer question. */
  71. async function gradeShortAnswerQuestion(
  72. q: QuizQuestion,
  73. userAnswer: string,
  74. language: string,
  75. ): Promise<QuestionResult> {
  76. const pts = q.points ?? 1;
  77. try {
  78. const modelConfig = getCurrentModelConfig();
  79. const headers: Record<string, string> = {
  80. 'Content-Type': 'application/json',
  81. 'x-model': modelConfig.modelString,
  82. 'x-api-key': modelConfig.apiKey,
  83. };
  84. if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
  85. if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
  86. const res = await fetch('/api/quiz-grade', {
  87. method: 'POST',
  88. headers,
  89. body: JSON.stringify({
  90. question: q.question,
  91. userAnswer,
  92. points: pts,
  93. commentPrompt: q.commentPrompt,
  94. language,
  95. }),
  96. });
  97. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  98. const data = (await res.json()) as { score: number; comment: string };
  99. const earned = Math.max(0, Math.min(pts, data.score));
  100. return {
  101. questionId: q.id,
  102. correct: earned >= pts * 0.8,
  103. status: earned >= pts * 0.8 ? 'correct' : 'incorrect',
  104. earned,
  105. aiComment: data.comment,
  106. };
  107. } catch (err) {
  108. log.error('[quiz-view] AI grading failed for', q.id, err);
  109. // Fallback: give half credit
  110. return {
  111. questionId: q.id,
  112. correct: null,
  113. status: 'incorrect',
  114. earned: Math.round(pts * 0.5),
  115. aiComment:
  116. language === 'zh-CN'
  117. ? '评分服务暂时不可用,已给予基础分。'
  118. : 'Grading service unavailable. Base score given.',
  119. };
  120. }
  121. }
  122. // ─── Sub-components ─────────────────────────────────────────────────────────
  123. function QuizCover({
  124. questionCount,
  125. totalPoints,
  126. onStart,
  127. }: {
  128. questionCount: number;
  129. totalPoints: number;
  130. onStart: () => void;
  131. }) {
  132. const { t } = useI18n();
  133. return (
  134. <div className="w-full h-full flex flex-col items-center justify-center gap-4 relative overflow-hidden">
  135. {/* Background decoration */}
  136. <div className="absolute top-0 right-0 p-6 opacity-[0.03]">
  137. <PieChart className="w-52 h-52 text-violet-500" />
  138. </div>
  139. <div className="absolute bottom-0 left-0 p-6 opacity-[0.02]">
  140. <BookOpenText className="w-40 h-40 text-violet-500 rotate-12" />
  141. </div>
  142. <motion.div
  143. initial={{ scale: 0.8, opacity: 0 }}
  144. animate={{ scale: 1, opacity: 1 }}
  145. transition={{ type: 'spring', stiffness: 200, damping: 20 }}
  146. 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"
  147. >
  148. <PieChart className="w-8 h-8 text-violet-500" />
  149. </motion.div>
  150. <motion.div
  151. initial={{ y: 10, opacity: 0 }}
  152. animate={{ y: 0, opacity: 1 }}
  153. transition={{ delay: 0.1 }}
  154. className="text-center z-10"
  155. >
  156. <h3 className="text-xl font-bold text-gray-800 dark:text-gray-100">{t('quiz.title')}</h3>
  157. <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{t('quiz.subtitle')}</p>
  158. </motion.div>
  159. <motion.div
  160. initial={{ y: 10, opacity: 0 }}
  161. animate={{ y: 0, opacity: 1 }}
  162. transition={{ delay: 0.2 }}
  163. className="flex gap-5 text-sm z-10"
  164. >
  165. <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
  166. <div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
  167. <BookOpenText className="w-3.5 h-3.5 text-violet-500" />
  168. </div>
  169. <span>
  170. {questionCount} {t('quiz.questionsCount')}
  171. </span>
  172. </div>
  173. <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
  174. <div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
  175. <PieChart className="w-3.5 h-3.5 text-violet-500" />
  176. </div>
  177. <span>
  178. {t('quiz.totalPrefix')} {totalPoints} {t('quiz.pointsSuffix')}
  179. </span>
  180. </div>
  181. </motion.div>
  182. <motion.button
  183. initial={{ y: 10, opacity: 0 }}
  184. animate={{ y: 0, opacity: 1 }}
  185. transition={{ delay: 0.3 }}
  186. whileHover={{ scale: 1.05 }}
  187. whileTap={{ scale: 0.95 }}
  188. onClick={onStart}
  189. 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"
  190. >
  191. {t('quiz.startQuiz')}
  192. <ChevronRight className="w-4 h-4" />
  193. </motion.button>
  194. </div>
  195. );
  196. }
  197. function SingleChoiceQuestion({
  198. question,
  199. index,
  200. value,
  201. onChange,
  202. disabled,
  203. result,
  204. }: {
  205. question: QuizQuestion;
  206. index: number;
  207. value?: string;
  208. onChange: (value: string) => void;
  209. disabled?: boolean;
  210. result?: QuestionResult;
  211. }) {
  212. const isReview = !!result;
  213. return (
  214. <QuestionCard question={question} index={index} result={result}>
  215. <div className="grid gap-2">
  216. {question.options?.map((opt) => {
  217. const selected = value === opt.value;
  218. const isCorrectOpt = isReview && question.answer?.includes(opt.value);
  219. const isWrong = isReview && selected && result?.status === 'incorrect';
  220. return (
  221. <button
  222. key={opt.value}
  223. disabled={disabled}
  224. onClick={() => !disabled && onChange(opt.value)}
  225. className={cn(
  226. 'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm',
  227. // Default state
  228. !isReview &&
  229. !selected &&
  230. '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',
  231. !isReview &&
  232. selected &&
  233. 'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700',
  234. // Review states
  235. isReview &&
  236. isCorrectOpt &&
  237. 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30',
  238. isReview &&
  239. isWrong &&
  240. !isCorrectOpt &&
  241. 'border-red-300 bg-red-50 dark:bg-red-900/30',
  242. isReview &&
  243. !isCorrectOpt &&
  244. !selected &&
  245. 'border-gray-100 dark:border-gray-700 opacity-60',
  246. disabled && !isReview && 'cursor-default',
  247. )}
  248. >
  249. <span
  250. className={cn(
  251. 'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors',
  252. !isReview &&
  253. !selected &&
  254. 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
  255. !isReview && selected && 'bg-violet-500 text-white',
  256. isReview && isCorrectOpt && 'bg-emerald-500 text-white',
  257. isReview && isWrong && !isCorrectOpt && 'bg-red-400 text-white',
  258. isReview &&
  259. !isCorrectOpt &&
  260. !selected &&
  261. 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500',
  262. )}
  263. >
  264. {opt.value}
  265. </span>
  266. <span
  267. className={cn(
  268. 'flex-1',
  269. isReview && !isCorrectOpt && !selected && 'text-gray-400 dark:text-gray-500',
  270. )}
  271. >
  272. {opt.label}
  273. </span>
  274. {isReview && isCorrectOpt && (
  275. <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
  276. )}
  277. {isReview && isWrong && !isCorrectOpt && (
  278. <XCircle className="w-5 h-5 text-red-400 shrink-0" />
  279. )}
  280. </button>
  281. );
  282. })}
  283. </div>
  284. </QuestionCard>
  285. );
  286. }
  287. function MultipleChoiceQuestion({
  288. question,
  289. index,
  290. value,
  291. onChange,
  292. disabled,
  293. result,
  294. }: {
  295. question: QuizQuestion;
  296. index: number;
  297. value?: string[];
  298. onChange: (value: string[]) => void;
  299. disabled?: boolean;
  300. result?: QuestionResult;
  301. }) {
  302. const isReview = !!result;
  303. const selected = value ?? [];
  304. const toggle = (optValue: string) => {
  305. if (disabled) return;
  306. if (selected.includes(optValue)) {
  307. onChange(selected.filter((v) => v !== optValue));
  308. } else {
  309. onChange([...selected, optValue]);
  310. }
  311. };
  312. const { t } = useI18n();
  313. return (
  314. <QuestionCard question={question} index={index} result={result}>
  315. {!isReview && (
  316. <p className="text-xs text-gray-400 dark:text-gray-500 mb-2">
  317. {t('quiz.multipleChoiceHint')}
  318. </p>
  319. )}
  320. <div className="grid gap-2">
  321. {question.options?.map((opt) => {
  322. const isSelected = selected.includes(opt.value);
  323. const isCorrectOpt = isReview && question.answer?.includes(opt.value);
  324. const isWrong = isReview && isSelected && !isCorrectOpt;
  325. return (
  326. <button
  327. key={opt.value}
  328. disabled={disabled}
  329. onClick={() => toggle(opt.value)}
  330. className={cn(
  331. 'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm',
  332. !isReview &&
  333. !isSelected &&
  334. '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',
  335. !isReview &&
  336. isSelected &&
  337. 'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700',
  338. isReview &&
  339. isCorrectOpt &&
  340. 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30',
  341. isReview && isWrong && 'border-red-300 bg-red-50 dark:bg-red-900/30',
  342. isReview &&
  343. !isCorrectOpt &&
  344. !isSelected &&
  345. 'border-gray-100 dark:border-gray-700 opacity-60',
  346. disabled && !isReview && 'cursor-default',
  347. )}
  348. >
  349. <span
  350. className={cn(
  351. 'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 transition-colors',
  352. !isReview &&
  353. !isSelected &&
  354. 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
  355. !isReview && isSelected && 'bg-violet-500 text-white',
  356. isReview && isCorrectOpt && 'bg-emerald-500 text-white',
  357. isReview && isWrong && 'bg-red-400 text-white',
  358. isReview &&
  359. !isCorrectOpt &&
  360. !isSelected &&
  361. 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500',
  362. )}
  363. >
  364. {!isReview && isSelected ? <Check className="w-3.5 h-3.5" /> : opt.value}
  365. </span>
  366. <span
  367. className={cn(
  368. 'flex-1',
  369. isReview && !isCorrectOpt && !isSelected && 'text-gray-400 dark:text-gray-500',
  370. )}
  371. >
  372. {opt.label}
  373. </span>
  374. {isReview && isCorrectOpt && (
  375. <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
  376. )}
  377. {isReview && isWrong && <XCircle className="w-5 h-5 text-red-400 shrink-0" />}
  378. </button>
  379. );
  380. })}
  381. </div>
  382. </QuestionCard>
  383. );
  384. }
  385. function ShortAnswerQuestion({
  386. question,
  387. index,
  388. value,
  389. onChange,
  390. disabled,
  391. result,
  392. }: {
  393. question: QuizQuestion;
  394. index: number;
  395. value?: string;
  396. onChange: (value: string) => void;
  397. disabled?: boolean;
  398. result?: QuestionResult;
  399. }) {
  400. const isReview = !!result;
  401. const { t } = useI18n();
  402. // Ref to track latest value for voice transcription append
  403. const valueRef = useRef(value);
  404. useEffect(() => {
  405. valueRef.current = value;
  406. }, [value]);
  407. return (
  408. <QuestionCard question={question} index={index} result={result}>
  409. {!isReview ? (
  410. <div className="relative">
  411. <textarea
  412. value={value ?? ''}
  413. onChange={(e) => onChange(e.target.value)}
  414. disabled={disabled}
  415. placeholder={t('quiz.inputPlaceholder')}
  416. 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"
  417. />
  418. <SpeechButton
  419. size="sm"
  420. disabled={disabled}
  421. className="absolute bottom-3 left-3"
  422. onTranscription={(text) => {
  423. const cur = valueRef.current ?? '';
  424. onChange(cur + (cur ? ' ' : '') + text);
  425. }}
  426. />
  427. <span className="absolute bottom-3 right-3 text-xs text-gray-300 dark:text-gray-600">
  428. {(value ?? '').length} {t('quiz.charCount')}
  429. </span>
  430. </div>
  431. ) : (
  432. <div className="space-y-3">
  433. <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">
  434. <p className="text-xs text-gray-400 dark:text-gray-500 mb-1">{t('quiz.yourAnswer')}</p>
  435. {value || (
  436. <span className="text-gray-400 dark:text-gray-500 italic">
  437. {t('quiz.notAnswered')}
  438. </span>
  439. )}
  440. </div>
  441. {result.aiComment && (
  442. <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">
  443. <Sparkles className="w-4 h-4 text-violet-500 shrink-0 mt-0.5" />
  444. <div>
  445. <p className="text-xs font-medium text-violet-600 dark:text-violet-400 mb-0.5">
  446. {t('quiz.aiComment')}
  447. </p>
  448. <p className="text-xs text-violet-600/80 dark:text-violet-400/80">
  449. {result.aiComment}
  450. </p>
  451. </div>
  452. <span className="ml-auto text-xs font-bold text-violet-600 dark:text-violet-400 shrink-0">
  453. {result.earned}/{question.points ?? 1}
  454. {t('quiz.pointsSuffix')}
  455. </span>
  456. </div>
  457. )}
  458. </div>
  459. )}
  460. </QuestionCard>
  461. );
  462. }
  463. function QuestionCard({
  464. question,
  465. index,
  466. result,
  467. children,
  468. }: {
  469. question: QuizQuestion;
  470. index: number;
  471. result?: QuestionResult;
  472. children: React.ReactNode;
  473. }) {
  474. const { t } = useI18n();
  475. const isReview = !!result;
  476. const pts = question.points ?? 1;
  477. return (
  478. <motion.div
  479. initial={{ opacity: 0, y: 12 }}
  480. animate={{ opacity: 1, y: 0 }}
  481. transition={{ delay: index * 0.05 }}
  482. className={cn(
  483. 'bg-white dark:bg-gray-800 rounded-2xl border p-5 relative overflow-hidden',
  484. !isReview && 'border-gray-150 dark:border-gray-700 shadow-sm',
  485. isReview &&
  486. result.status === 'correct' &&
  487. 'border-emerald-200 dark:border-emerald-800 shadow-sm shadow-emerald-50 dark:shadow-emerald-900/20',
  488. isReview &&
  489. result.status === 'incorrect' &&
  490. 'border-red-200 dark:border-red-800 shadow-sm shadow-red-50 dark:shadow-red-900/20',
  491. )}
  492. >
  493. {/* Left accent */}
  494. <div
  495. className={cn(
  496. 'absolute left-0 top-0 bottom-0 w-1 rounded-l-2xl',
  497. !isReview && 'bg-violet-400',
  498. isReview && result.status === 'correct' && 'bg-emerald-400',
  499. isReview && result.status === 'incorrect' && 'bg-red-400',
  500. )}
  501. />
  502. {/* Header */}
  503. <div className="flex items-start justify-between mb-3">
  504. <div className="flex items-start gap-3">
  505. <span
  506. className={cn(
  507. 'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0',
  508. !isReview &&
  509. 'bg-violet-100 dark:bg-violet-900/50 text-violet-600 dark:text-violet-400',
  510. isReview &&
  511. result.status === 'correct' &&
  512. 'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400',
  513. isReview &&
  514. result.status === 'incorrect' &&
  515. 'bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400',
  516. )}
  517. >
  518. {index + 1}
  519. </span>
  520. <div>
  521. <p className="text-sm font-medium text-gray-800 dark:text-gray-100 leading-relaxed">
  522. {question.question}
  523. </p>
  524. <p className="text-xs text-gray-400 mt-0.5">
  525. {question.type === 'single'
  526. ? t('quiz.singleChoice')
  527. : question.type === 'multiple'
  528. ? t('quiz.multipleChoice')
  529. : t('quiz.shortAnswer')}
  530. {' · '}
  531. {pts} {t('quiz.pointsSuffix')}
  532. </p>
  533. </div>
  534. </div>
  535. {isReview && (
  536. <div className="shrink-0 ml-2">
  537. {result.status === 'correct' && <CheckCircle2 className="w-6 h-6 text-emerald-500" />}
  538. {result.status === 'incorrect' && <XCircle className="w-6 h-6 text-red-400" />}
  539. </div>
  540. )}
  541. </div>
  542. {/* Body */}
  543. {children}
  544. {/* Analysis (review only) */}
  545. {isReview && question.analysis && (
  546. <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">
  547. <span className="font-medium">{t('quiz.analysis')}</span>
  548. {question.analysis}
  549. </div>
  550. )}
  551. </motion.div>
  552. );
  553. }
  554. function ScoreBanner({
  555. score,
  556. total,
  557. results,
  558. }: {
  559. score: number;
  560. total: number;
  561. results: QuestionResult[];
  562. }) {
  563. const { t } = useI18n();
  564. const pct = total > 0 ? Math.round((score / total) * 100) : 0;
  565. const correctCount = results.filter((r) => r.status === 'correct').length;
  566. const incorrectCount = results.filter((r) => r.status === 'incorrect').length;
  567. const color = pct >= 80 ? 'emerald' : pct >= 60 ? 'amber' : 'red';
  568. const colorMap = {
  569. emerald: {
  570. bg: 'from-emerald-500 to-teal-500',
  571. shadow: 'shadow-emerald-200/50 dark:shadow-emerald-900/50',
  572. ring: 'bg-emerald-400/30',
  573. text: t('quiz.excellent'),
  574. },
  575. amber: {
  576. bg: 'from-amber-500 to-yellow-500',
  577. shadow: 'shadow-amber-200/50 dark:shadow-amber-900/50',
  578. ring: 'bg-amber-400/30',
  579. text: t('quiz.keepGoing'),
  580. },
  581. red: {
  582. bg: 'from-red-500 to-rose-500',
  583. shadow: 'shadow-red-200/50 dark:shadow-red-900/50',
  584. ring: 'bg-red-400/30',
  585. text: t('quiz.needsReview'),
  586. },
  587. };
  588. const c = colorMap[color];
  589. return (
  590. <motion.div
  591. initial={{ opacity: 0, scale: 0.95 }}
  592. animate={{ opacity: 1, scale: 1 }}
  593. className={cn('rounded-2xl p-6 bg-gradient-to-r text-white shadow-lg', c.bg, c.shadow)}
  594. >
  595. <div className="flex items-center justify-between">
  596. <div>
  597. <p className="text-white/80 text-sm font-medium">{c.text}</p>
  598. <div className="flex items-baseline gap-1 mt-1">
  599. <span className="text-4xl font-black">{score}</span>
  600. <span className="text-white/60 text-lg">/ {total}</span>
  601. </div>
  602. <div className="flex gap-3 mt-3 text-xs">
  603. <span className="flex items-center gap-1">
  604. <CheckCircle2 className="w-3.5 h-3.5" /> {correctCount} {t('quiz.correct')}
  605. </span>
  606. <span className="flex items-center gap-1">
  607. <XCircle className="w-3.5 h-3.5" /> {incorrectCount} {t('quiz.incorrect')}
  608. </span>
  609. </div>
  610. </div>
  611. {/* Percentage ring */}
  612. <div className="relative w-20 h-20">
  613. <svg className="w-20 h-20 -rotate-90" viewBox="0 0 80 80">
  614. <circle
  615. cx="40"
  616. cy="40"
  617. r="34"
  618. fill="none"
  619. stroke="rgba(255,255,255,0.2)"
  620. strokeWidth="6"
  621. />
  622. <motion.circle
  623. cx="40"
  624. cy="40"
  625. r="34"
  626. fill="none"
  627. stroke="white"
  628. strokeWidth="6"
  629. strokeLinecap="round"
  630. strokeDasharray={`${2 * Math.PI * 34}`}
  631. initial={{ strokeDashoffset: 2 * Math.PI * 34 }}
  632. animate={{ strokeDashoffset: 2 * Math.PI * 34 * (1 - pct / 100) }}
  633. transition={{ duration: 1, ease: 'easeOut', delay: 0.3 }}
  634. />
  635. </svg>
  636. <div className="absolute inset-0 flex items-center justify-center">
  637. <span className="text-lg font-black">{pct}%</span>
  638. </div>
  639. </div>
  640. </div>
  641. </motion.div>
  642. );
  643. }
  644. // ─── Main Component ─────────────────────────────────────────────────────────
  645. export function QuizView({ questions, sceneId }: QuizViewProps) {
  646. const { t, locale } = useI18n();
  647. const [phase, setPhase] = useState<Phase>('not_started');
  648. const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
  649. const [results, setResults] = useState<QuestionResult[]>([]);
  650. // Draft cache for quiz answers, keyed by sceneId to isolate across classrooms
  651. const {
  652. cachedValue: cachedAnswers,
  653. updateCache: updateAnswersCache,
  654. clearCache: clearAnswersCache,
  655. } = useDraftCache<Record<string, string | string[]>>({
  656. key: `quizDraft:${sceneId}`,
  657. });
  658. // Restore cached answers during render (derived state pattern)
  659. const [prevCachedAnswers, setPrevCachedAnswers] = useState(cachedAnswers);
  660. if (cachedAnswers !== prevCachedAnswers) {
  661. setPrevCachedAnswers(cachedAnswers);
  662. if (cachedAnswers && Object.keys(cachedAnswers).length > 0 && phase === 'not_started') {
  663. setAnswers(cachedAnswers);
  664. setPhase('answering');
  665. }
  666. }
  667. const totalPoints = useMemo(
  668. () => questions.reduce((sum, q) => sum + (q.points ?? 1), 0),
  669. [questions],
  670. );
  671. const allAnswered = useMemo(() => {
  672. return questions.every((q) => {
  673. const a = answers[q.id];
  674. if (!a) return false;
  675. if (Array.isArray(a)) return a.length > 0;
  676. return (a as string).trim().length > 0;
  677. });
  678. }, [questions, answers]);
  679. const handleSetAnswer = useCallback(
  680. (questionId: string, value: string | string[]) => {
  681. setAnswers((prev) => {
  682. const next = { ...prev, [questionId]: value };
  683. updateAnswersCache(next);
  684. return next;
  685. });
  686. },
  687. [updateAnswersCache],
  688. );
  689. const handleSubmit = useCallback(() => {
  690. setPhase('grading');
  691. clearAnswersCache();
  692. }, [clearAnswersCache]);
  693. // When entering grading phase, grade choice questions locally + call API for short-answer
  694. useEffect(() => {
  695. if (phase !== 'grading') return;
  696. let cancelled = false;
  697. (async () => {
  698. // 1. Grade choice questions locally (instant)
  699. const choiceResults = gradeChoiceQuestions(questions, answers);
  700. // 2. Grade short-answer questions via AI API (parallel)
  701. const shortAnswerQs = questions.filter(isShortAnswer);
  702. const aiResults = await Promise.all(
  703. shortAnswerQs.map((q) =>
  704. gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale),
  705. ),
  706. );
  707. if (cancelled) return;
  708. // 3. Merge results in original question order
  709. const allResultsMap = new Map<string, QuestionResult>();
  710. for (const r of [...choiceResults, ...aiResults]) {
  711. allResultsMap.set(r.questionId, r);
  712. }
  713. const ordered = questions.map((q) => allResultsMap.get(q.id)!).filter(Boolean);
  714. setResults(ordered);
  715. setPhase('reviewing');
  716. })();
  717. return () => {
  718. cancelled = true;
  719. };
  720. }, [phase, questions, answers, locale]);
  721. const handleRetry = useCallback(() => {
  722. setPhase('not_started');
  723. setAnswers({});
  724. setResults([]);
  725. clearAnswersCache();
  726. }, [clearAnswersCache]);
  727. const earnedScore = useMemo(() => results.reduce((sum, r) => sum + r.earned, 0), [results]);
  728. const resultMap = useMemo(() => {
  729. const map: Record<string, QuestionResult> = {};
  730. results.forEach((r) => {
  731. map[r.questionId] = r;
  732. });
  733. return map;
  734. }, [results]);
  735. return (
  736. <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">
  737. <AnimatePresence mode="wait">
  738. {phase === 'not_started' && (
  739. <motion.div
  740. key="cover"
  741. initial={{ opacity: 0 }}
  742. animate={{ opacity: 1 }}
  743. exit={{ opacity: 0, x: -20 }}
  744. className="flex-1"
  745. >
  746. <QuizCover
  747. questionCount={questions.length}
  748. totalPoints={totalPoints}
  749. onStart={() => setPhase('answering')}
  750. />
  751. </motion.div>
  752. )}
  753. {phase === 'answering' && (
  754. <motion.div
  755. key="answering"
  756. initial={{ opacity: 0, x: 20 }}
  757. animate={{ opacity: 1, x: 0 }}
  758. exit={{ opacity: 0, x: -20 }}
  759. className="flex-1 flex flex-col min-h-0"
  760. >
  761. {/* Header bar */}
  762. <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">
  763. <div className="flex items-center gap-2">
  764. <PieChart className="w-4 h-4 text-violet-500" />
  765. <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
  766. {t('quiz.answering')}
  767. </span>
  768. <span className="text-xs text-gray-400 ml-1">
  769. {
  770. Object.keys(answers).filter((k) => {
  771. const a = answers[k];
  772. if (Array.isArray(a)) return a.length > 0;
  773. return typeof a === 'string' && a.trim().length > 0;
  774. }).length
  775. }{' '}
  776. / {questions.length}
  777. </span>
  778. </div>
  779. <button
  780. onClick={handleSubmit}
  781. disabled={!allAnswered}
  782. className={cn(
  783. 'px-4 py-1.5 rounded-lg text-xs font-medium transition-all',
  784. allAnswered
  785. ? '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]'
  786. : 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed',
  787. )}
  788. >
  789. {t('quiz.submitAnswers')}
  790. </button>
  791. </div>
  792. {/* Questions */}
  793. <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
  794. {questions.map((q, i) => {
  795. if (q.type === 'single') {
  796. return (
  797. <SingleChoiceQuestion
  798. key={q.id}
  799. question={q}
  800. index={i}
  801. value={answers[q.id] as string | undefined}
  802. onChange={(v) => handleSetAnswer(q.id, v)}
  803. />
  804. );
  805. }
  806. if (q.type === 'multiple') {
  807. return (
  808. <MultipleChoiceQuestion
  809. key={q.id}
  810. question={q}
  811. index={i}
  812. value={answers[q.id] as string[] | undefined}
  813. onChange={(v) => handleSetAnswer(q.id, v)}
  814. />
  815. );
  816. }
  817. return (
  818. <ShortAnswerQuestion
  819. key={q.id}
  820. question={q}
  821. index={i}
  822. value={answers[q.id] as string | undefined}
  823. onChange={(v) => handleSetAnswer(q.id, v)}
  824. />
  825. );
  826. })}
  827. </div>
  828. </motion.div>
  829. )}
  830. {phase === 'grading' && (
  831. <motion.div
  832. key="grading"
  833. initial={{ opacity: 0 }}
  834. animate={{ opacity: 1 }}
  835. exit={{ opacity: 0 }}
  836. className="flex-1 flex flex-col items-center justify-center gap-5"
  837. >
  838. <motion.div
  839. animate={{ rotate: 360 }}
  840. transition={{ repeat: Infinity, duration: 1.5, ease: 'linear' }}
  841. >
  842. <Loader2 className="w-10 h-10 text-violet-500" />
  843. </motion.div>
  844. <div className="text-center">
  845. <p className="text-base font-semibold text-gray-700 dark:text-gray-200">
  846. {t('quiz.aiGrading')}
  847. </p>
  848. <p className="text-sm text-gray-400 mt-1">{t('quiz.aiGradingWait')}</p>
  849. </div>
  850. <div className="flex gap-1 mt-2">
  851. {[0, 1, 2].map((i) => (
  852. <motion.div
  853. key={i}
  854. className="w-2 h-2 rounded-full bg-violet-400"
  855. animate={{ opacity: [0.3, 1, 0.3] }}
  856. transition={{
  857. repeat: Infinity,
  858. duration: 1.2,
  859. delay: i * 0.2,
  860. }}
  861. />
  862. ))}
  863. </div>
  864. </motion.div>
  865. )}
  866. {phase === 'reviewing' && (
  867. <motion.div
  868. key="reviewing"
  869. initial={{ opacity: 0, x: 20 }}
  870. animate={{ opacity: 1, x: 0 }}
  871. className="flex-1 flex flex-col min-h-0"
  872. >
  873. {/* Header bar */}
  874. <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">
  875. <div className="flex items-center gap-2">
  876. <CheckCircle2 className="w-4 h-4 text-emerald-500" />
  877. <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
  878. {t('quiz.quizReport')}
  879. </span>
  880. </div>
  881. <button
  882. onClick={handleRetry}
  883. 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"
  884. >
  885. <RotateCcw className="w-3.5 h-3.5" />
  886. {t('quiz.retry')}
  887. </button>
  888. </div>
  889. {/* Results */}
  890. <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
  891. <ScoreBanner score={earnedScore} total={totalPoints} results={results} />
  892. {questions.map((q, i) => {
  893. const r = resultMap[q.id];
  894. if (q.type === 'single') {
  895. return (
  896. <SingleChoiceQuestion
  897. key={q.id}
  898. question={q}
  899. index={i}
  900. value={answers[q.id] as string | undefined}
  901. onChange={() => {}}
  902. disabled
  903. result={r}
  904. />
  905. );
  906. }
  907. if (q.type === 'multiple') {
  908. return (
  909. <MultipleChoiceQuestion
  910. key={q.id}
  911. question={q}
  912. index={i}
  913. value={answers[q.id] as string[] | undefined}
  914. onChange={() => {}}
  915. disabled
  916. result={r}
  917. />
  918. );
  919. }
  920. return (
  921. <ShortAnswerQuestion
  922. key={q.id}
  923. question={q}
  924. index={i}
  925. value={answers[q.id] as string | undefined}
  926. onChange={() => {}}
  927. disabled
  928. result={r}
  929. />
  930. );
  931. })}
  932. </div>
  933. </motion.div>
  934. )}
  935. </AnimatePresence>
  936. </div>
  937. );
  938. }