Spaces:
Running
Running
Download classroom_static/teacher.js from Telearn/Project_Demo: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_static/teacher.js
- Command line
-
hf download hf://spaces/Telearn/Project_Demo/classroom_static/teacher.js
-
curl -L -o teacher.js https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_static/teacher.js
14.5 kB
| 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 `<button class="student-row ${student.student_id === selected ? "selected" : ""}" data-id="${student.student_id}"><span class="student-name">${esc(student.display_name)}</span><span class="status-label"><i class="status-dot ${dot}"></i>${label}</span><span class="time-label">${savedTime(latest)}</span><span class="help-label">${progressLabel}</span></button>`; | |
| }).join("") : '<div class="empty-roster">這個班級尚無學生。請用上方欄位建立名單。</div>'; | |
| 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 => `<button class="teacher-file-tab ${path === file ? "active" : ""}" data-file="${esc(path)}">${esc(path)}</button>`).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 => `<option value="${esc(item.class_code)}">${esc(item.course_name)}|${esc(item.class_code)}</option>`).join("") + '<option value="__new__">+ 建立新課程代碼</option>'; | |
| 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 => `<option value="${item.id}">Lesson ${item.lesson ?? item.week}|${esc(item.title)}${item.workspace_id && item.workspace_id !== item.id ? "(同一專案)" : ""}</option>`).join(""); | |
| E.teacherWeek.value = "week02"; | |
| await loadCatalog(); | |
| E.loginDialog.showModal(); | |
| })(); | |