DRM / static /admin-v020.js
Codex
Fix layout spacing and schedule overlap
9cfe2aa
(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 = `
<div class="schedule-editor-segment-copy">
<strong>${segment.label}</strong>
<span>${minutesToTime(segment.startMinutes)} - ${minutesToTime(segment.endMinutes)}</span>
</div>
<div class="schedule-editor-segment-kind">${segment.kind === "class" ? "上课" : "课间"}</div>
${segment.kind === "class" ? `<button class="schedule-editor-delete" type="button" data-delete-segment="${index}">删除</button>` : ""}
<button class="schedule-editor-resize" type="button" data-resize-segment="${index}" aria-label="调整时长"></button>
`;
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) => `<option value="${option.value}">${option.label}</option>`).join("");
startSelect.innerHTML = markup;
endSelect.innerHTML = markup;
}
function courseCard(course) {
return `
<article class="admin-card admin-row-card course-card" data-course-id="${course.id}">
<div class="admin-card-head course-row-head">
<div>
<p class="column-label">Course</p>
<h2>${course.title}</h2>
</div>
<div class="course-row-actions">
<span class="task-count">周${weekdayLabel(course.day_of_week)}</span>
<button class="secondary-button" type="button" data-edit-course="${course.id}">编辑课程</button>
<button class="danger-button" type="button" data-delete-course="${course.id}">删除课程</button>
</div>
</div>
<p class="admin-card-copy">${course.start_time} - ${course.end_time} · 第 ${course.start_week}-${course.end_week} 周 · ${formatWeekPattern(course.week_pattern)}</p>
<p class="admin-card-copy">${course.location || "未填写地点"}</p>
</article>
`;
}
function renderCourses() {
if (!courseGrid) {
return;
}
if (!state.courses.length) {
courseGrid.innerHTML = `
<article class="admin-card empty-admin-card">
<h2>还没有固定课程</h2>
<p class="admin-card-copy">在左侧填写课程信息并保存后,周课表会自动按周显示。</p>
</article>
`;
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 `
<article class="admin-card admin-row-card list-row-card" data-category-id="${category.id}">
<div class="admin-row-main">
<div>
<p class="column-label">Category</p>
<h2>${category.name}</h2>
<p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
</div>
<div class="admin-row-side">
<span class="task-count">${taskCount} 项任务</span>
<button class="danger-button" type="button" data-delete-category="${category.id}">删除此清单</button>
</div>
</div>
</article>
`;
}
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();
})();