taboola
label image questions in report, warn on incomplete download, changes to report aesthetics
8bbeab6
Raw
History Blame Contribute Delete
10.8 kB
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);
}
}