Spaces:
Running
Running
Download classroom_static/admin.js from Telearn/Project_Demo: direct link, hf CLI and curl.
- Browser
- Download file 29 kB
-
https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_static/admin.js
- Command line
-
hf download hf://spaces/Telearn/Project_Demo/classroom_static/admin.js
-
curl -L -o admin.js https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_static/admin.js
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); | |
| 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(); | |