Spaces:
Running
Running
taboola
label image questions in report, warn on incomplete download, changes to report aesthetics
8bbeab6 | 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 }>; | |
| } | |
| function CoffeeCup({ size = 32 }: { size?: number }) { | |
| const w = size; | |
| const h = Math.round(size * (380 / 300)); | |
| return ( | |
| <svg width={w} height={h} viewBox="0 0 300 380" aria-hidden="true" style={{ flexShrink: 0 }}> | |
| <g transform="translate(0,380) scale(0.1,-0.1)" fill="var(--color-primary)" stroke="none"> | |
| <path className="steam-line" style={{ animationDelay: "0s" }} | |
| d="M1195 3398 c-22 -11 -42 -22 -43 -23 -2 -2 19 -36 46 -77 55 -81 90 -148 103 -191 11 -40 -8 -82 -85 -186 -76 -102 -82 -115 -96 -175 -8 -35 -7 -63 4 -114 14 -67 102 -282 115 -282 16 0 101 32 101 38 0 4 -22 59 -50 123 -27 63 -53 136 -56 162 -8 55 8 91 90 200 83 112 99 150 93 221 -2 34 -13 79 -23 101 -30 65 -135 225 -148 225 -6 -1 -29 -10 -51 -22z"/> | |
| <path className="steam-line" style={{ animationDelay: "0.55s" }} | |
| d="M1825 3388 c-22 -11 -42 -22 -43 -23 -2 -2 19 -36 46 -77 55 -81 96 -159 106 -201 8 -34 -18 -86 -86 -175 -98 -129 -121 -216 -85 -332 24 -77 99 -240 111 -239 6 0 32 8 57 17 l46 17 -47 105 c-27 58 -54 131 -60 162 -16 72 -8 92 73 201 113 153 130 218 87 328 -26 64 -138 239 -154 239 -6 -1 -29 -10 -51 -22z"/> | |
| <path className="steam-line" style={{ animationDelay: "1.1s" }} | |
| d="M528 3374 c-27 -13 -48 -27 -48 -30 0 -3 23 -39 51 -80 63 -94 99 -170 99 -210 0 -30 -27 -77 -119 -204 -94 -130 -92 -218 11 -442 l40 -86 47 15 c25 9 49 19 54 23 4 3 -10 44 -31 91 -93 202 -93 240 -3 361 93 126 115 172 115 243 1 47 -6 75 -31 126 -26 56 -128 220 -136 219 -1 -1 -23 -12 -49 -26z"/> | |
| <path d="M392 2090 c-44 -10 -70 -35 -83 -79 -7 -24 -9 -178 -7 -461 4 -398 5 -430 26 -503 65 -236 240 -437 487 -559 164 -82 304 -112 510 -112 234 1 401 45 591 157 103 60 248 199 304 290 l35 57 75 -6 c187 -16 352 83 436 261 143 300 -34 653 -350 697 l-76 11 0 84 c0 60 -5 93 -16 112 -35 59 21 56 -979 58 -506 1 -935 -2 -953 -7z m2075 -491 c83 -40 135 -136 135 -250 0 -144 -97 -259 -221 -263 -42 -1 -46 1 -45 22 0 12 1 132 2 267 l2 245 42 0 c23 0 61 -9 85 -21z"/> | |
| </g> | |
| </svg> | |
| ); | |
| } | |
| function estimateMinutes(total: number): string { | |
| const secs = total * 45; | |
| if (secs < 90) return "about a minute"; | |
| const mins = Math.ceil(secs / 60); | |
| return `about ${mins} minutes`; | |
| } | |
| 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 topRef = useRef<HTMLDivElement>(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 { | |
| 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 scrollToTop = () => { | |
| iframeRef.current?.contentWindow?.scrollTo({ top: 0, behavior: "smooth" }); | |
| }; | |
| const activeReport = reports[activeIndex]; | |
| const doneCount = reports.filter((r) => r.status === "done").length; | |
| const errorCount = reports.filter((r) => r.status === "error").length; | |
| const totalCount = reports.length; | |
| const handleDownloadAll = () => { | |
| const missing = totalCount - doneCount; | |
| if (missing > 0) { | |
| const ok = window.confirm( | |
| `尚有 ${missing} 位學生的報告尚未生成完成,這份下載檔將不完整(只包含目前已完成的 ${doneCount} 份報告,缺少的學生不會出現在檔案中)。\n\n確定要現在下載嗎?` | |
| ); | |
| if (!ok) return; | |
| } | |
| onExportAllHtml(); | |
| }; | |
| return ( | |
| <div className="space-y-4" ref={topRef}> | |
| {/* 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} / {totalCount} | |
| </span> | |
| </div> | |
| <div className="w-full h-4 bg-[var(--color-border)] rounded-full overflow-hidden"> | |
| <div | |
| className="h-full progress-stripe rounded-full transition-[width] duration-500" | |
| style={{ width: `${Math.max(((doneCount + errorCount) / totalCount) * 100, 4)}%` }} | |
| /> | |
| </div> | |
| <div className="flex items-center justify-center gap-2.5 mt-2"> | |
| <CoffeeCup /> | |
| <p className="text-xs text-[var(--color-text-muted)]"> | |
| Hang tight — this will take {estimateMinutes(totalCount)}. Grab a coffee! | |
| </p> | |
| </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={handleDownloadAll} 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={`inline-block w-1.5 h-1.5 rounded-full mr-2 ${ | |
| r.status === "done" ? "bg-emerald-500" | |
| : r.status === "generating" ? "bg-amber-400" | |
| : r.status === "error" ? "bg-red-400" | |
| : "bg-[var(--color-border)]" | |
| }`} /> | |
| {r.studentName} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Report content */} | |
| <div className="card overflow-hidden relative" 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" | |
| /> | |
| {/* Floating back-to-top button */} | |
| <button | |
| onClick={scrollToTop} | |
| title="Back to top" | |
| className="absolute bottom-4 right-4 w-10 h-10 rounded-full bg-[var(--color-primary)] text-white shadow-lg flex items-center justify-center hover:bg-[var(--color-primary-light)] transition-colors z-10" | |
| > | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> | |
| <path d="M8 12V4M4 7l4-4 4 4" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /> | |
| </svg> | |
| </button> | |
| </> | |
| )} | |
| {activeReport?.status === "generating" && ( | |
| <div className="w-full h-full flex flex-col items-center justify-center gap-6"> | |
| <CoffeeCup size={64} /> | |
| <div className="text-center space-y-1"> | |
| <p className="text-[var(--color-text-muted)] text-base"> | |
| 正在為 <strong className="text-[var(--color-text)]">{activeReport.studentName}</strong> 生成報告... | |
| </p> | |
| </div> | |
| </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"> | |
| <div className="w-10 h-10 rounded-full bg-red-50 border border-red-200 flex items-center justify-center text-red-500 font-bold text-lg">✕</div> | |
| <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> | |
| ); | |
| function 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); | |
| } | |
| } | |