| (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, """) |
| .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 = ` |
| <div class="planner-empty"> |
| <p>目前没有可安排的任务</p> |
| <span>先回到第一页添加待办,再把它拖到本周课表里。</span> |
| </div> |
| `; |
| return; |
| } |
|
|
| plannerTaskPool.innerHTML = tasks.map((task) => ` |
| <article class="planner-task-card ${task.schedule ? "is-scheduled" : ""}" draggable="${state.authenticated}" data-planner-task-id="${task.id}"> |
| <div class="planner-task-top"> |
| <h4>${escapeHtml(task.title)}</h4> |
| <span class="planner-task-category">${escapeHtml(task.category_name)}</span> |
| </div> |
| <div class="planner-task-tags"> |
| <span>截止 ${formatLocalDateTime(task.due_at)}</span> |
| <span>进度 ${Math.round(task.progress_percent || 0)}%</span> |
| <span>${task.schedule ? `${task.schedule.date} · ${task.schedule.start_time}-${task.schedule.end_time}` : "尚未排入周表"}</span> |
| </div> |
| ${task.schedule && state.authenticated ? `<button class="planner-task-clear" type="button" data-clear-schedule="${task.id}">移出排程</button>` : ""} |
| </article> |
| `).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 = ` |
| <strong>${formatLessonLabel(slotIndex)}</strong> |
| <span>${escapeHtml(slot.start)} - ${escapeHtml(slot.end)}</span> |
| `; |
| 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 = `<span>${escapeHtml(block.label || `第${blockIndex + 1}大节`)}</span>`; |
| 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 = ` |
| <div class="planner-course-stack"> |
| <strong class="planner-course-title">${escapeHtml(item.title)}</strong> |
| <div class="planner-course-details"> |
| <span class="planner-course-line">${escapeHtml(courseWeekText)}</span> |
| <span class="planner-course-line">${escapeHtml(item.location || "")}</span> |
| <span class="planner-course-line planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span> |
| </div> |
| </div> |
| `; |
| } else { |
| block.dataset.taskId = item.task_id; |
| block.style.setProperty("--event-accent", item.completed ? "#66d0ff" : mixColor(item.progress_percent || 0)); |
| block.innerHTML = ` |
| <div class="planner-event-top"> |
| <strong>${escapeHtml(item.title)}</strong> |
| <span class="planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span> |
| </div> |
| <div class="planner-event-meta"> |
| <span>${escapeHtml(item.category_name)}</span> |
| <span>进度 ${Math.round(item.progress_percent || 0)}%</span> |
| </div> |
| ${state.authenticated ? `<button class="planner-event-clear" type="button" data-clear-schedule="${item.task_id}">移出</button>` : ""} |
| ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-top" data-resize-task-start="${item.task_id}"></div>` : ""} |
| ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-bottom" data-resize-task-end="${item.task_id}"></div>` : ""} |
| `; |
|
|
| 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) { |
| |
| } |
| } |
|
|
| 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 = ` |
| <strong>${escapeHtml(day.short_label)}</strong> |
| <span>${escapeHtml(day.month_day)}</span> |
| `; |
| 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 = ` |
| <strong>${formatTimelineSlotLabel(slotIndex)}</strong> |
| <span>${escapeHtml(slot.start)} - ${escapeHtml(slot.end)}</span> |
| `; |
| 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 = `<span>${escapeHtml(block.label || `第${blockIndex + 1}大节`)}</span>`; |
| 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 = ` |
| <div class="planner-course-stack"> |
| <strong class="planner-course-title">${escapeHtml(item.title)}</strong> |
| <div class="planner-course-details"> |
| <span class="planner-course-line">${escapeHtml(courseWeekText)}</span> |
| <span class="planner-course-line">${escapeHtml(item.location || "")}</span> |
| <span class="planner-course-line planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span> |
| </div> |
| </div> |
| `; |
| canvasLayer.appendChild(block); |
| return; |
| block.innerHTML = ` |
| <div class="planner-event-top"> |
| <strong>${escapeHtml(item.title)}</strong> |
| <span class="planner-lock-badge">固定课程</span> |
| </div> |
| <div class="planner-event-meta"> |
| <span class="planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span> |
| <span>${escapeHtml(item.location || "")}</span> |
| </div> |
| `; |
| } else { |
| block.dataset.taskId = item.task_id; |
| block.style.setProperty("--event-accent", item.completed ? "#66d0ff" : mixColor(item.progress_percent || 0)); |
| block.innerHTML = ` |
| <div class="planner-event-top"> |
| <strong>${escapeHtml(item.title)}</strong> |
| <span class="planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span> |
| </div> |
| <div class="planner-event-meta"> |
| <span>${escapeHtml(item.category_name)}</span> |
| <span>进度 ${Math.round(item.progress_percent || 0)}%</span> |
| </div> |
| ${state.authenticated ? `<button class="planner-event-clear" type="button" data-clear-schedule="${item.task_id}">移出</button>` : ""} |
| ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-top" data-resize-task-start="${item.task_id}"></div>` : ""} |
| ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-bottom" data-resize-task-end="${item.task_id}"></div>` : ""} |
| `; |
|
|
| 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) { |
| |
| } |
| } |
|
|
| 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 = ` |
| <strong>${escapeHtml(dayMeta ? dayMeta.short_label : "")}</strong> |
| <span>${hasConflict ? "时间冲突" : `${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}`}</span> |
| `; |
| }); |
|
|
| 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) { |
| |
| } |
| } |
| } |
|
|
| 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 }); |
| })(); |
|
|