(function () { const bootstrap = window.__ADMIN_BOOTSTRAP__ || {}; const state = { adminPage: bootstrap.adminPage || "schedule", courses: bootstrap.courses || [], categories: bootstrap.categories || [], scheduleSettings: bootstrap.scheduleSettings || {}, defaultTimeSlots: bootstrap.defaultTimeSlots || [], scheduleEditor: { segments: [], interaction: null, dragKind: null, pixelsPerMinute: 2.25, }, }; const toastStack = document.getElementById("toastStack"); const scheduleSettingsForm = document.getElementById("scheduleSettingsForm"); const scheduleEditorAxis = document.getElementById("scheduleEditorAxis"); const scheduleEditorTrack = document.getElementById("scheduleEditorTrack"); const scheduleEditorDropzone = document.getElementById("scheduleEditorDropzone"); const scheduleSegmentCount = document.getElementById("scheduleSegmentCount"); const resetTimelineButton = document.getElementById("resetTimelineButton"); const schedulePalette = document.getElementById("schedulePalette"); const createCategoryForm = document.getElementById("createCategoryForm"); const adminGrid = document.getElementById("adminGrid"); const courseGrid = document.getElementById("courseGrid"); const courseForm = document.getElementById("courseForm"); const resetCourseEditorButton = document.getElementById("resetCourseEditorButton"); const courseEditorHeading = document.getElementById("courseEditorHeading"); if (!toastStack) { return; } function showToast(message, kind = "success") { const toast = document.createElement("div"); toast.className = `toast ${kind}`; toast.textContent = message; toastStack.appendChild(toast); window.setTimeout(() => toast.remove(), 2600); } async function requestJSON(url, options = {}) { const response = await fetch(url, { headers: { "Content-Type": "application/json", }, ...options, }); const payload = await response.json().catch(() => ({ ok: false, error: "请求失败" })); if (!response.ok || !payload.ok) { throw new Error(payload.error || "请求失败"); } return payload; } function toMinutes(value) { const [hour, minute] = String(value).split(":").map(Number); return (hour * 60) + minute; } function minutesToTime(totalMinutes) { const hour = Math.floor(totalMinutes / 60); const minute = totalMinutes % 60; return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; } function snapMinutes(value, step = 5) { return Math.round(value / step) * step; } function formatWeekPattern(pattern) { if (pattern === "odd") { return "单周"; } if (pattern === "even") { return "双周"; } return "每周"; } function weekdayLabel(value) { return ["一", "二", "三", "四", "五", "六", "日"][Number(value) - 1] || ""; } function buildSlotLabel(index) { return `第${String(index + 1).padStart(2, "0")}节课`; } function getConfiguredTimeSlots() { const slots = Array.isArray(state.scheduleSettings.time_slots) && state.scheduleSettings.time_slots.length ? state.scheduleSettings.time_slots : state.defaultTimeSlots; return slots.map((slot, index) => ({ label: buildSlotLabel(index), start: slot.start, end: slot.end, })); } function buildScheduleSegmentsFromSlots(timeSlots) { const slots = (timeSlots || []).map((slot, index) => ({ kind: "class", label: buildSlotLabel(index), durationMinutes: Math.max(25, toMinutes(slot.end) - toMinutes(slot.start)), })); const segments = []; slots.forEach((slot, index) => { if (index > 0) { const previous = timeSlots[index - 1]; const current = timeSlots[index]; const gap = Math.max(5, toMinutes(current.start) - toMinutes(previous.end)); segments.push({ id: `break-${index}`, kind: "break", label: `课间 ${index}`, durationMinutes: gap, }); } segments.push({ id: `class-${index + 1}`, kind: "class", label: slot.label, durationMinutes: slot.durationMinutes, }); }); return segments; } function renumberSegments(segments) { let classIndex = 1; let breakIndex = 1; segments.forEach((segment) => { if (segment.kind === "class") { segment.label = buildSlotLabel(classIndex - 1); classIndex += 1; } else { segment.label = `课间 ${breakIndex}`; breakIndex += 1; } }); } function reflowSegments() { const dayStartInput = document.getElementById("dayStartInput"); if (!dayStartInput) { return; } const baseStart = toMinutes(dayStartInput.value); let cursor = baseStart; renumberSegments(state.scheduleEditor.segments); state.scheduleEditor.segments.forEach((segment) => { segment.startMinutes = cursor; segment.endMinutes = cursor + segment.durationMinutes; cursor = segment.endMinutes; }); } function getEditorPixelsPerMinute() { return state.scheduleEditor.pixelsPerMinute || 1.65; } function segmentMinDuration(segment) { return segment.kind === "break" ? 5 : 25; } function validateSegmentOrder(segments) { if (!segments.length) { return "请至少保留一节课"; } if (segments[0].kind !== "class") { return "时间表必须从课程开始"; } if (segments[segments.length - 1].kind !== "class") { return "时间表必须以课程结束"; } for (let index = 1; index < segments.length; index += 1) { if (segments[index].kind === segments[index - 1].kind) { return "课程后面必须是休息,休息后面必须是课程"; } } return null; } function buildScheduleAxisMarks() { const segments = state.scheduleEditor.segments; if (!segments.length) { return []; } const marks = new Set(); marks.add(segments[0].startMinutes); marks.add(segments[segments.length - 1].endMinutes); segments.forEach((segment) => { marks.add(segment.startMinutes); marks.add(segment.endMinutes); }); let hour = Math.floor(segments[0].startMinutes / 60) * 60; while (hour <= segments[segments.length - 1].endMinutes) { marks.add(hour); hour += 60; } return Array.from(marks).sort((left, right) => left - right); } function renderScheduleEditor() { if (!scheduleEditorAxis || !scheduleEditorTrack || !scheduleEditorDropzone) { return; } reflowSegments(); const segments = state.scheduleEditor.segments; if (!segments.length) { scheduleEditorAxis.innerHTML = ""; scheduleEditorTrack.innerHTML = ""; scheduleEditorTrack.appendChild(scheduleEditorDropzone); if (scheduleSegmentCount) { scheduleSegmentCount.textContent = "0 段"; } return; } const startMinutes = segments[0].startMinutes; const endMinutes = segments[segments.length - 1].endMinutes; const totalMinutes = Math.max(endMinutes - startMinutes, 1); const contentHeight = Math.max(Math.round(totalMinutes * getEditorPixelsPerMinute()), 980); const dropzoneTop = contentHeight + 20; const height = dropzoneTop + 96; scheduleEditorAxis.innerHTML = ""; scheduleEditorTrack.innerHTML = ""; scheduleEditorAxis.style.height = `${height}px`; scheduleEditorTrack.style.height = `${height}px`; const marks = buildScheduleAxisMarks(); const visibleMarks = []; marks.forEach((minute) => { const top = Math.round((minute - startMinutes) * getEditorPixelsPerMinute()); const line = document.createElement("div"); line.className = "schedule-editor-line"; line.style.top = `${top}px`; scheduleEditorTrack.appendChild(line); }); marks.forEach((minute, index) => { const top = Math.round((minute - startMinutes) * getEditorPixelsPerMinute()); const isEdge = index === 0 || index === marks.length - 1; const previous = visibleMarks[visibleMarks.length - 1]; if (isEdge && previous && top - previous.top < 42) { visibleMarks.pop(); } if (!previous || isEdge || top - (visibleMarks[visibleMarks.length - 1]?.top ?? -Infinity) >= 42) { visibleMarks.push({ minute, top, index }); } }); visibleMarks.forEach(({ minute, top, index }) => { const tick = document.createElement("div"); tick.className = "schedule-editor-tick"; if (index === 0) { tick.classList.add("is-leading"); } else if (index === marks.length - 1) { tick.classList.add("is-terminal"); } tick.style.top = `${top}px`; tick.textContent = minutesToTime(minute); scheduleEditorAxis.appendChild(tick); }); segments.forEach((segment, index) => { const minimumHeight = segment.kind === "break" ? 16 : 42; const rawHeight = Math.max(Math.round(segment.durationMinutes * getEditorPixelsPerMinute()), minimumHeight); const inset = rawHeight > 80 ? 4 : rawHeight > 56 ? 3 : 2; const segmentHeight = Math.max(rawHeight - (inset * 2), 16); const block = document.createElement("article"); block.className = `schedule-editor-segment ${segment.kind}`; block.style.top = `${Math.round((segment.startMinutes - startMinutes) * getEditorPixelsPerMinute()) + inset}px`; block.style.height = `${segmentHeight}px`; block.classList.toggle("is-compact", segmentHeight < 88); block.classList.toggle("is-tight", segmentHeight < 56); block.innerHTML = `
${segment.label} ${minutesToTime(segment.startMinutes)} - ${minutesToTime(segment.endMinutes)}
${segment.kind === "class" ? "上课" : "课间"}
${segment.kind === "class" ? `` : ""} `; scheduleEditorTrack.appendChild(block); }); scheduleEditorDropzone.style.top = `${dropzoneTop}px`; scheduleEditorDropzone.style.bottom = "auto"; scheduleEditorTrack.appendChild(scheduleEditorDropzone); if (scheduleSegmentCount) { scheduleSegmentCount.textContent = `${segments.length} 段`; } } function appendSegment(kind) { const segments = state.scheduleEditor.segments; if (!segments.length && kind !== "class") { showToast("时间表必须从课程开始", "error"); return; } if (segments.length && segments[segments.length - 1].kind === kind) { showToast("课程后面必须是休息,休息后面必须是课程", "error"); return; } segments.push({ id: `${kind}-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`, kind, label: kind === "class" ? "新课程" : "新课间", durationMinutes: kind === "class" ? 45 : 10, }); renderScheduleEditor(); } function deleteClassSegment(index) { const segments = [...state.scheduleEditor.segments]; const target = segments[index]; if (!target || target.kind !== "class") { return; } if (index === 0) { segments.splice(index, segments[index + 1]?.kind === "break" ? 2 : 1); } else if (index === segments.length - 1) { segments.splice(index - 1, segments[index - 1]?.kind === "break" ? 2 : 1); } else { segments.splice(index, 1); if (segments[index - 1] && segments[index] && segments[index - 1].kind === "break" && segments[index].kind === "break") { segments[index - 1].durationMinutes += segments[index].durationMinutes; segments.splice(index, 1); } } state.scheduleEditor.segments = segments.filter(Boolean); renderScheduleEditor(); } function beginScheduleResize(event, index) { const segment = state.scheduleEditor.segments[index]; if (!segment) { return; } event.preventDefault(); state.scheduleEditor.interaction = { index, pointerId: event.pointerId, startY: event.clientY, snapshot: state.scheduleEditor.segments.map((item) => ({ ...item })), }; } function updateScheduleResize(clientY) { const interaction = state.scheduleEditor.interaction; if (!interaction) { return; } const segments = interaction.snapshot.map((item) => ({ ...item })); const target = segments[interaction.index]; const deltaMinutes = snapMinutes((clientY - interaction.startY) / getEditorPixelsPerMinute()); target.durationMinutes = Math.max(segmentMinDuration(target), target.durationMinutes + deltaMinutes); state.scheduleEditor.segments = segments; renderScheduleEditor(); } function endScheduleResize() { state.scheduleEditor.interaction = null; } function collectSchedulePayload() { const error = validateSegmentOrder(state.scheduleEditor.segments); if (error) { throw new Error(error); } reflowSegments(); const classSegments = state.scheduleEditor.segments.filter((segment) => segment.kind === "class"); const dayStart = document.getElementById("dayStartInput").value; const dayEnd = minutesToTime(classSegments[classSegments.length - 1].endMinutes); document.getElementById("dayEndInput").value = dayEnd; return { semester_start: document.getElementById("semesterStartInput").value, day_start: dayStart, day_end: dayEnd, default_task_duration_minutes: Number(document.getElementById("defaultDurationInput").value), time_slots: classSegments.map((segment, index) => ({ label: buildSlotLabel(index), start: minutesToTime(segment.startMinutes), end: minutesToTime(segment.endMinutes), })), }; } function resetScheduleEditor(useDefault) { const source = useDefault ? state.defaultTimeSlots : getConfiguredTimeSlots(); state.scheduleEditor.segments = buildScheduleSegmentsFromSlots(source); renderScheduleEditor(); } function buildPeriodOptions() { return getConfiguredTimeSlots().map((slot, index) => ({ value: String(index + 1), label: `第${String(index + 1).padStart(2, "0")}节 · ${slot.start}-${slot.end}`, })); } function findPeriodByBoundary(boundary, edge) { const slots = getConfiguredTimeSlots(); const index = slots.findIndex((slot) => slot[edge] === boundary); return index >= 0 ? String(index + 1) : "1"; } function populateCoursePeriodOptions() { const startSelect = document.getElementById("courseStartPeriodInput"); const endSelect = document.getElementById("courseEndPeriodInput"); if (!startSelect || !endSelect) { return; } const options = buildPeriodOptions(); const markup = options.map((option) => ``).join(""); startSelect.innerHTML = markup; endSelect.innerHTML = markup; } function courseCard(course) { return `

Course

${course.title}

周${weekdayLabel(course.day_of_week)}

${course.start_time} - ${course.end_time} · 第 ${course.start_week}-${course.end_week} 周 · ${formatWeekPattern(course.week_pattern)}

${course.location || "未填写地点"}

`; } function renderCourses() { if (!courseGrid) { return; } if (!state.courses.length) { courseGrid.innerHTML = `

还没有固定课程

在左侧填写课程信息并保存后,周课表会自动按周显示。

`; return; } courseGrid.innerHTML = state.courses.map(courseCard).join(""); } function resetCourseForm(course = null) { const slots = getConfiguredTimeSlots(); document.getElementById("courseIdInput").value = course ? course.id : ""; document.getElementById("courseTitleInput").value = course ? course.title : ""; document.getElementById("courseLocationInput").value = course ? (course.location || "") : ""; document.getElementById("courseWeekdayInput").value = course ? String(course.day_of_week) : "1"; document.getElementById("courseStartPeriodInput").value = course ? findPeriodByBoundary(course.start_time, "start") : "1"; document.getElementById("courseEndPeriodInput").value = course ? findPeriodByBoundary(course.end_time, "end") : String(Math.min(2, slots.length || 1)); document.getElementById("courseStartWeekInput").value = course ? String(course.start_week) : "1"; document.getElementById("courseEndWeekInput").value = course ? String(course.end_week) : "16"; document.getElementById("courseWeekPatternInput").value = course ? course.week_pattern : "all"; if (courseEditorHeading) { courseEditorHeading.textContent = course ? "编辑课程" : "新增课程"; } } function collectCoursePayload() { const slots = getConfiguredTimeSlots(); const startPeriod = Number(document.getElementById("courseStartPeriodInput").value); const endPeriod = Number(document.getElementById("courseEndPeriodInput").value); if (endPeriod < startPeriod) { throw new Error("结束节次不能早于开始节次"); } const startSlot = slots[startPeriod - 1]; const endSlot = slots[endPeriod - 1]; if (!startSlot || !endSlot) { throw new Error("请选择有效的节次"); } return { title: document.getElementById("courseTitleInput").value.trim(), location: document.getElementById("courseLocationInput").value.trim(), day_of_week: Number(document.getElementById("courseWeekdayInput").value), start_time: startSlot.start, end_time: endSlot.end, start_week: Number(document.getElementById("courseStartWeekInput").value), end_week: Number(document.getElementById("courseEndWeekInput").value), week_pattern: document.getElementById("courseWeekPatternInput").value, }; } function categoryCard(category) { const taskCount = Array.isArray(category.tasks) ? category.tasks.length : Number(category.task_count || 0); return `

Category

${category.name}

删除分类会同时移除其下全部任务,请谨慎操作。

${taskCount} 项任务
`; } function renderCategories() { if (!adminGrid) { return; } adminGrid.innerHTML = state.categories.map(categoryCard).join(""); } function initSchedulePage() { if (!scheduleSettingsForm || !scheduleEditorAxis || !scheduleEditorTrack || !scheduleEditorDropzone) { return; } resetScheduleEditor(false); if (schedulePalette) { schedulePalette.addEventListener("dragstart", (event) => { const card = event.target.closest("[data-palette-kind]"); if (!card) { return; } state.scheduleEditor.dragKind = card.dataset.paletteKind; event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData("text/plain", state.scheduleEditor.dragKind); }); schedulePalette.addEventListener("dragend", () => { state.scheduleEditor.dragKind = null; }); } scheduleEditorTrack.addEventListener("dragover", (event) => { if (!state.scheduleEditor.dragKind) { return; } event.preventDefault(); scheduleEditorDropzone.classList.add("is-active"); }); scheduleEditorTrack.addEventListener("dragleave", (event) => { if (!scheduleEditorTrack.contains(event.relatedTarget)) { scheduleEditorDropzone.classList.remove("is-active"); } }); scheduleEditorTrack.addEventListener("drop", (event) => { if (!state.scheduleEditor.dragKind) { return; } event.preventDefault(); scheduleEditorDropzone.classList.remove("is-active"); appendSegment(state.scheduleEditor.dragKind); state.scheduleEditor.dragKind = null; }); scheduleEditorTrack.addEventListener("click", (event) => { const deleteButton = event.target.closest("[data-delete-segment]"); if (!deleteButton) { return; } deleteClassSegment(Number(deleteButton.dataset.deleteSegment)); }); scheduleEditorTrack.addEventListener("pointerdown", (event) => { const resizeHandle = event.target.closest("[data-resize-segment]"); if (!resizeHandle) { return; } beginScheduleResize(event, Number(resizeHandle.dataset.resizeSegment)); }); document.addEventListener("pointermove", (event) => { if (!state.scheduleEditor.interaction || event.pointerId !== state.scheduleEditor.interaction.pointerId) { return; } event.preventDefault(); updateScheduleResize(event.clientY); }); document.addEventListener("pointerup", (event) => { if (!state.scheduleEditor.interaction || event.pointerId !== state.scheduleEditor.interaction.pointerId) { return; } endScheduleResize(); }); document.addEventListener("pointercancel", endScheduleResize); if (resetTimelineButton) { resetTimelineButton.addEventListener("click", () => { resetScheduleEditor(true); showToast("已恢复默认节次,记得保存"); }); } document.getElementById("dayStartInput").addEventListener("change", renderScheduleEditor); scheduleSettingsForm.addEventListener("submit", async (event) => { event.preventDefault(); try { const payload = collectSchedulePayload(); const result = await requestJSON("/api/settings/schedule", { method: "PATCH", body: JSON.stringify(payload), }); state.scheduleSettings = result.settings; resetScheduleEditor(false); populateCoursePeriodOptions(); showToast("时间表设置已保存"); } catch (error) { showToast(error.message, "error"); } }); } function initListsPage() { if (!createCategoryForm || !adminGrid) { return; } renderCategories(); createCategoryForm.addEventListener("submit", async (event) => { event.preventDefault(); const nameInput = document.getElementById("newCategoryName"); const name = nameInput.value.trim(); try { const payload = await requestJSON("/api/categories", { method: "POST", body: JSON.stringify({ name }), }); state.categories = [...state.categories, payload.category]; renderCategories(); nameInput.value = ""; showToast("新清单已创建"); } catch (error) { showToast(error.message, "error"); } }); adminGrid.addEventListener("click", async (event) => { const button = event.target.closest("[data-delete-category]"); if (!button) { return; } try { await requestJSON(`/api/categories/${button.dataset.deleteCategory}`, { method: "DELETE", body: JSON.stringify({}), }); state.categories = state.categories.filter((category) => category.id !== button.dataset.deleteCategory); renderCategories(); showToast("清单已删除"); } catch (error) { showToast(error.message, "error"); } }); } function initCoursesPage() { if (!courseGrid || !courseForm) { return; } populateCoursePeriodOptions(); resetCourseForm(); renderCourses(); if (resetCourseEditorButton) { resetCourseEditorButton.addEventListener("click", () => resetCourseForm()); } courseForm.addEventListener("submit", async (event) => { event.preventDefault(); const courseId = document.getElementById("courseIdInput").value; try { const payload = collectCoursePayload(); const result = await requestJSON(courseId ? `/api/courses/${courseId}` : "/api/courses", { method: courseId ? "PATCH" : "POST", body: JSON.stringify(payload), }); if (courseId) { state.courses = state.courses.map((course) => (course.id === courseId ? result.course : course)); showToast("课程已更新"); } else { state.courses = [result.course, ...state.courses]; showToast("课程已创建"); } renderCourses(); resetCourseForm(); } catch (error) { showToast(error.message, "error"); } }); courseGrid.addEventListener("click", async (event) => { const editButton = event.target.closest("[data-edit-course]"); if (editButton) { const course = state.courses.find((item) => item.id === editButton.dataset.editCourse); if (course) { resetCourseForm(course); } return; } const deleteButton = event.target.closest("[data-delete-course]"); if (!deleteButton) { return; } try { await requestJSON(`/api/courses/${deleteButton.dataset.deleteCourse}`, { method: "DELETE", body: JSON.stringify({}), }); state.courses = state.courses.filter((course) => course.id !== deleteButton.dataset.deleteCourse); renderCourses(); resetCourseForm(); showToast("课程已删除"); } catch (error) { showToast(error.message, "error"); } }); } initSchedulePage(); initListsPage(); initCoursesPage(); })();