const $ = id => document.getElementById(id); const E = {}; [ "classLabel", "connectionDot", "connectionText", "loginButton", "teacherWeek", "submissionSummary", "downloadButton", "publishButton", "studentTotal", "teacherFilters", "rosterList", "detailEmpty", "detailView", "selectedStudentName", "selectedStudentMeta", "teacherFiles", "teacherActiveFile", "teacherVersionLabel", "versionViewButton", "lineCount", "studentCode", "studentOutput", "outputState", "ackHelpButton", "copyCodeButton", "deleteStudentButton", "rosterAddForm", "studentNames", "addStudentsButton", "loginDialog", "loginForm", "classCode", "newClassFields", "newClassCode", "newClassName", "teacherPassword", "loginError", "publishDialog", "publishMessage", "cancelPublish", "confirmPublish", "toast" ].forEach(id => E[id] = $(id)); let socket; let students = []; let selected = null; let file = "main.py"; let versionView = "draft"; let filter = "all"; let credentials; let reconnect; let catalog = { courses: [] }; let courseCatalog = { weeks: [] }; const clean = value => String(value || "").trim().replace(/[^0-9A-Za-z_\-一-鿿]/g, "").slice(0, 48); const esc = value => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); const teacherWorkspace = () => courseCatalog.weeks.find(item => item.id === E.teacherWeek.value)?.workspace_id || E.teacherWeek.value; const savedTime = value => value ? new Date(value).toLocaleString("zh-TW", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"; function toast(text) { E.toast.textContent = text; E.toast.classList.add("visible"); setTimeout(() => E.toast.classList.remove("visible"), 2000); } function status(student) { if (student.needs_help) return ["help", student.connected ? "求助" : "求助·離線"]; if (!student.connected) return ["error", "離線"]; if (student.status === "running") return ["running", "執行中"]; if (student.status === "error") return ["error", "錯誤"]; return ["online", "在線"]; } function show() { const submitted = students.filter(student => student.submitted_at).length; const checkpointed = students.filter(student => student.checkpoint_at).length; const helpCount = students.filter(student => student.needs_help).length; E.studentTotal.textContent = `${students.length} 人`; E.submissionSummary.textContent = `${checkpointed}/${students.length} 暫存 · ${submitted}/${students.length} 正式提交 · ${helpCount} 人求助`; const helpFilter=E.teacherFilters.querySelector('[data-filter="help"]'); helpFilter.textContent=helpCount?`需要協助 ${helpCount}`:"需要協助"; const list = students.filter(student => filter === "all" || ( filter === "submitted" ? student.submitted_at : filter === "checkpointed" ? student.checkpoint_at : filter === "help" ? student.needs_help : student.status === filter )); E.rosterList.innerHTML = list.length ? list.map(student => { const [dot, label] = status(student); const latest = Math.max(student.updated_at || 0, student.submitted_at || 0, student.checkpoint_at || 0); const counts = student.version_counts || { checkpoint: 0, submit: 0 }; const progressLabel = counts.checkpoint || counts.submit ? `暫 ${counts.checkpoint} · 交 ${counts.submit}` : "—"; return ``; }).join("") : '
這個班級尚無學生。請用上方欄位建立名單。
'; E.rosterList.querySelectorAll("[data-id]").forEach(button => button.onclick = () => { selected = button.dataset.id; file = "main.py"; versionView = "draft"; show(); }); detail(); } function detail() { const student = students.find(item => item.student_id === selected); if (!student) { E.detailEmpty.hidden = false; E.detailView.hidden = true; return; } E.detailEmpty.hidden = true; E.detailView.hidden = false; const [, label] = status(student); if(versionView==="submit"&&!student.final_files)versionView="draft"; const files=versionView==="submit"?student.final_files:(student.files||{}); const paths = Object.keys(files).sort((a, b) => a === "main.py" ? -1 : b === "main.py" ? 1 : a.localeCompare(b)); if (!(file in files)) file = paths[0]; E.selectedStudentName.textContent = `${student.display_name} · ${student.project_name}`; const counts=student.version_counts||{checkpoint:0,submit:0},progress=[]; if(student.needs_help&&student.help_requested_at)progress.push(`求助於 ${savedTime(student.help_requested_at)}`); progress.push(versionView==="submit"?`檢視正式提交版本 · ${savedTime(student.submitted_at)}`:"目前顯示自動同步草稿"); if(student.updated_at)progress.push(`草稿更新 ${savedTime(student.updated_at)}`); if(student.checkpoint_at)progress.push(`可回復暫存 ${counts.checkpoint} 版,最近 ${savedTime(student.checkpoint_at)}`); if(student.submitted_at)progress.push(`正式提交 ${counts.submit} 版,最近 ${savedTime(student.submitted_at)}`); E.selectedStudentMeta.textContent = `${label} · ${progress.join(" · ")}`; E.ackHelpButton.hidden = !student.needs_help; E.versionViewButton.hidden = !student.final_files; E.versionViewButton.textContent=versionView==="submit"?"看目前草稿":"看正式提交"; E.versionViewButton.setAttribute("aria-pressed",String(versionView==="submit")); E.teacherVersionLabel.textContent=versionView==="submit"?"正式提交版本":"目前草稿"; E.teacherFiles.innerHTML = paths.map(path => ``).join(""); E.teacherFiles.querySelectorAll("[data-file]").forEach(button => button.onclick = () => { file = button.dataset.file; detail(); }); const code = files[file] || ""; E.teacherActiveFile.textContent = file; E.lineCount.textContent = `${Math.max(1, code.split("\n").length)} 行`; E.studentCode.textContent = code || "# 空檔案"; E.studentOutput.textContent = student.output || "尚無執行結果。"; E.outputState.textContent = `${label} · 草稿執行結果`; } function connect() { clearTimeout(reconnect); const previous = socket; if (previous) { previous.onclose = null; previous.close(); } if (!credentials) return; const query = new URLSearchParams({ class_code: credentials.classCode, week_id: teacherWorkspace() }); const scheme = location.protocol === "https:" ? "wss" : "ws"; E.classLabel.textContent = `${credentials.classCode} · ${E.teacherWeek.options[E.teacherWeek.selectedIndex]?.textContent || "Lesson"}`; E.connectionText.textContent = "連線中"; const current = new WebSocket(`${scheme}://${location.host}/ws/classroom/teacher?${query}`); socket = current; current.onopen = () => current.send(JSON.stringify({ type: "auth", password: credentials.password })); current.onmessage = event => { const message = JSON.parse(event.data); if (message.type !== "snapshot") return; E.connectionDot.className = "status-dot online"; E.connectionText.textContent = "即時監看"; students = message.students; if (selected && !students.some(student => student.student_id === selected)) selected = null; show(); }; current.onclose = event => { if (socket !== current) return; E.connectionDot.className = "status-dot error"; E.connectionText.textContent = "連線中斷"; if (event.code === 4403) { credentials = null; E.loginError.textContent = "教師密碼不正確"; E.loginDialog.showModal(); } else if (credentials) { reconnect = setTimeout(connect, 2500); } }; } async function request(path, method = "POST", extra = {}) { const base = credentials ? { class_code: credentials.classCode, week_id: teacherWorkspace(), password: credentials.password } : {}; const response = await fetch(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...base, ...extra }) }); if (!response.ok) { let message = "操作失敗"; try { message = (await response.json()).detail || message; } catch (_) {} throw Error(message); } return response; } async function loadCatalog() { catalog = await (await fetch("/api/classroom/classes")).json(); const saved = sessionStorage.getItem("teacher_class"); E.classCode.innerHTML = catalog.courses.map(item => ``).join("") + ''; E.classCode.value = catalog.courses.some(item => item.class_code === saved) ? saved : (catalog.courses[0]?.class_code || "__new__"); toggleNewClass(); } function toggleNewClass() { const adding = E.classCode.value === "__new__"; E.newClassFields.hidden = !adding; E.newClassCode.required = adding; } E.classCode.onchange = toggleNewClass; E.loginForm.onsubmit = async event => { event.preventDefault(); const button = E.loginForm.querySelector("button[type=submit]"); const password = E.teacherPassword.value; button.disabled = true; E.loginError.textContent = ""; try { let classCode = clean(E.classCode.value); if (E.classCode.value === "__new__") { classCode = clean(E.newClassCode.value); if (!classCode) throw Error("請輸入新課程代碼"); button.textContent = "建立課程中…"; credentials = { classCode, password }; const created = await (await request("/api/classroom/classes", "POST", { class_code: classCode, course_name: E.newClassName.value.trim() || classCode })).json(); if (created.warning) toast(created.warning); } credentials = { classCode, password }; sessionStorage.setItem("teacher_class", classCode); E.loginDialog.close(); connect(); } catch (error) { credentials = null; E.loginError.textContent = error.message; } finally { button.disabled = false; button.textContent = "開始監看"; } }; E.loginButton.onclick = async () => { await loadCatalog(); E.loginDialog.showModal(); }; E.teacherWeek.onchange = () => { selected = null; connect(); }; E.teacherFilters.querySelectorAll("[data-filter]").forEach(button => button.onclick = () => { filter = button.dataset.filter; E.teacherFilters.querySelectorAll("button").forEach(item => item.setAttribute("aria-pressed", String(item === button))); show(); }); E.ackHelpButton.onclick = () => { if (selected) socket.send(JSON.stringify({ type: "ack_help", student_id: selected })); }; E.versionViewButton.onclick=()=>{versionView=versionView==="submit"?"draft":"submit";detail()}; E.rosterAddForm.onsubmit = async event => { event.preventDefault(); const names = E.studentNames.value.split(/[,,\n]+/).map(name => name.trim()).filter(Boolean); if (!names.length) return toast("請輸入學生姓名"); E.addStudentsButton.disabled = true; E.addStudentsButton.textContent = "加入中…"; try { const data = await (await request("/api/classroom/students", "POST", { display_names: names })).json(); E.studentNames.value = ""; toast(data.warning || `已加入 ${data.created.length} 位學生`); } catch (error) { toast(error.message); } finally { E.addStudentsButton.disabled = false; E.addStudentsButton.textContent = "加入名單"; } }; E.copyCodeButton.onclick = async () => { const student = students.find(item => item.student_id === selected); if (student) { const files=versionView==="submit"?student.final_files:student.files;await navigator.clipboard.writeText(files?.[file] || ""); toast("已複製檔案"); } }; E.deleteStudentButton.onclick = async () => { const student = students.find(item => item.student_id === selected); if (!student || !confirm(`從 ${credentials.classCode} 刪除學生「${student.display_name}」與其教室進度?\n\n已發布到 Demo 的作品不會被刪除。`)) return; try { const data = await (await request("/api/classroom/students", "DELETE", { student_id: student.student_id })).json(); students = students.filter(item => item.student_id !== student.student_id); selected = null; show(); toast(data.warning || "已從課程名單刪除學生"); } catch (error) { toast(error.message); } }; E.downloadButton.onclick = async () => { try { const response = await request("/api/classroom/export"); const blob = await response.blob(); const anchor = document.createElement("a"); anchor.href = URL.createObjectURL(blob); anchor.download = `${credentials.classCode}_${teacherWorkspace()}_final.zip`; anchor.click(); URL.revokeObjectURL(anchor.href); toast("已產生全班 ZIP"); } catch (error) { toast(error.message); } }; E.publishButton.onclick = () => { const count = students.filter(student => student.submitted_at).length; E.publishMessage.textContent = `將 ${count} 份正式提交的最新版本公開到 Project Demo;進度暫存不會發布。Space 會重新建置。`; E.publishDialog.showModal(); }; E.cancelPublish.onclick = () => E.publishDialog.close(); E.confirmPublish.onclick = async () => { E.confirmPublish.disabled = true; E.confirmPublish.textContent = "發布中…"; try { const data = await (await request("/api/classroom/publish")).json(); E.publishDialog.close(); toast(`已發布 ${data.count} 份作品`); } catch (error) { E.publishMessage.textContent = error.message; } finally { E.confirmPublish.disabled = false; E.confirmPublish.textContent = "確認發布"; } }; setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping" })); }, 5000); (async () => { courseCatalog = await (await fetch("/api/classroom/course")).json(); E.teacherWeek.innerHTML = courseCatalog.weeks.map(item => ``).join(""); E.teacherWeek.value = "week02"; await loadCatalog(); E.loginDialog.showModal(); })();