(function () { const bootstrap = window.__DRM_BOOTSTRAP__ || {}; const state = { authenticated: !!bootstrap.authenticated, planner: bootstrap.planner || {}, selectedDate: bootstrap.planner ? bootstrap.planner.selected_date : "", activePage: 0, dragTaskId: null, interaction: null, suppressClickUntil: 0, pixelsPerMinute: 0.58, }; const DEFAULT_PIXELS_PER_MINUTE = 0.58; const WEEK_HEADER_HEIGHT = 54; const AXIS_WIDTH = 78; const SLOT_WIDTH = 108; const CANVAS_GAP = 12; const SNAP_MINUTES = 5; const MIN_DURATION = 15; const CLICK_SUPPRESS_MS = 260; const pageTrack = document.getElementById("pageTrack"); const pageSlides = Array.from(document.querySelectorAll(".page-slide")); const plannerDateInput = document.getElementById("plannerDateInput"); const plannerPrevDay = document.getElementById("plannerPrevDay"); const plannerNextDay = document.getElementById("plannerNextDay"); const plannerDateLabel = document.getElementById("plannerDateLabel"); const plannerWeekday = document.getElementById("plannerWeekday"); const plannerAcademicWeek = document.getElementById("plannerAcademicWeek"); const plannerWindow = document.getElementById("plannerWindow"); const plannerHeadlineNote = document.getElementById("plannerHeadlineNote"); const plannerTaskCount = document.getElementById("plannerTaskCount"); const plannerTaskPool = document.getElementById("plannerTaskPool"); const plannerTimeline = document.getElementById("plannerTimeline"); const timelineScroll = document.getElementById("timelineScroll"); const loginModal = document.getElementById("loginModal"); const toastStack = document.getElementById("toastStack"); if (!pageTrack || !plannerDateInput || !plannerPrevDay || !plannerNextDay || !plannerTaskPool || !plannerTimeline || !timelineScroll) { return; } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function showToast(message, kind = "success") { if (!toastStack) { return; } const toast = document.createElement("div"); toast.className = `toast ${kind}`; toast.textContent = message; toastStack.appendChild(toast); window.setTimeout(() => toast.remove(), 2600); } function openLoginModal() { if (loginModal) { loginModal.classList.add("is-open"); } } function requireAuth() { if (!state.authenticated) { openLoginModal(); return false; } return true; } 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 clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function mixColor(progress) { if (progress <= 50) { return "#73d883"; } if (progress <= 80) { return "#ffc857"; } return "#ff6b5c"; } function toMinutes(value) { const [hour, minute] = String(value).split(":").map(Number); return (hour * 60) + minute; } function minutesToTime(value) { const hour = Math.floor(value / 60); const minute = value % 60; return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; } function snapMinutes(value) { return Math.round(value / SNAP_MINUTES) * SNAP_MINUTES; } function shiftDate(dateString, offsetDays) { const [year, month, day] = String(dateString).split("-").map(Number); const shifted = new Date(Date.UTC(year, month - 1, day + offsetDays)); return [ shifted.getUTCFullYear(), String(shifted.getUTCMonth() + 1).padStart(2, "0"), String(shifted.getUTCDate()).padStart(2, "0"), ].join("-"); } function formatLocalDateTime(isoString) { const date = new Date(isoString); return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(date).replace(",", ""); } function formatWeekRange(weekStart, weekEnd) { if (!weekStart || !weekEnd) { return ""; } const [startYear, startMonth, startDay] = String(weekStart).split("-").map(Number); const [endYear, endMonth, endDay] = String(weekEnd).split("-").map(Number); return `${startYear}.${String(startMonth).padStart(2, "0")}.${String(startDay).padStart(2, "0")} - ${endYear}.${String(endMonth).padStart(2, "0")}.${String(endDay).padStart(2, "0")}`; } function getPlannerConfig() { const settings = state.planner.settings || { day_start: "08:15", day_end: "23:00", default_task_duration_minutes: 45, }; const dayStart = toMinutes(settings.day_start); const dayEnd = toMinutes(settings.day_end); return { settings, dayStart, dayEnd, totalMinutes: dayEnd - dayStart, canvasLeft: AXIS_WIDTH + SLOT_WIDTH + CANVAS_GAP, }; } function getWeekDays() { return state.planner.week_days || []; } function getWeekDayIndex(dateIso) { return getWeekDays().findIndex((day) => day.iso === dateIso); } function getWeekDayMeta(dateIso) { return getWeekDays().find((day) => day.iso === dateIso) || null; } function computePlannerScale() { const { totalMinutes } = getPlannerConfig(); const rect = timelineScroll.getBoundingClientRect(); const viewportAvailable = Math.max(Math.floor(window.innerHeight - rect.top - 18), 360); const measuredHeight = Math.floor(timelineScroll.clientHeight || viewportAvailable); const frameHeight = Math.max(Math.min(measuredHeight, viewportAvailable), 360); const bodyHeight = Math.max(frameHeight - WEEK_HEADER_HEIGHT - 4, 300); state.pixelsPerMinute = bodyHeight / totalMinutes; return { frameHeight, bodyHeight, }; } function getPixelsPerMinute() { return state.pixelsPerMinute || DEFAULT_PIXELS_PER_MINUTE; } function getCanvasRect() { const canvas = plannerTimeline.querySelector(".timeline-canvas-layer"); return canvas ? canvas.getBoundingClientRect() : null; } function clientYToMinutes(clientY) { const rect = getCanvasRect(); const { dayStart, dayEnd } = getPlannerConfig(); if (!rect) { return dayStart; } const offsetY = clamp(clientY - rect.top, 0, rect.height); const minutes = dayStart + (offsetY / getPixelsPerMinute()); return clamp(snapMinutes(minutes), dayStart, dayEnd); } function clientPointToSchedule(clientX, clientY) { const rect = getCanvasRect(); const days = getWeekDays(); if (!rect || !days.length) { return null; } const relativeX = clamp(clientX - rect.left, 0, Math.max(rect.width - 1, 0)); const columnWidth = rect.width / days.length; const dayIndex = clamp(Math.floor(relativeX / columnWidth), 0, days.length - 1); return { date: days[dayIndex].iso, minutes: clientYToMinutes(clientY), dayIndex, }; } function getTaskById(taskId) { return (state.planner.tasks || []).find((task) => task.id === taskId) || null; } function getTaskDuration(task) { if (task && task.schedule) { return Math.max(MIN_DURATION, toMinutes(task.schedule.end_time) - toMinutes(task.schedule.start_time)); } return Math.max(MIN_DURATION, Number((state.planner.settings && state.planner.settings.default_task_duration_minutes) || 45)); } function rangesOverlap(leftStart, leftEnd, rightStart, rightEnd) { return leftStart < rightEnd && rightStart < leftEnd; } function hasScheduleConflict(dateIso, startMinutes, endMinutes, ignoreTaskId = null) { return (state.planner.scheduled_items || []).some((item) => { const itemDate = item.date || state.selectedDate; if (itemDate !== dateIso) { return false; } if (ignoreTaskId && item.kind === "task" && item.task_id === ignoreTaskId) { return false; } return rangesOverlap( startMinutes, endMinutes, toMinutes(item.start_time), toMinutes(item.end_time) ); }); } function minutesToPixels(minutes) { return Math.round(minutes * getPixelsPerMinute()); } function timelinePixels(minutes) { return minutesToPixels(minutes); } function getBlockHeight(startMinutes, endMinutes) { const scaledHeight = timelinePixels(endMinutes - startMinutes); const minimumHeight = Math.max(28, Math.round(getPixelsPerMinute() * MIN_DURATION)); return Math.max(scaledHeight, minimumHeight); } function setBlockBounds(block, startMinutes, endMinutes, dayStart) { const height = getBlockHeight(startMinutes, endMinutes); block.style.top = `${timelinePixels(startMinutes - dayStart)}px`; block.style.height = `${height}px`; return height; } function setBlockHorizontalBounds(block, dateIso, overlapIndex = 0, overlapCount = 1) { const days = getWeekDays(); const dayIndex = getWeekDayIndex(dateIso); if (dayIndex < 0 || !days.length) { return; } const dayWidthPercent = 100 / days.length; const segmentWidth = dayWidthPercent / overlapCount; const leftPercent = (dayIndex * dayWidthPercent) + (overlapIndex * segmentWidth); block.style.left = `calc(${leftPercent}% + 4px)`; block.style.width = `calc(${segmentWidth}% - 8px)`; block.dataset.eventDate = dateIso; } function updateEventLayout(block, item, dayStart, overlapIndex = 0, overlapCount = 1) { const height = setBlockBounds(block, item.startMinutes, item.endMinutes, dayStart); setBlockHorizontalBounds(block, item.date, overlapIndex, overlapCount); block.classList.toggle("is-compact", height < 66); block.classList.toggle("is-tight", height < 40); block.classList.toggle("is-micro", height < 24); return height; } function getPointerDeltaMinutes(pointerStartY, clientY) { return snapMinutes((clientY - pointerStartY) / getPixelsPerMinute()); } function formatLessonLabel(index) { return `第${String(index + 1).padStart(2, "0")}节`; } function getTimelineAxisMinutes() { const axisMinutes = new Set([getPlannerConfig().dayStart, getPlannerConfig().dayEnd]); (state.planner.time_slots || []).forEach((slot) => { axisMinutes.add(toMinutes(slot.start)); axisMinutes.add(toMinutes(slot.end)); }); return Array.from(axisMinutes).sort((left, right) => left - right); } function suppressRecentClicks(duration = CLICK_SUPPRESS_MS) { state.suppressClickUntil = Date.now() + duration; } function beginPlannerInteraction() { document.body.classList.add("planner-interacting"); suppressRecentClicks(); } function finishPlannerInteraction() { document.body.classList.remove("planner-interacting"); suppressRecentClicks(); } function getPageIndexFromHash() { const hash = String(window.location.hash || "").toLowerCase(); return hash === "#planner" || hash === "#page2" || hash === "#week" ? 1 : 0; } function syncPageHash(index) { const nextHash = index === 1 ? "#planner" : "#home"; if (window.location.hash !== nextHash) { window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${nextHash}`); } } function setActivePage(index, options = {}) { state.activePage = clamp(index, 0, 1); pageTrack.style.transform = `translateX(-${state.activePage * 100}%)`; pageSlides.forEach((slide, slideIndex) => { slide.classList.toggle("is-active", slideIndex === state.activePage); }); document.querySelectorAll(".story-tab").forEach((tab) => { tab.classList.toggle("is-active", Number(tab.dataset.goPage) === state.activePage); }); if (!options.skipHash) { syncPageHash(state.activePage); } if (state.activePage === 1) { loadPlanner(state.selectedDate, true); } } function formatTimelineSlotLabel(index) { return `第${String(index + 1).padStart(2, "0")}节`; } function getCoursePeriodRange(startTime, endTime) { const slots = state.planner.time_slots || []; let startPeriod = null; let endPeriod = null; slots.forEach((slot, index) => { const slotIndex = index + 1; if (slot.start === startTime) { startPeriod = slotIndex; } if (slot.end === endTime) { endPeriod = slotIndex; } }); if (startPeriod === null || endPeriod === null) { const startMinutes = toMinutes(startTime); const endMinutes = toMinutes(endTime); slots.forEach((slot, index) => { const slotStart = toMinutes(slot.start); const slotEnd = toMinutes(slot.end); if (startPeriod === null && startMinutes <= slotStart && startMinutes < slotEnd) { startPeriod = index + 1; } if (slotStart < endMinutes && endMinutes <= slotEnd) { endPeriod = index + 1; } }); } if (startPeriod !== null && endPeriod !== null) { return startPeriod === endPeriod ? `第${startPeriod}节` : `${startPeriod}-${endPeriod}节`; } return `${startTime} - ${endTime}`; } function getCourseWeekText(item) { const patternLabel = item.week_pattern === "odd" ? " 单周" : item.week_pattern === "even" ? " 双周" : ""; return `${item.start_week}-${item.end_week}周${patternLabel}`; } function decorateScheduleItems(items) { const grouped = new Map(); (items || []).forEach((item) => { const prepared = { ...item, date: item.date || state.selectedDate, startMinutes: toMinutes(item.start_time), endMinutes: toMinutes(item.end_time), }; if (!grouped.has(prepared.date)) { grouped.set(prepared.date, []); } grouped.get(prepared.date).push(prepared); }); const result = []; getWeekDays().forEach((day) => { const prepared = (grouped.get(day.iso) || []) .sort((left, right) => ( left.startMinutes - right.startMinutes || left.endMinutes - right.endMinutes || left.kind.localeCompare(right.kind) )); let active = []; let currentGroup = []; let currentGroupWidth = 0; function finalizeGroup() { currentGroup.forEach((item) => { item.columnCount = currentGroupWidth || 1; }); currentGroup = []; currentGroupWidth = 0; } prepared.forEach((item) => { active = active.filter((activeItem) => activeItem.endMinutes > item.startMinutes); if (!active.length && currentGroup.length) { finalizeGroup(); } const usedColumns = new Set(active.map((activeItem) => activeItem.column)); let column = 0; while (usedColumns.has(column)) { column += 1; } item.column = column; active.push(item); currentGroup.push(item); currentGroupWidth = Math.max(currentGroupWidth, active.length); }); if (currentGroup.length) { finalizeGroup(); } result.push(...prepared); }); return result; } function decorateItems(items) { const prepared = (items || []) .map((item) => ({ ...item, startMinutes: toMinutes(item.start_time), endMinutes: toMinutes(item.end_time), })) .sort((left, right) => ( left.startMinutes - right.startMinutes || left.endMinutes - right.endMinutes )); let active = []; let currentGroup = []; let currentGroupWidth = 0; function finalizeGroup() { currentGroup.forEach((item) => { item.columnCount = currentGroupWidth || 1; }); currentGroup = []; currentGroupWidth = 0; } prepared.forEach((item) => { active = active.filter((activeItem) => activeItem.endMinutes > item.startMinutes); if (!active.length && currentGroup.length) { finalizeGroup(); } const usedColumns = new Set(active.map((activeItem) => activeItem.column)); let column = 0; while (usedColumns.has(column)) { column += 1; } item.column = column; active.push(item); currentGroup.push(item); currentGroupWidth = Math.max(currentGroupWidth, active.length); }); if (currentGroup.length) { finalizeGroup(); } return prepared; } function updateEventTimeLabel(block, startMinutes, endMinutes) { const timeLabel = block.querySelector(".planner-event-time"); if (timeLabel) { timeLabel.textContent = `${minutesToTime(startMinutes)} - ${minutesToTime(endMinutes)}`; } } function updateNowLine() { const line = document.getElementById("timelineNowLine"); if (!line) { return; } const { dayStart, dayEnd } = getPlannerConfig(); const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).formatToParts(new Date()); const map = {}; parts.forEach((part) => { if (part.type !== "literal") { map[part.type] = part.value; } }); const today = `${map.year}-${map.month}-${map.day}`; const currentMinutes = (Number(map.hour) * 60) + Number(map.minute); const dayIndex = getWeekDayIndex(today); if (dayIndex < 0 || currentMinutes < dayStart || currentMinutes > dayEnd) { line.style.display = "none"; return; } const dayWidthPercent = 100 / getWeekDays().length; line.style.display = "block"; line.style.left = `calc(${dayIndex * dayWidthPercent}% + 4px)`; line.style.width = `calc(${dayWidthPercent}% - 8px)`; line.style.top = `${timelinePixels(currentMinutes - dayStart)}px`; } function renderTaskPool() { const tasks = (state.planner.tasks || []) .filter((task) => !task.completed) .sort((left, right) => Number(!!left.schedule) - Number(!!right.schedule)); plannerTaskCount.textContent = `${tasks.length} 项`; if (!tasks.length) { plannerTaskPool.innerHTML = `

目前没有可安排的任务

先回到第一页添加待办,再把它拖到本周课表里。
`; return; } plannerTaskPool.innerHTML = tasks.map((task) => `

${escapeHtml(task.title)}

${escapeHtml(task.category_name)}
截止 ${formatLocalDateTime(task.due_at)} 进度 ${Math.round(task.progress_percent || 0)}% ${task.schedule ? `${task.schedule.date} · ${task.schedule.start_time}-${task.schedule.end_time}` : "尚未排入周表"}
${task.schedule && state.authenticated ? `` : ""}
`).join(""); } function renderTimeline() { const { dayStart, dayEnd, canvasLeft } = getPlannerConfig(); const timelineHeight = getPlannerHeight(); plannerTimeline.innerHTML = ""; plannerTimeline.style.height = `${timelineHeight}px`; plannerTimeline.style.setProperty("--timeline-axis-width", `${AXIS_WIDTH}px`); plannerTimeline.style.setProperty("--timeline-slot-width", `${SLOT_WIDTH}px`); plannerTimeline.style.setProperty("--timeline-canvas-left", `${canvasLeft}px`); const axisLayer = document.createElement("div"); axisLayer.className = "timeline-axis-layer"; const slotLayer = document.createElement("div"); slotLayer.className = "timeline-slot-layer"; const canvasLayer = document.createElement("div"); canvasLayer.className = "timeline-canvas-layer"; const preview = document.createElement("div"); preview.className = "timeline-drop-preview"; preview.id = "timelineDropPreview"; preview.style.display = "none"; const nowLine = document.createElement("div"); nowLine.className = "timeline-now-line"; nowLine.id = "timelineNowLine"; const axisRail = document.createElement("div"); axisRail.className = "timeline-axis-rail"; axisLayer.appendChild(axisRail); const lineMarkers = new Set([dayStart, dayEnd]); const timeSlots = state.planner.time_slots || []; timeSlots.forEach((slot, slotIndex) => { const slotStart = toMinutes(slot.start); const slotEnd = toMinutes(slot.end); lineMarkers.add(slotStart); lineMarkers.add(slotEnd); const band = document.createElement("div"); band.className = "timeline-slot-band"; band.style.top = `${timelinePixels(slotStart - dayStart)}px`; band.style.height = `${timelinePixels(slotEnd - slotStart)}px`; band.innerHTML = ` ${formatLessonLabel(slotIndex)} ${escapeHtml(slot.start)} - ${escapeHtml(slot.end)} `; slotLayer.appendChild(band); }); Array.from(lineMarkers).sort((left, right) => left - right).forEach((minute) => { const line = document.createElement("div"); line.className = "timeline-line is-slot"; line.style.top = `${timelinePixels(minute - dayStart)}px`; canvasLayer.appendChild(line); }); getTimelineAxisMinutes().forEach((minute, axisIndex, axisMinutes) => { const tick = document.createElement("div"); tick.className = "timeline-axis-tick"; if (axisIndex === 0) { tick.classList.add("is-leading"); } else if (axisIndex === axisMinutes.length - 1) { tick.classList.add("is-terminal"); } tick.style.top = `${timelinePixels(minute - dayStart)}px`; tick.textContent = minutesToTime(minute); axisLayer.appendChild(tick); }); const majorMap = new Map(); (state.planner.major_blocks || []).forEach((block) => { if (!majorMap.has(block.label)) { majorMap.set(block.label, block); } }); Array.from(majorMap.values()).forEach((block, blockIndex) => { const startMinutes = toMinutes(block.start); const endMinutes = toMinutes(block.end); const overlay = document.createElement("div"); overlay.className = "timeline-major-block"; overlay.style.top = `${timelinePixels(startMinutes - dayStart)}px`; overlay.style.height = `${timelinePixels(endMinutes - startMinutes)}px`; overlay.innerHTML = `${escapeHtml(block.label || `第${blockIndex + 1}大节`)}`; canvasLayer.appendChild(overlay); }); decorateItems(state.planner.scheduled_items).forEach((item) => { const widthPercent = 100 / (item.columnCount || 1); const leftPercent = widthPercent * (item.column || 0); const block = document.createElement("article"); block.className = `planner-event ${item.kind === "course" ? "course-event" : "task-event"} ${item.completed ? "is-complete" : ""}`; block.style.left = `${leftPercent}%`; block.style.width = `calc(${widthPercent}% - 8px)`; updateEventLayout(block, item.startMinutes, item.endMinutes, dayStart); if (item.kind === "course") { if (item.color) { block.style.setProperty("--event-accent", item.color); } const courseWeekText = getCourseWeekText(item); const courseLines = [ courseWeekText, item.location || "", `${item.start_time} - ${item.end_time}`, ].filter(Boolean); block.title = [item.title, ...courseLines].join("\n"); block.innerHTML = `
${escapeHtml(item.title)}
${escapeHtml(courseWeekText)} ${escapeHtml(item.location || "")} ${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}
`; } else { block.dataset.taskId = item.task_id; block.style.setProperty("--event-accent", item.completed ? "#66d0ff" : mixColor(item.progress_percent || 0)); block.innerHTML = `
${escapeHtml(item.title)} ${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}
${escapeHtml(item.category_name)} 进度 ${Math.round(item.progress_percent || 0)}%
${state.authenticated ? `` : ""} ${state.authenticated ? `
` : ""} ${state.authenticated ? `
` : ""} `; if (state.authenticated) { block.addEventListener("pointerdown", (event) => { if (event.target.closest("[data-clear-schedule]")) { return; } event.preventDefault(); event.stopPropagation(); const mode = event.target.closest("[data-resize-task-start]") ? "resize-start" : event.target.closest("[data-resize-task-end]") ? "resize-end" : "move"; beginPlannerInteraction(); state.interaction = { mode, block, taskId: item.task_id, pointerId: event.pointerId, pointerStartY: event.clientY, initialStartMinutes: item.startMinutes, initialEndMinutes: item.endMinutes, startMinutes: item.startMinutes, endMinutes: item.endMinutes, duration: item.endMinutes - item.startMinutes, }; if (typeof block.setPointerCapture === "function") { try { block.setPointerCapture(event.pointerId); } catch (error) { // Ignore browsers that reject capture for synthetic pointer sequences. } } block.classList.add("is-dragging"); }); } } canvasLayer.appendChild(block); }); canvasLayer.addEventListener("dragover", (event) => { if (!state.dragTaskId) { return; } event.preventDefault(); const task = getTaskById(state.dragTaskId); if (!task) { return; } const duration = getTaskDuration(task); const startMinutes = clamp(clientYToMinutes(event.clientY), dayStart, dayEnd - duration); preview.style.display = "block"; setBlockBounds(preview, startMinutes, startMinutes + duration, dayStart); preview.textContent = `${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}`; }); canvasLayer.addEventListener("dragleave", (event) => { if (!canvasLayer.contains(event.relatedTarget)) { preview.style.display = "none"; } }); canvasLayer.addEventListener("drop", async (event) => { if (!state.dragTaskId) { return; } event.preventDefault(); preview.style.display = "none"; preview.classList.remove("is-conflict"); if (!requireAuth()) { state.dragTaskId = null; return; } const task = getTaskById(state.dragTaskId); if (!task) { state.dragTaskId = null; return; } try { const duration = getTaskDuration(task); const startMinutes = clamp(clientYToMinutes(event.clientY), dayStart, dayEnd - duration); await requestJSON(`/api/tasks/${task.id}/schedule`, { method: "PATCH", body: JSON.stringify({ date: state.selectedDate, start_time: minutesToTime(startMinutes), end_time: minutesToTime(startMinutes + duration), }), }); await loadPlanner(state.selectedDate, true); showToast("任务已拖入时间表"); } catch (error) { showToast(error.message, "error"); } finally { state.dragTaskId = null; } }); plannerTimeline.appendChild(axisLayer); plannerTimeline.appendChild(slotLayer); plannerTimeline.appendChild(canvasLayer); plannerTimeline.appendChild(preview); plannerTimeline.appendChild(nowLine); updateNowLine(); } function renderWeekTimeline() { const { dayStart, dayEnd, canvasLeft } = getPlannerConfig(); const { frameHeight, bodyHeight } = computePlannerScale(); const days = getWeekDays(); plannerTimeline.innerHTML = ""; plannerTimeline.style.height = `${frameHeight}px`; plannerTimeline.style.setProperty("--timeline-axis-width", `${AXIS_WIDTH}px`); plannerTimeline.style.setProperty("--timeline-slot-width", `${SLOT_WIDTH}px`); plannerTimeline.style.setProperty("--timeline-canvas-left", `${canvasLeft}px`); plannerTimeline.style.setProperty("--timeline-week-header-height", `${WEEK_HEADER_HEIGHT}px`); if (!days.length) { return; } const headerLayer = document.createElement("div"); headerLayer.className = "timeline-week-header"; headerLayer.style.left = `${canvasLeft}px`; headerLayer.style.right = "12px"; headerLayer.style.height = `${WEEK_HEADER_HEIGHT - 8}px`; days.forEach((day) => { const head = document.createElement("button"); head.type = "button"; head.className = `timeline-day-head ${day.is_today ? "is-today" : ""} ${day.iso === state.selectedDate ? "is-selected" : ""}`; head.innerHTML = ` ${escapeHtml(day.short_label)} ${escapeHtml(day.month_day)} `; head.addEventListener("click", () => { if (day.iso !== state.selectedDate) { loadPlanner(day.iso, true); } }); headerLayer.appendChild(head); }); const axisLayer = document.createElement("div"); axisLayer.className = "timeline-axis-layer"; axisLayer.style.top = `${WEEK_HEADER_HEIGHT}px`; axisLayer.style.height = `${bodyHeight}px`; const slotLayer = document.createElement("div"); slotLayer.className = "timeline-slot-layer"; slotLayer.style.top = `${WEEK_HEADER_HEIGHT}px`; slotLayer.style.height = `${bodyHeight}px`; const canvasLayer = document.createElement("div"); canvasLayer.className = "timeline-canvas-layer"; canvasLayer.style.top = `${WEEK_HEADER_HEIGHT}px`; canvasLayer.style.height = `${bodyHeight}px`; const axisRail = document.createElement("div"); axisRail.className = "timeline-axis-rail"; axisLayer.appendChild(axisRail); const lineMarkers = new Set([dayStart, dayEnd]); const timeSlots = state.planner.time_slots || []; const dayWidthPercent = 100 / days.length; days.forEach((day, dayIndex) => { const dayColumn = document.createElement("div"); dayColumn.className = `timeline-day-column ${day.is_today ? "is-today" : ""} ${day.iso === state.selectedDate ? "is-selected" : ""}`; dayColumn.style.left = `${dayIndex * dayWidthPercent}%`; dayColumn.style.width = `${dayWidthPercent}%`; canvasLayer.appendChild(dayColumn); if (dayIndex > 0) { const divider = document.createElement("div"); divider.className = "timeline-day-divider"; divider.style.left = `${dayIndex * dayWidthPercent}%`; canvasLayer.appendChild(divider); } }); timeSlots.forEach((slot, slotIndex) => { const slotStart = toMinutes(slot.start); const slotEnd = toMinutes(slot.end); lineMarkers.add(slotStart); lineMarkers.add(slotEnd); const band = document.createElement("div"); band.className = "timeline-slot-band"; band.style.top = `${timelinePixels(slotStart - dayStart)}px`; band.style.height = `${timelinePixels(slotEnd - slotStart)}px`; band.innerHTML = ` ${formatTimelineSlotLabel(slotIndex)} ${escapeHtml(slot.start)} - ${escapeHtml(slot.end)} `; slotLayer.appendChild(band); }); Array.from(lineMarkers) .sort((left, right) => left - right) .forEach((minute) => { const line = document.createElement("div"); line.className = "timeline-line is-slot"; line.style.top = `${timelinePixels(minute - dayStart)}px`; canvasLayer.appendChild(line); }); getTimelineAxisMinutes().forEach((minute, axisIndex, axisMinutes) => { const tick = document.createElement("div"); tick.className = "timeline-axis-tick"; if (axisIndex === 0) { tick.classList.add("is-leading"); } else if (axisIndex === axisMinutes.length - 1) { tick.classList.add("is-terminal"); } tick.style.top = `${timelinePixels(minute - dayStart)}px`; tick.textContent = minutesToTime(minute); axisLayer.appendChild(tick); }); const majorMap = new Map(); (state.planner.major_blocks || []).forEach((block) => { if (!majorMap.has(block.label)) { majorMap.set(block.label, block); } }); Array.from(majorMap.values()).forEach((block, blockIndex) => { const startMinutes = toMinutes(block.start); const endMinutes = toMinutes(block.end); const overlay = document.createElement("div"); overlay.className = "timeline-major-block"; overlay.style.top = `${timelinePixels(startMinutes - dayStart)}px`; overlay.style.height = `${timelinePixels(endMinutes - startMinutes)}px`; overlay.innerHTML = `${escapeHtml(block.label || `第${blockIndex + 1}大节`)}`; canvasLayer.appendChild(overlay); }); decorateScheduleItems(state.planner.scheduled_items).forEach((item) => { const block = document.createElement("article"); block.className = `planner-event ${item.kind === "course" ? "course-event" : "task-event"} ${item.completed ? "is-complete" : ""}`; updateEventLayout(block, item, dayStart, item.column || 0, item.columnCount || 1); if (item.kind === "course") { if (item.color) { block.style.setProperty("--event-accent", item.color); } const courseWeekText = getCourseWeekText(item); const courseLines = [ courseWeekText, item.location || "", `${item.start_time} - ${item.end_time}`, ].filter(Boolean); block.title = [item.title, ...courseLines].join("\n"); block.innerHTML = `
${escapeHtml(item.title)}
${escapeHtml(courseWeekText)} ${escapeHtml(item.location || "")} ${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}
`; canvasLayer.appendChild(block); return; block.innerHTML = `
${escapeHtml(item.title)} 固定课程
${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)} ${escapeHtml(item.location || "")}
`; } else { block.dataset.taskId = item.task_id; block.style.setProperty("--event-accent", item.completed ? "#66d0ff" : mixColor(item.progress_percent || 0)); block.innerHTML = `
${escapeHtml(item.title)} ${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}
${escapeHtml(item.category_name)} 进度 ${Math.round(item.progress_percent || 0)}%
${state.authenticated ? `` : ""} ${state.authenticated ? `
` : ""} ${state.authenticated ? `
` : ""} `; if (state.authenticated) { block.addEventListener("pointerdown", (event) => { if (event.button !== 0 || event.target.closest("[data-clear-schedule]")) { return; } event.preventDefault(); event.stopPropagation(); const point = clientPointToSchedule(event.clientX, event.clientY); const mode = event.target.closest("[data-resize-task-start]") ? "resize-start" : event.target.closest("[data-resize-task-end]") ? "resize-end" : "move"; beginPlannerInteraction(); state.interaction = { mode, block, taskId: item.task_id, pointerId: event.pointerId, pointerStartX: event.clientX, pointerStartY: event.clientY, initialDate: item.date, currentDate: item.date, initialStartMinutes: item.startMinutes, initialEndMinutes: item.endMinutes, startMinutes: item.startMinutes, endMinutes: item.endMinutes, duration: item.endMinutes - item.startMinutes, pointerOffsetMinutes: point ? point.minutes - item.startMinutes : 0, hasConflict: false, }; if (typeof block.setPointerCapture === "function") { try { block.setPointerCapture(event.pointerId); } catch (error) { // Ignore browsers that reject capture for synthetic pointer sequences. } } block.classList.add("is-dragging"); }); } } canvasLayer.appendChild(block); }); const preview = document.createElement("div"); preview.className = "timeline-drop-preview"; preview.id = "timelineDropPreview"; preview.style.display = "none"; canvasLayer.appendChild(preview); const nowLine = document.createElement("div"); nowLine.className = "timeline-now-line"; nowLine.id = "timelineNowLine"; canvasLayer.appendChild(nowLine); canvasLayer.addEventListener("dragover", (event) => { if (!state.dragTaskId) { return; } event.preventDefault(); const task = getTaskById(state.dragTaskId); const point = clientPointToSchedule(event.clientX, event.clientY); if (!task || !point) { preview.style.display = "none"; return; } const duration = getTaskDuration(task); const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration); const dayMeta = getWeekDayMeta(point.date); const hasConflict = hasScheduleConflict(point.date, startMinutes, startMinutes + duration, task.id); preview.style.display = "grid"; preview.classList.toggle("is-conflict", hasConflict); setBlockBounds(preview, startMinutes, startMinutes + duration, dayStart); setBlockHorizontalBounds(preview, point.date, 0, 1); preview.innerHTML = ` ${escapeHtml(dayMeta ? dayMeta.short_label : "")} ${hasConflict ? "时间冲突" : `${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}`} `; }); canvasLayer.addEventListener("dragleave", (event) => { if (!canvasLayer.contains(event.relatedTarget)) { preview.style.display = "none"; preview.classList.remove("is-conflict"); } }); canvasLayer.addEventListener("drop", async (event) => { if (!state.dragTaskId) { return; } event.preventDefault(); preview.style.display = "none"; if (!requireAuth()) { state.dragTaskId = null; return; } const task = getTaskById(state.dragTaskId); const point = clientPointToSchedule(event.clientX, event.clientY); if (!task || !point) { state.dragTaskId = null; return; } try { const duration = getTaskDuration(task); const startMinutes = clamp(point.minutes, dayStart, dayEnd - duration); if (hasScheduleConflict(point.date, startMinutes, startMinutes + duration, task.id)) { showToast("该时间段已有课程或任务,请换一个时间", "error"); state.dragTaskId = null; return; } await requestJSON(`/api/tasks/${task.id}/schedule`, { method: "PATCH", body: JSON.stringify({ date: point.date, start_time: minutesToTime(startMinutes), end_time: minutesToTime(startMinutes + duration), }), }); await loadPlanner(point.date, true); showToast("任务已拖入本周课表"); } catch (error) { showToast(error.message, "error"); } finally { state.dragTaskId = null; } }); plannerTimeline.appendChild(headerLayer); plannerTimeline.appendChild(axisLayer); plannerTimeline.appendChild(slotLayer); plannerTimeline.appendChild(canvasLayer); updateNowLine(); } function renderWeekPlanner() { state.selectedDate = state.planner.selected_date || state.selectedDate; plannerDateInput.value = state.selectedDate; plannerDateLabel.textContent = formatWeekRange(state.planner.week_start, state.planner.week_end); plannerWeekday.textContent = state.planner.week_range_label || ""; plannerAcademicWeek.textContent = state.planner.academic_label || ""; plannerWindow.textContent = `${state.planner.settings.day_start} - ${state.planner.settings.day_end}`; plannerHeadlineNote.textContent = "课程会按学期周次自动固定显示,任务可拖入本周任意一天,并通过上下边缘拉伸;每项任务最短 15 分钟。"; renderTaskPool(); renderWeekTimeline(); } function renderPlanner() { state.selectedDate = state.planner.selected_date || state.selectedDate; plannerDateInput.value = state.selectedDate; plannerDateLabel.textContent = formatPlannerDate(state.selectedDate); plannerWeekday.textContent = state.planner.weekday || ""; plannerAcademicWeek.textContent = state.planner.academic_label || ""; plannerWindow.textContent = `${state.planner.settings.day_start} - ${state.planner.settings.day_end}`; plannerHeadlineNote.textContent = "时间轴已按你给出的校内节次排好,课程固定显示,任务可拖拽并从上边缘或下边缘拉伸。"; renderTaskPool(); renderTimeline(); } async function loadPlanner(targetDate, silent) { try { const payload = await requestJSON(`/api/planner?date=${encodeURIComponent(targetDate)}`, { method: "GET", }); state.planner = payload.planner; state.selectedDate = payload.planner.selected_date; renderWeekPlanner(); } catch (error) { if (!silent) { showToast(error.message, "error"); } } } document.addEventListener("click", (event) => { if (Date.now() < state.suppressClickUntil) { event.preventDefault(); event.stopPropagation(); } }, true); document.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-go-page]"); if (pageButton) { setActivePage(Number(pageButton.dataset.goPage)); } const clearButton = event.target.closest("[data-clear-schedule]"); if (clearButton && (plannerTimeline.contains(clearButton) || plannerTaskPool.contains(clearButton))) { if (!requireAuth()) { return; } requestJSON(`/api/tasks/${clearButton.dataset.clearSchedule}/schedule`, { method: "PATCH", body: JSON.stringify({ clear: true }), }) .then(() => loadPlanner(state.selectedDate, true)) .then(() => showToast("任务已移出时间表")) .catch((error) => showToast(error.message, "error")); } }); plannerTaskPool.addEventListener("dragstart", (event) => { const card = event.target.closest("[data-planner-task-id]"); if (!card) { return; } if (!requireAuth()) { event.preventDefault(); return; } state.dragTaskId = card.dataset.plannerTaskId; event.dataTransfer.setData("text/plain", state.dragTaskId); event.dataTransfer.effectAllowed = "move"; beginPlannerInteraction(); card.classList.add("is-dragging"); }); plannerTaskPool.addEventListener("dragend", (event) => { const card = event.target.closest("[data-planner-task-id]"); if (card) { card.classList.remove("is-dragging"); } state.dragTaskId = null; finishPlannerInteraction(); const preview = document.getElementById("timelineDropPreview"); if (preview) { preview.style.display = "none"; preview.classList.remove("is-conflict"); } }); document.addEventListener("pointermove", (event) => { if (!state.interaction || event.pointerId !== state.interaction.pointerId) { return; } event.preventDefault(); const { dayStart, dayEnd } = getPlannerConfig(); const deltaMinutes = getPointerDeltaMinutes(state.interaction.pointerStartY, event.clientY); if (state.interaction.mode === "move") { const point = clientPointToSchedule(event.clientX, event.clientY); const duration = state.interaction.initialEndMinutes - state.interaction.initialStartMinutes; state.interaction.currentDate = point ? point.date : state.interaction.initialDate; const startMinutes = clamp( state.interaction.initialStartMinutes + deltaMinutes, dayStart, dayEnd - duration ); state.interaction.startMinutes = startMinutes; state.interaction.endMinutes = startMinutes + duration; state.interaction.duration = duration; } else if (state.interaction.mode === "resize-start") { const startMinutes = clamp( state.interaction.initialStartMinutes + deltaMinutes, dayStart, state.interaction.initialEndMinutes - MIN_DURATION ); state.interaction.currentDate = state.interaction.initialDate; state.interaction.startMinutes = startMinutes; state.interaction.endMinutes = state.interaction.initialEndMinutes; state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes; } else { const endMinutes = clamp( state.interaction.initialEndMinutes + deltaMinutes, state.interaction.initialStartMinutes + MIN_DURATION, dayEnd ); state.interaction.currentDate = state.interaction.initialDate; state.interaction.startMinutes = state.interaction.initialStartMinutes; state.interaction.endMinutes = endMinutes; state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes; } state.interaction.hasConflict = hasScheduleConflict( state.interaction.currentDate, state.interaction.startMinutes, state.interaction.endMinutes, state.interaction.taskId ); state.interaction.block.classList.toggle("is-conflict", !!state.interaction.hasConflict); updateEventLayout(state.interaction.block, { date: state.interaction.currentDate, startMinutes: state.interaction.startMinutes, endMinutes: state.interaction.endMinutes, }, dayStart); updateEventTimeLabel(state.interaction.block, state.interaction.startMinutes, state.interaction.endMinutes); }); function releaseInteractionPointer(current) { if ( current && typeof current.block.releasePointerCapture === "function" && typeof current.block.hasPointerCapture === "function" && current.pointerId !== undefined && current.block.hasPointerCapture(current.pointerId) ) { try { current.block.releasePointerCapture(current.pointerId); } catch (error) { // Ignore browsers that already released pointer capture. } } } function persistInteraction(current) { requestJSON(`/api/tasks/${current.taskId}/schedule`, { method: "PATCH", body: JSON.stringify({ date: state.selectedDate, start_time: minutesToTime(current.startMinutes), end_time: minutesToTime(current.endMinutes), }), }) .then(() => loadPlanner(state.selectedDate, true)) .then(() => showToast("规划时间已更新")) .catch((error) => showToast(error.message, "error")); } function persistWeekInteraction(current) { requestJSON(`/api/tasks/${current.taskId}/schedule`, { method: "PATCH", body: JSON.stringify({ date: current.currentDate || current.initialDate || state.selectedDate, start_time: minutesToTime(current.startMinutes), end_time: minutesToTime(current.endMinutes), }), }) .then(() => loadPlanner(current.currentDate || state.selectedDate, true)) .then(() => showToast("规划时间已更新")) .catch((error) => showToast(error.message, "error")); } function finishInteraction(event) { if (!state.interaction) { return; } if (event && event.pointerId !== undefined && event.pointerId !== state.interaction.pointerId) { return; } const current = state.interaction; current.block.classList.remove("is-dragging"); current.block.classList.remove("is-conflict"); state.interaction = null; releaseInteractionPointer(current); finishPlannerInteraction(); if ( (current.currentDate || current.initialDate) === current.initialDate && current.startMinutes === current.initialStartMinutes && current.endMinutes === current.initialEndMinutes ) { return; } if (current.hasConflict) { loadPlanner(current.currentDate || state.selectedDate, true); showToast("该时间段已有课程或任务,请换一个时间", "error"); return; } persistWeekInteraction(current); } document.addEventListener("pointerup", finishInteraction); document.addEventListener("pointercancel", finishInteraction); window.addEventListener("blur", finishInteraction); plannerDateInput.addEventListener("change", () => { if (plannerDateInput.value) { loadPlanner(plannerDateInput.value); } }); plannerPrevDay.addEventListener("click", () => { loadPlanner(shiftDate(state.selectedDate, -7)); }); plannerNextDay.addEventListener("click", () => { loadPlanner(shiftDate(state.selectedDate, 7)); }); window.setInterval(() => { if (state.activePage === 1) { loadPlanner(state.selectedDate, true); } }, 45000); window.setInterval(updateNowLine, 60000); window.addEventListener("resize", () => { if (state.activePage === 1) { renderWeekPlanner(); } else { updateNowLine(); } }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && state.activePage === 1) { loadPlanner(state.selectedDate, true); } }); window.addEventListener("hashchange", () => { setActivePage(getPageIndexFromHash(), { skipHash: true }); }); renderWeekPlanner(); setActivePage(getPageIndexFromHash(), { skipHash: true }); })();