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();
})();