Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect } from "react"; | |
| import type { StudentReport } from "../../App"; | |
| interface ReportViewerProps { | |
| reports: StudentReport[]; | |
| isGenerating: boolean; | |
| onBack: () => void; | |
| onExportAllHtml: () => void; | |
| onSaveToDb?: () => Promise<{ students_saved: number }>; | |
| } | |
| export function ReportViewer({ | |
| reports, | |
| isGenerating, | |
| onBack, | |
| onExportAllHtml, | |
| onSaveToDb, | |
| }: ReportViewerProps) { | |
| const [activeIndex, setActiveIndex] = useState(0); | |
| const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); | |
| const [saveMsg, setSaveMsg] = useState(""); | |
| const iframeRef = useRef<HTMLIFrameElement>(null); | |
| const handleSaveToDb = async () => { | |
| if (!onSaveToDb) return; | |
| setSaveState("saving"); | |
| setSaveMsg(""); | |
| try { | |
| const res = await onSaveToDb(); | |
| setSaveState("saved"); | |
| setSaveMsg(`已儲存 ${res.students_saved} 位學生成績`); | |
| } catch (e) { | |
| setSaveState("error"); | |
| setSaveMsg(e instanceof Error ? e.message : "儲存失敗"); | |
| } | |
| }; | |
| // Auto-switch to the report currently being generated | |
| useEffect(() => { | |
| const generatingIdx = reports.findIndex((r) => r.status === "generating"); | |
| if (generatingIdx >= 0) { | |
| setActiveIndex(generatingIdx); | |
| } else { | |
| // When generation finishes, show the last completed one | |
| let lastDone = -1; | |
| for (let j = reports.length - 1; j >= 0; j--) { | |
| if (reports[j].status === "done") { lastDone = j; break; } | |
| } | |
| if (lastDone >= 0) setActiveIndex(lastDone); | |
| } | |
| }, [reports]); | |
| const activeReport = reports[activeIndex]; | |
| const doneCount = reports.filter((r) => r.status === "done").length; | |
| const errorCount = reports.filter((r) => r.status === "error").length; | |
| const handleDownloadSingle = () => { | |
| if (!activeReport?.html) return; | |
| const blob = new Blob([activeReport.html], { type: "text/html" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `classlens-${activeReport.studentName}.html`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| return ( | |
| <div className="space-y-4"> | |
| {/* Progress bar */} | |
| {isGenerating && ( | |
| <div className="card p-4"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-sm font-semibold text-[var(--color-text)]"> | |
| 生成進度 | |
| </span> | |
| <span className="text-sm text-[var(--color-text-muted)]"> | |
| {doneCount + errorCount} / {reports.length} | |
| </span> | |
| </div> | |
| <div className="w-full h-2 bg-[var(--color-border)] rounded-full overflow-hidden"> | |
| <div | |
| className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-success)] transition-all duration-500" | |
| style={{ | |
| width: `${((doneCount + errorCount) / reports.length) * 100}%`, | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Action bar */} | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <button onClick={onBack} className="btn btn-outline text-sm"> | |
| ← 返回選擇學生 | |
| </button> | |
| <div className="flex-1" /> | |
| {activeReport?.status === "done" && ( | |
| <button onClick={handleDownloadSingle} className="btn btn-outline text-sm"> | |
| 下載此報告 | |
| </button> | |
| )} | |
| {doneCount > 1 && ( | |
| <button onClick={onExportAllHtml} className="btn btn-outline text-sm"> | |
| 下載全部報告 | |
| </button> | |
| )} | |
| {onSaveToDb && !isGenerating && doneCount > 0 && ( | |
| <button | |
| onClick={handleSaveToDb} | |
| disabled={saveState === "saving" || saveState === "saved"} | |
| className="btn btn-primary text-sm disabled:opacity-60" | |
| > | |
| {saveState === "saving" && "儲存中..."} | |
| {saveState === "saved" && "✅ 已儲存"} | |
| {(saveState === "idle" || saveState === "error") && "儲存至資料庫"} | |
| </button> | |
| )} | |
| </div> | |
| {saveMsg && ( | |
| <p | |
| className={`text-sm text-right ${ | |
| saveState === "error" ? "text-red-400" : "text-[var(--color-success)]" | |
| }`} | |
| > | |
| {saveMsg} | |
| </p> | |
| )} | |
| {/* Student tabs */} | |
| <div className="flex gap-2 overflow-x-auto pb-2"> | |
| {reports.map((r, i) => ( | |
| <button | |
| key={r.studentIndex} | |
| onClick={() => setActiveIndex(i)} | |
| className={`shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all ${ | |
| i === activeIndex | |
| ? "bg-[var(--color-primary)] text-white shadow-md" | |
| : "bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] border border-[var(--color-border)]" | |
| }`} | |
| > | |
| <span className="mr-1.5"> | |
| {r.status === "done" && "✅"} | |
| {r.status === "generating" && "⏳"} | |
| {r.status === "pending" && "⬜"} | |
| {r.status === "error" && "❌"} | |
| </span> | |
| {r.studentName} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Report content */} | |
| <div className="card overflow-hidden" style={{ height: "70vh" }}> | |
| {activeReport?.status === "done" && ( | |
| <iframe | |
| ref={iframeRef} | |
| srcDoc={activeReport.html} | |
| title={`Report - ${activeReport.studentName}`} | |
| className="w-full h-full border-0" | |
| sandbox="allow-scripts allow-same-origin" | |
| /> | |
| )} | |
| {activeReport?.status === "generating" && ( | |
| <div className="w-full h-full flex flex-col items-center justify-center gap-4"> | |
| <div className="w-10 h-10 border-3 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" /> | |
| <p className="text-[var(--color-text-muted)] text-lg"> | |
| 正在為 <strong className="text-[var(--color-text)]">{activeReport.studentName}</strong> 生成報告... | |
| </p> | |
| </div> | |
| )} | |
| {activeReport?.status === "pending" && ( | |
| <div className="w-full h-full flex items-center justify-center"> | |
| <p className="text-[var(--color-text-muted)]"> | |
| 等待生成... | |
| </p> | |
| </div> | |
| )} | |
| {activeReport?.status === "error" && ( | |
| <div className="w-full h-full flex flex-col items-center justify-center gap-3"> | |
| <span className="text-4xl">❌</span> | |
| <p className="text-[var(--color-text)] font-semibold"> | |
| {activeReport.studentName} 報告生成失敗 | |
| </p> | |
| <p className="text-sm text-[var(--color-text-muted)] max-w-md text-center"> | |
| {activeReport.error} | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |