Spaces:
Running
Running
File size: 11,258 Bytes
f002ae5 bc45e54 f002ae5 bc45e54 f002ae5 4c4a722 bc45e54 9930a07 c2c3b04 9930a07 c2c3b04 9930a07 f002ae5 4c4a722 f002ae5 4c4a722 bc45e54 9930a07 bc45e54 4c4a722 f002ae5 9930a07 18816a6 9930a07 f002ae5 9930a07 bc45e54 8bbeab6 bc45e54 9930a07 f002ae5 9930a07 f002ae5 9930a07 f002ae5 9930a07 d3f40d1 f002ae5 9930a07 f002ae5 bc45e54 f002ae5 bc45e54 f002ae5 8bbeab6 f002ae5 7b8d5bb 4c4a722 169129a 7b8d5bb 4c4a722 3a83965 7b8d5bb 4c4a722 bc45e54 4c4a722 bc45e54 f002ae5 3a83965 f002ae5 9930a07 f002ae5 9930a07 f002ae5 9930a07 f002ae5 3a83965 f002ae5 bc45e54 9930a07 bc45e54 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 | 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 && totalCount > 0 && (
<button
onClick={() => { void handleSaveToDb(); }}
disabled={saveState === "saving" || saveState === "saved" || doneCount !== totalCount}
title={
doneCount !== totalCount
? `尚有 ${totalCount - doneCount} 位學生的報告未成功生成,須全部完成才能儲存至資料庫`
: undefined
}
className="btn btn-primary text-sm disabled:opacity-60"
>
{saveState === "saving" && "儲存中..."}
{saveState === "saved" && "已儲存"}
{(saveState === "idle" || saveState === "error") &&
(doneCount !== totalCount
? `儲存至資料庫(尚有 ${totalCount - doneCount} 份未完成)`
: "儲存至資料庫")}
</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);
}
}
|