DRM / static /planner.js
Codex
Fix planner conflicts and compact layout
cc14448
(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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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) {
// 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 = `
<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) {
// 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 = `
<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) {
// 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 });
})();