ClassLensPortal / chatkit /frontend /src /components /step2 /DataPreviewModal.tsx
taboola
error message in generate student report, gate quiz records on final save, randomize question bank, UI changes
dc384f2
Raw
History Blame Contribute Delete
6.3 kB
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"
>
&times;
</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>
);
}