steveyehtw's picture
Harden demo runtime and add browser Pygame runner
7547388
Raw History Blame Contribute Delete
29 kB
const $ = id => document.getElementById(id);
const E = {};
[
"adminStatus", "loginButton", "weekAdminList", "weekForm", "weekHeading",
"weekTitle", "weekProject", "weekOutput", "saveWeekButton", "newWeekFile",
"weekFiles", "weekFileLabel", "weekFileContent", "demoTotal", "newDemoButton",
"demoClassSelect", "demoStudentSelect", "demoProjectSelect", "demoPickerSummary",
"demoForm", "demoHeading", "demoClass", "demoStudent",
"demoProject", "newDemoFile", "demoFiles", "demoFileLabel", "demoFileContent",
"demoAssets", "saveDemoButton", "deleteDemoButton", "adminLoginDialog",
"adminLoginForm", "adminPassword", "adminLoginError", "toast",
"classroomTotal", "newClassroomButton", "classroomList", "classroomHeading",
"deleteClassroomButton", "saveClassroomButton", "classroomCode", "classroomName",
"backupWeek", "backupSubmissionsButton", "adminStudentForm", "adminStudentNames",
"adminAddStudentsButton", "adminStudentTotal", "adminStudentList", "submissionHeading",
"submissionTotal", "submissionList", "submissionProject", "submissionMeta",
"deleteSubmissionButton", "submissionFiles", "submissionCode"
].forEach(id => E[id] = $(id));
let password = "";
let state = { course: { weeks: [] }, demo_projects: [], classrooms: { courses: [], submissions: [] } };
let selectedWeek = "";
let weekFiles = {};
let weekFile = "";
let originalDemo = null;
let demoFiles = {};
let demoFile = "";
let demoAssets = [];
let selectedClassroom = "";
let selectedClassroomStudent = "";
let editingClassroomStudent = "";
let selectedSubmission = null;
let submissionFiles = {};
let submissionFile = "";
const esc = value => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
function toast(message) {
E.toast.textContent = message;
E.toast.classList.add("visible");
setTimeout(() => E.toast.classList.remove("visible"), 2600);
}
async function request(path, data = {}, method = "POST") {
const response = await fetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password, ...data })
});
if (!response.ok) {
let message = "操作失敗";
try { message = (await response.json()).detail || message; } catch (_) {}
throw Error(message);
}
return response.json();
}
function setBusy(button, busy, label) {
if (!button.dataset.label) button.dataset.label = button.textContent;
button.disabled = busy;
button.textContent = busy ? label : button.dataset.label;
}
document.querySelectorAll(".admin-tab").forEach(button => button.onclick = () => {
document.querySelectorAll(".admin-tab").forEach(item => item.classList.toggle("active", item === button));
document.querySelectorAll(".admin-view").forEach(view => view.classList.toggle("active", view.id === button.dataset.view));
});
async function login() {
state = await request("/api/admin/state");
E.adminStatus.textContent = `已登入 · ${state.course.title || "課程管理"}`;
renderWeeks();
renderDemoPicker();
E.backupWeek.innerHTML = state.course.weeks.map(week => `<option value="${esc(week.id)}">Lesson ${week.lesson ?? week.week}|${esc(week.title)}</option>`).join("");
E.backupWeek.value = "week02";
renderClassrooms();
if (state.course.weeks.length) await selectWeek(state.course.weeks[0].id);
}
E.adminLoginForm.onsubmit = async event => {
event.preventDefault();
password = E.adminPassword.value;
const button = E.adminLoginForm.querySelector("button[type=submit]");
setBusy(button, true, "登入中…");
E.adminLoginError.textContent = "";
try {
await login();
E.adminLoginDialog.close();
} catch (error) {
password = "";
E.adminLoginError.textContent = error.message;
} finally { setBusy(button, false); }
};
E.loginButton.onclick = () => E.adminLoginDialog.showModal();
function renderWeeks() {
E.weekAdminList.innerHTML = state.course.weeks.map(week => `
<button class="admin-list-button ${week.id === selectedWeek ? "active" : ""}" data-week="${esc(week.id)}">
<div><strong>Lesson ${week.lesson ?? week.week}|${esc(week.title)}</strong><small>${esc(week.project)}</small></div>
<span>${week.template ? "有套版" : "無套版"}</span>
</button>`).join("");
E.weekAdminList.querySelectorAll("[data-week]").forEach(button => button.onclick = () => selectWeek(button.dataset.week));
}
async function selectWeek(weekId) {
saveWeekFileBuffer();
selectedWeek = weekId;
renderWeeks();
const data = await request("/api/admin/week", { week_id: weekId });
E.weekHeading.textContent = `Lesson ${data.week.lesson ?? data.week.week}|${data.week.title}`;
E.weekTitle.value = data.week.title || "";
E.weekProject.value = data.week.project || "";
E.weekOutput.value = data.week.output || "";
weekFiles = data.files || {};
weekFile = Object.keys(weekFiles)[0] || "";
renderWeekFiles();
}
function saveWeekFileBuffer() {
if (weekFile) weekFiles[weekFile] = E.weekFileContent.value;
}
function renderWeekFiles() {
E.weekFiles.innerHTML = Object.keys(weekFiles).sort().map(path => `
<div class="admin-file-row ${path === weekFile ? "active" : ""}"><button type="button" data-open="${esc(path)}">${esc(path)}</button><button class="remove-file" type="button" data-remove="${esc(path)}" aria-label="移除 ${esc(path)}">×</button></div>`).join("") || '<div class="admin-empty">此 Lesson 目前沒有套版。學生會看到「尚未發布套版」。</div>';
E.weekFiles.querySelectorAll("[data-open]").forEach(button => button.onclick = () => {
saveWeekFileBuffer(); weekFile = button.dataset.open; renderWeekFiles();
});
E.weekFiles.querySelectorAll("[data-remove]").forEach(button => button.onclick = () => {
const path = button.dataset.remove;
delete weekFiles[path];
if (weekFile === path) weekFile = Object.keys(weekFiles)[0] || "";
renderWeekFiles();
});
E.weekFileLabel.textContent = weekFile || "尚未選擇檔案";
E.weekFileContent.disabled = !weekFile;
E.weekFileContent.value = weekFile ? weekFiles[weekFile] : "";
}
function askFileName(existing) {
const path = (prompt("檔案名稱(例如 main.py 或 data/case.json):") || "").trim().replaceAll("\\", "/");
if (!/^[\w\-\u4e00-\u9fff/]+\.(py|html|css|js|json|txt|csv|md|toml|yaml|yml)$/i.test(path) || path.includes("..")) {
if (path) toast("檔名或副檔名不符合規則");
return "";
}
if (path in existing) { toast("檔案已存在"); return ""; }
return path;
}
E.newWeekFile.onclick = () => {
saveWeekFileBuffer();
const path = askFileName(weekFiles);
if (!path) return;
weekFiles[path] = path.endsWith(".py") ? "# Lesson Python 套版\n" : "";
weekFile = path;
renderWeekFiles();
};
E.weekForm.onsubmit = async event => {
event.preventDefault();
saveWeekFileBuffer();
setBusy(E.saveWeekButton, true, "儲存中…");
try {
await request("/api/admin/week/save", {
week_id: selectedWeek,
title: E.weekTitle.value,
project: E.weekProject.value,
output: E.weekOutput.value,
files: weekFiles
});
const week = state.course.weeks.find(item => item.id === selectedWeek);
Object.assign(week, { title: E.weekTitle.value.trim(), project: E.weekProject.value.trim(), output: E.weekOutput.value.trim(), template: Object.keys(weekFiles).length > 0 });
E.weekHeading.textContent = `Lesson ${week.lesson ?? week.week}|${week.title}`;
renderWeeks();
toast("課程內容已儲存,Space 正在重新建置");
} catch (error) { toast(error.message); }
finally { setBusy(E.saveWeekButton, false); }
};
function demoKey(item) { return `${item.class_name}\n${item.student_name}\n${item.project_name}`; }
const sortedUnique = values => [...new Set(values)].sort((a, b) => a.localeCompare(b, "zh-Hant"));
function optionList(values, placeholder) {
return `<option value="">${placeholder}</option>` + values.map(value => `<option value="${esc(value)}">${esc(value)}</option>`).join("");
}
function renderDemoPicker(selection = null) {
const classes = sortedUnique(state.demo_projects.map(item => item.class_name));
E.demoTotal.textContent = `${classes.length} 門課程 · ${state.demo_projects.length} 個專題`;
E.demoClassSelect.innerHTML = optionList(classes, "請選擇課程");
E.demoClassSelect.value = selection?.class_name || "";
renderStudentPicker(selection?.student_name || "", selection?.project_name || "");
}
function renderStudentPicker(studentName = "", projectName = "") {
const className = E.demoClassSelect.value;
const students = sortedUnique(state.demo_projects.filter(item => item.class_name === className).map(item => item.student_name));
E.demoStudentSelect.innerHTML = optionList(students, className ? "請選擇學生" : "請先選課程");
E.demoStudentSelect.disabled = !className;
E.demoStudentSelect.value = students.includes(studentName) ? studentName : "";
renderProjectPicker(projectName);
}
function renderProjectPicker(projectName = "") {
const className = E.demoClassSelect.value;
const studentName = E.demoStudentSelect.value;
const projects = state.demo_projects.filter(item => item.class_name === className && item.student_name === studentName);
E.demoProjectSelect.innerHTML = optionList(projects.map(item => item.project_name), studentName ? "請選擇專題" : "請先選學生");
E.demoProjectSelect.disabled = !studentName;
E.demoProjectSelect.value = projects.some(item => item.project_name === projectName) ? projectName : "";
if (!E.demoProjectSelect.value) E.demoPickerSummary.innerHTML = `<strong>${studentName ? "請選擇專題" : className ? "請選擇學生" : "尚未選擇專題"}</strong><p>選好三個層級後,右側會載入可編輯的程式檔案。</p>`;
}
function resetDemoEditor() {
originalDemo = null;
demoFiles = {};
demoAssets = [];
demoFile = "";
E.demoHeading.textContent = "選擇一個 Demo 專題";
[E.demoClass, E.demoStudent, E.demoProject].forEach(input => input.value = "");
enableDemoForm(false);
renderDemoFiles();
}
E.demoClassSelect.onchange = () => { resetDemoEditor(); renderStudentPicker(); };
E.demoStudentSelect.onchange = () => { resetDemoEditor(); renderProjectPicker(); };
E.demoProjectSelect.onchange = () => {
const item = state.demo_projects.find(project => project.class_name === E.demoClassSelect.value && project.student_name === E.demoStudentSelect.value && project.project_name === E.demoProjectSelect.value);
if (item) selectDemo(item);
};
function enableDemoForm(enabled) {
[E.demoClass, E.demoStudent, E.demoProject, E.newDemoFile, E.saveDemoButton].forEach(element => element.disabled = !enabled);
E.deleteDemoButton.disabled = !enabled || !originalDemo;
}
async function selectDemo(item) {
saveDemoFileBuffer();
originalDemo = { class_name: item.class_name, student_name: item.student_name, project_name: item.project_name };
const data = await request("/api/admin/demo/project", { project: originalDemo });
E.demoClass.value = item.class_name;
E.demoStudent.value = item.student_name;
E.demoProject.value = item.project_name;
E.demoHeading.textContent = item.project_name;
demoFiles = data.files || {};
demoAssets = data.assets || [];
demoFile = Object.keys(demoFiles)[0] || "";
E.demoPickerSummary.innerHTML = `<strong>${esc(item.project_name)}</strong><p>${esc(item.class_name)} → ${esc(item.student_name)} → ${esc(item.type)}</p>`;
enableDemoForm(true);
renderDemoFiles();
}
function saveDemoFileBuffer() {
if (demoFile) demoFiles[demoFile] = E.demoFileContent.value;
}
function renderDemoFiles() {
E.demoFiles.innerHTML = Object.keys(demoFiles).sort().map(path => `
<div class="admin-file-row ${path === demoFile ? "active" : ""}"><button type="button" data-open="${esc(path)}">${esc(path)}</button><button class="remove-file" type="button" data-remove="${esc(path)}" aria-label="移除 ${esc(path)}">×</button></div>`).join("") || '<div class="admin-empty">尚無文字程式檔案。</div>';
E.demoFiles.querySelectorAll("[data-open]").forEach(button => button.onclick = () => {
saveDemoFileBuffer(); demoFile = button.dataset.open; renderDemoFiles();
});
E.demoFiles.querySelectorAll("[data-remove]").forEach(button => button.onclick = () => {
const path = button.dataset.remove;
delete demoFiles[path];
if (demoFile === path) demoFile = Object.keys(demoFiles)[0] || "";
renderDemoFiles();
});
E.demoFileLabel.textContent = demoFile || "尚未選擇檔案";
E.demoFileContent.disabled = !demoFile;
E.demoFileContent.value = demoFile ? demoFiles[demoFile] : "";
E.demoAssets.textContent = demoAssets.length ? `保留 ${demoAssets.length} 個非文字資產:${demoAssets.join("、")}` : "沒有圖片或音效資產。";
}
E.newDemoButton.onclick = () => {
const pickedClass = E.demoClassSelect.value;
const pickedStudent = E.demoStudentSelect.value;
originalDemo = null;
demoFiles = { "main.py": "" };
demoAssets = [];
demoFile = "main.py";
E.demoClass.value = pickedClass || "";
E.demoStudent.value = pickedStudent || "";
E.demoProject.value = "";
E.demoHeading.textContent = "建立新 Demo 專題";
enableDemoForm(true);
E.demoPickerSummary.innerHTML = `<strong>建立新專題</strong><p>會建立在右側填寫的課程與學生之下。</p>`;
renderDemoFiles();
E.demoStudent.focus();
};
E.newDemoFile.onclick = () => {
saveDemoFileBuffer();
const path = askFileName(demoFiles);
if (!path) return;
demoFiles[path] = "";
demoFile = path;
renderDemoFiles();
};
E.demoForm.onsubmit = async event => {
event.preventDefault();
saveDemoFileBuffer();
setBusy(E.saveDemoButton, true, "儲存中…");
try {
const data = await request("/api/admin/demo/save", {
original: originalDemo,
class_name: E.demoClass.value,
student_name: E.demoStudent.value,
project_name: E.demoProject.value,
files: demoFiles
});
if (originalDemo) state.demo_projects = state.demo_projects.filter(item => demoKey(item) !== demoKey(originalDemo));
originalDemo = data.project;
state.demo_projects.push({ ...data.project, type: "main.py" in demoFiles ? "python" : "index.html" in demoFiles ? "html" : "external" });
E.demoHeading.textContent = data.project.project_name;
renderDemoPicker(originalDemo);
E.demoPickerSummary.innerHTML = `<strong>${esc(data.project.project_name)}</strong><p>${esc(data.project.class_name)} → ${esc(data.project.student_name)}</p>`;
enableDemoForm(true);
toast("Demo 專題已儲存,Space 正在重新建置");
} catch (error) { toast(error.message); }
finally { setBusy(E.saveDemoButton, false); }
};
E.deleteDemoButton.onclick = async () => {
if (!originalDemo || !confirm(`永久刪除 Demo 專題「${originalDemo.project_name}」?\n\n這會移除程式、圖片與音效,無法從管理頁復原。`)) return;
setBusy(E.deleteDemoButton, true, "刪除中…");
try {
await request("/api/admin/demo/delete", { project: originalDemo });
state.demo_projects = state.demo_projects.filter(item => demoKey(item) !== demoKey(originalDemo));
originalDemo = null;
demoFiles = {};
demoAssets = [];
demoFile = "";
E.demoHeading.textContent = "選擇一個 Demo 專題";
[E.demoClass, E.demoStudent, E.demoProject].forEach(input => input.value = "");
enableDemoForm(false);
renderDemoPicker();
renderDemoFiles();
toast("Demo 專題已刪除,Space 正在重新建置");
} catch (error) { toast(error.message); enableDemoForm(true); }
};
async function refreshClassrooms(classCode = selectedClassroom, studentId = selectedClassroomStudent) {
const next = await request("/api/admin/state");
state.classrooms = next.classrooms;
selectedClassroom = state.classrooms.courses.some(item => item.class_code === classCode) ? classCode : (state.classrooms.courses[0]?.class_code || "");
selectedClassroomStudent = studentId;
renderClassrooms();
}
function currentClassroom() {
return state.classrooms.courses.find(item => item.class_code === selectedClassroom);
}
function renderClassrooms() {
const courses = state.classrooms.courses || [];
if (selectedClassroom !== "__new__" && !courses.some(item => item.class_code === selectedClassroom)) selectedClassroom = courses[0]?.class_code || "";
E.classroomTotal.textContent = `${courses.length} 個班級`;
E.classroomList.innerHTML = courses.map(item => {
const submissions = state.classrooms.submissions.filter(entry => entry.class_code === item.class_code).length;
return `<button class="admin-list-button ${item.class_code === selectedClassroom ? "active" : ""}" data-classroom="${esc(item.class_code)}"><div><strong>${esc(item.course_name)}</strong><small>${esc(item.class_code)}</small></div><span>${item.students.length} 人 · ${submissions} 份</span></button>`;
}).join("") || '<div class="admin-empty">尚未建立班級。</div>';
E.classroomList.querySelectorAll("[data-classroom]").forEach(button => button.onclick = () => {
selectedClassroom = button.dataset.classroom;
selectedClassroomStudent = "";
editingClassroomStudent = "";
selectedSubmission = null;
renderClassrooms();
});
renderClassroomDetail();
}
function renderClassroomDetail() {
const course = currentClassroom();
const enabled = Boolean(course);
E.classroomHeading.textContent = course ? course.course_name : (selectedClassroom === "__new__" ? "建立新班級" : "選擇班級");
E.classroomCode.disabled = selectedClassroom !== "__new__";
E.classroomName.disabled = !enabled && selectedClassroom !== "__new__";
E.saveClassroomButton.disabled = !enabled && selectedClassroom !== "__new__";
E.deleteClassroomButton.disabled = !enabled;
E.saveClassroomButton.textContent = selectedClassroom === "__new__" ? "建立班級" : "儲存班級名稱";
E.saveClassroomButton.dataset.label = E.saveClassroomButton.textContent;
[E.backupWeek, E.backupSubmissionsButton, E.adminStudentNames, E.adminAddStudentsButton].forEach(element => element.disabled = !enabled);
if (selectedClassroom === "__new__") {
E.classroomCode.value = "";
E.classroomName.value = "";
} else {
E.classroomCode.value = course?.class_code || "";
E.classroomName.value = course?.course_name || "";
}
renderClassroomStudents();
}
function renderClassroomStudents() {
const members = currentClassroom()?.students || [];
if (selectedClassroomStudent && !members.some(item => item.student_id === selectedClassroomStudent)) selectedClassroomStudent = "";
if (editingClassroomStudent && !members.some(item => item.student_id === editingClassroomStudent)) editingClassroomStudent = "";
E.adminStudentTotal.textContent = `${members.length} 人`;
E.adminStudentList.innerHTML = members.length ? `<button class="manager-person ${selectedClassroomStudent ? "" : "active"}" data-student="">全部學生</button>` + members.map(member => {
if (member.student_id === editingClassroomStudent) return `<form class="student-edit-form" data-student-edit-form="${esc(member.student_id)}"><input class="text-field" name="display_name" aria-label="學生姓名" maxlength="80" value="${esc(member.display_name)}" required><button class="btn compact" type="submit">儲存</button><button class="copy-button" type="button" data-cancel-student-edit>取消</button><p class="student-edit-error" role="alert"></p></form>`;
return `<div class="manager-person-row"><button class="manager-person ${member.student_id === selectedClassroomStudent ? "active" : ""}" data-student="${esc(member.student_id)}">${esc(member.display_name)}</button><button class="copy-button student-edit-button" type="button" data-edit-student="${esc(member.student_id)}" aria-label="編輯 ${esc(member.display_name)} 的姓名">改名</button><button class="remove-file" type="button" data-remove-student="${esc(member.student_id)}" aria-label="刪除 ${esc(member.display_name)}">×</button></div>`;
}).join("") : '<div class="admin-empty">這個班級尚無學生。</div>';
E.adminStudentList.querySelectorAll("[data-student]").forEach(button => button.onclick = () => { selectedClassroomStudent = button.dataset.student; selectedSubmission = null; renderClassroomStudents(); });
E.adminStudentList.querySelectorAll("[data-remove-student]").forEach(button => button.onclick = () => removeClassroomStudent(button.dataset.removeStudent));
E.adminStudentList.querySelectorAll("[data-edit-student]").forEach(button => button.onclick = () => {
editingClassroomStudent = button.dataset.editStudent;
renderClassroomStudents();
const input = E.adminStudentList.querySelector('[data-student-edit-form] input[name="display_name"]');
input?.focus(); input?.select();
});
E.adminStudentList.querySelectorAll("[data-cancel-student-edit]").forEach(button => button.onclick = () => { editingClassroomStudent = ""; renderClassroomStudents(); });
E.adminStudentList.querySelectorAll("[data-student-edit-form]").forEach(form => form.onsubmit = event => renameClassroomStudent(event, form));
renderSubmissions();
}
function visibleSubmissions() {
return state.classrooms.submissions.filter(item => item.class_code === selectedClassroom && (!selectedClassroomStudent || item.student_id === selectedClassroomStudent));
}
function renderSubmissions() {
const entries = visibleSubmissions();
const student = currentClassroom()?.students.find(item => item.student_id === selectedClassroomStudent);
E.submissionHeading.textContent = student ? `${student.display_name}的提交` : "已提交內容";
E.submissionTotal.textContent = `${entries.length} 份`;
E.submissionList.innerHTML = entries.map((item, index) => `<button class="submission-row ${selectedSubmission && item.student_id === selectedSubmission.student_id && item.week_id === selectedSubmission.week_id ? "active" : ""}" data-submission="${index}"><strong>${esc(item.display_name)} · ${esc(item.week_id)}</strong><span>${esc(item.project_name)} · ${item.file_count} 檔</span></button>`).join("") || '<div class="admin-empty">目前沒有已提交內容。</div>';
E.submissionList.querySelectorAll("[data-submission]").forEach(button => button.onclick = () => selectSubmission(entries[Number(button.dataset.submission)]));
if (!selectedSubmission || !entries.some(item => item.student_id === selectedSubmission.student_id && item.week_id === selectedSubmission.week_id)) resetSubmissionDetail();
}
async function selectSubmission(item) {
selectedSubmission = item;
const data = await request("/api/admin/classroom/submission", item);
submissionFiles = data.submission.files || {};
submissionFile = Object.keys(submissionFiles)[0] || "";
E.submissionProject.textContent = item.project_name;
E.submissionMeta.textContent = `${item.display_name} · ${item.week_id} · ${new Date(item.submitted_at).toLocaleString("zh-TW")}`;
E.deleteSubmissionButton.disabled = false;
renderSubmissionFiles();
renderSubmissions();
}
function renderSubmissionFiles() {
E.submissionFiles.innerHTML = Object.keys(submissionFiles).map(path => `<button class="${path === submissionFile ? "active" : ""}" data-submission-file="${esc(path)}">${esc(path)}</button>`).join("");
E.submissionFiles.querySelectorAll("[data-submission-file]").forEach(button => button.onclick = () => { submissionFile = button.dataset.submissionFile; renderSubmissionFiles(); });
E.submissionCode.textContent = submissionFile ? submissionFiles[submissionFile] : "這份提交沒有可顯示的檔案。";
}
function resetSubmissionDetail() {
selectedSubmission = null;
submissionFiles = {};
submissionFile = "";
E.submissionProject.textContent = "尚未選擇提交";
E.submissionMeta.textContent = "";
E.submissionFiles.innerHTML = "";
E.submissionCode.textContent = "選擇一份提交查看內容。";
E.deleteSubmissionButton.disabled = true;
}
E.newClassroomButton.onclick = () => {
selectedClassroom = "__new__";
selectedClassroomStudent = "";
editingClassroomStudent = "";
resetSubmissionDetail();
renderClassrooms();
E.classroomCode.focus();
};
E.saveClassroomButton.onclick = async () => {
const code = E.classroomCode.value.trim();
const name = E.classroomName.value.trim();
if (!code || !name) return toast("請輸入班級代碼與名稱");
setBusy(E.saveClassroomButton, true, "儲存中…");
try {
const method = selectedClassroom === "__new__" ? "POST" : "PUT";
const data = await request("/api/classroom/classes", { class_code: code, course_name: name }, method);
selectedClassroom = code;
await refreshClassrooms(code);
toast(data.warning || "班級已儲存,Space 正在重新建置");
} catch (error) { toast(error.message); }
finally { setBusy(E.saveClassroomButton, false); }
};
E.deleteClassroomButton.onclick = async () => {
const course = currentClassroom();
if (!course || !confirm(`刪除班級「${course.course_name}」?\n\n學生名單、教室進度與提交封存都會被移除。`)) return;
setBusy(E.deleteClassroomButton, true, "刪除中…");
try { const data = await request("/api/classroom/classes", { class_code: course.class_code }, "DELETE"); selectedClassroom = ""; await refreshClassrooms(); toast(data.warning || "班級已刪除"); }
catch (error) { toast(error.message); }
finally { setBusy(E.deleteClassroomButton, false); }
};
E.adminStudentForm.onsubmit = async event => {
event.preventDefault();
const names = E.adminStudentNames.value.split(/[,,\n]+/).map(value => value.trim()).filter(Boolean);
if (!names.length) return toast("請輸入學生姓名");
setBusy(E.adminAddStudentsButton, true, "加入中…");
try { const data = await request("/api/classroom/students", { class_code: selectedClassroom, display_names: names }); E.adminStudentNames.value = ""; await refreshClassrooms(selectedClassroom); toast(data.warning || `已加入 ${data.created.length} 位學生`); }
catch (error) { toast(error.message); }
finally { setBusy(E.adminAddStudentsButton, false); }
};
async function renameClassroomStudent(event, form) {
event.preventDefault();
const studentId = form.dataset.studentEditForm;
const displayName = form.querySelector('[name="display_name"]').value.trim();
const errorLabel = form.querySelector(".student-edit-error");
if (!displayName) { errorLabel.textContent = "請輸入學生姓名"; return; }
const button = form.querySelector('button[type="submit"]');
const previousSubmission = selectedSubmission;
setBusy(button, true, "儲存中…");
errorLabel.textContent = "";
try {
const data = await request("/api/classroom/students", { class_code: selectedClassroom, student_id: studentId, display_name: displayName }, "PUT");
editingClassroomStudent = "";
await refreshClassrooms(selectedClassroom, studentId);
if (previousSubmission?.student_id === studentId) {
const updated = visibleSubmissions().find(item => item.student_id === studentId && item.week_id === previousSubmission.week_id);
if (updated) await selectSubmission(updated);
else resetSubmissionDetail();
}
toast(data.warning || "學生姓名已更新;既有作品與進度已保留");
} catch (error) {
errorLabel.textContent = error.message;
} finally { setBusy(button, false); }
}
async function removeClassroomStudent(studentId) {
const member = currentClassroom()?.students.find(item => item.student_id === studentId);
if (!member || !confirm(`從這個班級刪除「${member.display_name}」?\n\n教室進度與提交封存也會被移除。`)) return;
try { const data = await request("/api/classroom/students", { class_code: selectedClassroom, student_id: studentId }, "DELETE"); selectedClassroomStudent = ""; await refreshClassrooms(selectedClassroom); toast(data.warning || "學生已刪除"); }
catch (error) { toast(error.message); }
}
E.backupSubmissionsButton.onclick = async () => {
setBusy(E.backupSubmissionsButton, true, "備份中…");
try { const data = await request("/api/admin/classroom/backup", { class_code: selectedClassroom, week_id: E.backupWeek.value }); toast(`已備份 ${data.count} 份提交,Space 正在重新建置`); }
catch (error) { toast(error.message); }
finally { setBusy(E.backupSubmissionsButton, false); }
};
E.deleteSubmissionButton.onclick = async () => {
if (!selectedSubmission || !confirm(`刪除 ${selectedSubmission.display_name} 的 ${selectedSubmission.week_id} 正式提交?`)) return;
setBusy(E.deleteSubmissionButton, true, "刪除中…");
try { await request("/api/admin/classroom/submission/delete", selectedSubmission); resetSubmissionDetail(); await refreshClassrooms(selectedClassroom, selectedClassroomStudent); toast("已刪除提交"); }
catch (error) { toast(error.message); }
finally { setBusy(E.deleteSubmissionButton, false); }
};
E.adminLoginDialog.showModal();