Spaces:
Running
Running
taboola
error message in generate student report, gate quiz records on final save, randomize question bank, UI changes
dc384f2 | import { useEffect, useState } from "react"; | |
| import type { MouseEvent } from "react"; | |
| import { apiPost } from "../../lib/api"; | |
| import { spawnSparkles } from "../../lib/sparkle"; | |
| interface DataPreviewModalProps { | |
| onGenerate: () => void; | |
| onClose: () => void; | |
| isGenerating: boolean; | |
| sessionId: number; | |
| selectedIndices: number[]; | |
| students: { index: number; name: string }[]; | |
| } | |
| interface PreviewResponse { | |
| student_index: number; | |
| student_name: string; | |
| total_questions: number; | |
| wrong_count: number; | |
| markdown_prompt: string; | |
| } | |
| export function DataPreviewModal({ | |
| onGenerate, | |
| onClose, | |
| isGenerating, | |
| sessionId, | |
| selectedIndices, | |
| students, | |
| }: DataPreviewModalProps) { | |
| const [previewIdx, setPreviewIdx] = useState<number>( | |
| selectedIndices.length > 0 ? selectedIndices[0] : 0 | |
| ); | |
| const [loading, setLoading] = useState(false); | |
| const [previews, setPreviews] = useState<Record<number, PreviewResponse>>({}); | |
| const [error, setError] = useState(""); | |
| const [triggering, setTriggering] = useState(false); | |
| // Let the sparkle burst actually play before the modal closes and the step 3 | |
| // loading screen takes over — calling onGenerate() immediately unmounted | |
| // this button (and its in-progress particles) before the animation finished. | |
| const handleGenerateClick = (e: MouseEvent<HTMLButtonElement>) => { | |
| spawnSparkles(e); | |
| setTriggering(true); | |
| setTimeout(onGenerate, 450); | |
| }; | |
| // Fetch every selected student's prompt in one request up front — switching | |
| // the dropdown below then just reads from local state, no re-fetch per switch. | |
| useEffect(() => { | |
| let active = true; | |
| setLoading(true); | |
| setError(""); | |
| setPreviews({}); | |
| apiPost<{ previews: PreviewResponse[] }>( | |
| `/api/sessions/${sessionId}/preview-student-prompts`, | |
| { student_indices: selectedIndices } | |
| ) | |
| .then((res) => { | |
| if (!active) return; | |
| const map: Record<number, PreviewResponse> = {}; | |
| for (const p of res.previews) map[p.student_index] = p; | |
| setPreviews(map); | |
| }) | |
| .catch((e) => | |
| active && setError(e instanceof Error ? e.message : "Failed to load preview") | |
| ) | |
| .finally(() => active && setLoading(false)); | |
| return () => { | |
| active = false; | |
| }; | |
| }, [sessionId, selectedIndices]); | |
| const preview = previews[previewIdx] ?? null; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> | |
| <div | |
| className="absolute inset-0 bg-black/50 backdrop-blur-sm" | |
| onClick={onClose} | |
| /> | |
| <div className="relative w-full max-w-6xl h-[92vh] flex flex-col card overflow-hidden"> | |
| <div className="flex items-center justify-between p-5 border-b border-[var(--color-border)]"> | |
| <h3 className="font-display text-xl font-bold text-[var(--color-text)]"> | |
| 最終提示詞預覽 | |
| </h3> | |
| <button | |
| onClick={onClose} | |
| className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-border)]/50 transition-colors text-lg" | |
| > | |
| × | |
| </button> | |
| </div> | |
| <div className="flex-1 overflow-hidden flex flex-col p-5 min-h-0"> | |
| <div className="flex items-center gap-3 mb-3 flex-wrap"> | |
| <label className="text-sm font-semibold text-[var(--color-text)]"> | |
| 預覽學生: | |
| </label> | |
| <select | |
| value={previewIdx} | |
| onChange={(e) => setPreviewIdx(Number(e.target.value))} | |
| className="input !w-auto !py-1.5 text-sm" | |
| > | |
| {selectedIndices.map((idx) => ( | |
| <option key={idx} value={idx}> | |
| {students.find((s) => s.index === idx)?.name || `Student ${idx + 1}`} | |
| </option> | |
| ))} | |
| </select> | |
| {preview && ( | |
| <> | |
| <span className="badge badge-success text-xs"> | |
| {preview.student_name} | |
| </span> | |
| <span className="badge badge-success text-xs"> | |
| 總題數 {preview.total_questions} | |
| </span> | |
| <span | |
| className={`badge text-xs ${ | |
| preview.wrong_count === 0 ? "badge-success" : "bg-amber-500/20 text-amber-400" | |
| }`} | |
| > | |
| 錯 {preview.wrong_count} | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| <p className="text-xs text-[var(--color-text-muted)] mb-3"> | |
| 以下是實際送給 LLM 的完整提示詞(僅含該學生答錯的題目) | |
| </p> | |
| {loading ? ( | |
| <div className="flex-1 flex items-center justify-center"> | |
| <div className="w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" /> | |
| </div> | |
| ) : error ? ( | |
| <div className="flex-1 px-3 py-2 rounded-lg bg-red-500/10 text-red-400 text-sm"> | |
| {error} | |
| </div> | |
| ) : ( | |
| <textarea | |
| value={preview?.markdown_prompt ?? ""} | |
| readOnly | |
| className="input !rounded-xl font-mono text-sm leading-relaxed resize-none flex-1 min-h-0" | |
| spellCheck={false} | |
| /> | |
| )} | |
| </div> | |
| <div className="flex items-center justify-end gap-3 p-5 border-t border-[var(--color-border)]"> | |
| <button onClick={onClose} className="btn btn-outline"> | |
| 返回編輯 | |
| </button> | |
| <button | |
| onClick={handleGenerateClick} | |
| disabled={isGenerating || triggering} | |
| className="btn btn-accent text-lg px-8 py-3 disabled:opacity-50 disabled:cursor-not-allowed relative overflow-visible" | |
| > | |
| {isGenerating || triggering ? ( | |
| <span className="flex items-center gap-2"> | |
| <span className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" /> | |
| 生成報告中... | |
| </span> | |
| ) : ( | |
| "生成報告" | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |