Codex commited on
Commit
b132f4e
·
1 Parent(s): 719856e

Fix planner date controls and resizing

Browse files
Files changed (2) hide show
  1. static/planner.js +232 -116
  2. static/v020.css +14 -2
static/planner.js CHANGED
@@ -2,32 +2,20 @@
2
  const bootstrap = window.__DRM_BOOTSTRAP__ || {};
3
  const state = {
4
  authenticated: !!bootstrap.authenticated,
5
- planner: bootstrap.planner || {
6
- selected_date: "",
7
- weekday: "",
8
- academic_label: "",
9
- settings: {
10
- day_start: "08:15",
11
- day_end: "23:00",
12
- default_task_duration_minutes: 45,
13
- },
14
- time_slots: [],
15
- major_blocks: [],
16
- tasks: [],
17
- scheduled_items: [],
18
- },
19
- selectedDate: (bootstrap.planner && bootstrap.planner.selected_date) || "",
20
  activePage: 0,
21
  dragTaskId: null,
22
  interaction: null,
23
  };
24
 
25
  const PIXELS_PER_MINUTE = 1.16;
26
- const AXIS_WIDTH = 94;
27
- const SLOT_WIDTH = 150;
28
  const CANVAS_GAP = 18;
29
  const SNAP_MINUTES = 5;
30
  const MIN_DURATION = 15;
 
31
 
32
  const pageTrack = document.getElementById("pageTrack");
33
  const plannerDateInput = document.getElementById("plannerDateInput");
@@ -44,7 +32,7 @@
44
  const loginModal = document.getElementById("loginModal");
45
  const toastStack = document.getElementById("toastStack");
46
 
47
- if (!pageTrack || !plannerDateInput || !plannerTimeline || !plannerTaskPool) {
48
  return;
49
  }
50
 
@@ -100,6 +88,16 @@
100
  return Math.min(Math.max(value, min), max);
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
103
  function toMinutes(value) {
104
  const [hour, minute] = String(value).split(":").map(Number);
105
  return (hour * 60) + minute;
@@ -115,6 +113,16 @@
115
  return Math.round(value / SNAP_MINUTES) * SNAP_MINUTES;
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
118
  function formatLocalDateTime(isoString) {
119
  const date = new Date(isoString);
120
  return new Intl.DateTimeFormat("zh-CN", {
@@ -128,14 +136,18 @@
128
  }
129
 
130
  function formatPlannerDate(dateString) {
131
- const [year, month, day] = dateString.split("-").map(Number);
132
  return `${year} 年 ${String(month).padStart(2, "0")} 月 ${String(day).padStart(2, "0")} 日`;
133
  }
134
 
135
  function getPlannerConfig() {
136
- const settings = state.planner.settings || {};
137
- const dayStart = toMinutes(settings.day_start || "08:15");
138
- const dayEnd = toMinutes(settings.day_end || "23:00");
 
 
 
 
139
  return {
140
  settings,
141
  dayStart,
@@ -145,17 +157,37 @@
145
  };
146
  }
147
 
148
- function getTaskDuration(task) {
149
- if (task && task.schedule) {
150
- return Math.max(MIN_DURATION, toMinutes(task.schedule.end_time) - toMinutes(task.schedule.start_time));
 
 
 
 
 
 
 
 
 
 
 
151
  }
152
- return Number((state.planner.settings && state.planner.settings.default_task_duration_minutes) || 45);
 
 
153
  }
154
 
155
  function getTaskById(taskId) {
156
  return (state.planner.tasks || []).find((task) => task.id === taskId) || null;
157
  }
158
 
 
 
 
 
 
 
 
159
  function setActivePage(index) {
160
  state.activePage = clamp(index, 0, 1);
161
  pageTrack.style.transform = `translateX(-${state.activePage * 100}%)`;
@@ -168,66 +200,57 @@
168
  }
169
 
170
  function decorateItems(items) {
171
- const prepared = (items || []).map((item) => ({
172
- ...item,
173
- startMinutes: toMinutes(item.start_time),
174
- endMinutes: toMinutes(item.end_time),
175
- })).sort((left, right) => (
176
- left.startMinutes - right.startMinutes
177
- || left.endMinutes - right.endMinutes
178
- ));
 
 
179
 
180
  let active = [];
181
- let group = [];
182
- let maxColumns = 0;
183
 
184
  function finalizeGroup() {
185
- group.forEach((item) => {
186
- item.columnCount = maxColumns || 1;
187
  });
188
- group = [];
189
- maxColumns = 0;
190
  }
191
 
192
  prepared.forEach((item) => {
193
  active = active.filter((activeItem) => activeItem.endMinutes > item.startMinutes);
194
- if (!active.length && group.length) {
195
  finalizeGroup();
196
  }
197
- const used = new Set(active.map((activeItem) => activeItem.column));
198
  let column = 0;
199
- while (used.has(column)) {
200
  column += 1;
201
  }
202
  item.column = column;
203
  active.push(item);
204
- group.push(item);
205
- maxColumns = Math.max(maxColumns, active.length);
206
  });
207
- if (group.length) {
 
208
  finalizeGroup();
209
  }
210
- return prepared;
211
- }
212
 
213
- function getCanvasRect() {
214
- const canvas = plannerTimeline.querySelector(".timeline-canvas-layer");
215
- return canvas ? canvas.getBoundingClientRect() : null;
216
  }
217
 
218
- function clientYToMinutes(clientY) {
219
- const rect = getCanvasRect();
220
- const { dayStart, dayEnd } = getPlannerConfig();
221
- if (!rect) {
222
- return dayStart;
223
  }
224
- const offset = clamp(clientY - rect.top, 0, rect.height);
225
- const minutes = dayStart + (offset / PIXELS_PER_MINUTE);
226
- return clamp(snapMinutes(minutes), dayStart, dayEnd);
227
- }
228
-
229
- function getPlannerHeight() {
230
- return getPlannerConfig().totalMinutes * PIXELS_PER_MINUTE;
231
  }
232
 
233
  function updateNowLine() {
@@ -245,18 +268,22 @@
245
  minute: "2-digit",
246
  hour12: false,
247
  }).formatToParts(new Date());
 
248
  const map = {};
249
  parts.forEach((part) => {
250
  if (part.type !== "literal") {
251
  map[part.type] = part.value;
252
  }
253
  });
 
254
  const today = `${map.year}-${map.month}-${map.day}`;
255
  const currentMinutes = (Number(map.hour) * 60) + Number(map.minute);
 
256
  if (today !== state.selectedDate || currentMinutes < dayStart || currentMinutes > dayEnd) {
257
  line.style.display = "none";
258
  return;
259
  }
 
260
  line.style.display = "block";
261
  line.style.left = `${canvasLeft}px`;
262
  line.style.right = "14px";
@@ -266,15 +293,17 @@
266
  function renderTaskPool() {
267
  const tasks = (state.planner.tasks || []).filter((task) => !task.completed);
268
  plannerTaskCount.textContent = `${tasks.length} 项`;
 
269
  if (!tasks.length) {
270
  plannerTaskPool.innerHTML = `
271
  <div class="planner-empty">
272
  <p>目前没有可安排的任务</p>
273
- <span>先回第一页添加待办,再把它拖左侧时间表。</span>
274
  </div>
275
  `;
276
  return;
277
  }
 
278
  plannerTaskPool.innerHTML = tasks.map((task) => `
279
  <article class="planner-task-card ${task.schedule ? "is-scheduled" : ""}" draggable="${state.authenticated}" data-planner-task-id="${task.id}">
280
  <div class="planner-task-top">
@@ -293,47 +322,93 @@
293
 
294
  function renderTimeline() {
295
  const { dayStart, dayEnd, canvasLeft } = getPlannerConfig();
 
 
296
  plannerTimeline.innerHTML = "";
297
- plannerTimeline.style.height = `${getPlannerHeight()}px`;
298
  plannerTimeline.style.setProperty("--timeline-axis-width", `${AXIS_WIDTH}px`);
299
  plannerTimeline.style.setProperty("--timeline-slot-width", `${SLOT_WIDTH}px`);
300
  plannerTimeline.style.setProperty("--timeline-canvas-left", `${canvasLeft}px`);
301
 
302
  const axisLayer = document.createElement("div");
303
  axisLayer.className = "timeline-axis-layer";
 
304
  const slotLayer = document.createElement("div");
305
  slotLayer.className = "timeline-slot-layer";
 
306
  const canvasLayer = document.createElement("div");
307
  canvasLayer.className = "timeline-canvas-layer";
308
 
309
- const markers = new Set([dayStart, dayEnd]);
 
 
 
 
 
 
 
 
 
 
 
 
310
  (state.planner.time_slots || []).forEach((slot) => {
311
- markers.add(toMinutes(slot.start));
312
- markers.add(toMinutes(slot.end));
 
 
 
 
313
  const band = document.createElement("div");
314
  band.className = "timeline-slot-band";
315
- band.style.top = `${(toMinutes(slot.start) - dayStart) * PIXELS_PER_MINUTE}px`;
316
- band.style.height = `${Math.max((toMinutes(slot.end) - toMinutes(slot.start)) * PIXELS_PER_MINUTE, 52)}px`;
317
- band.innerHTML = `<strong>${escapeHtml(slot.label)}</strong><span>${escapeHtml(slot.start)} - ${escapeHtml(slot.end)}</span>`;
 
 
 
318
  slotLayer.appendChild(band);
319
  });
320
 
321
  for (let minute = Math.ceil(dayStart / 60) * 60; minute <= dayEnd; minute += 60) {
322
- markers.add(minute);
 
323
  }
324
 
325
- Array.from(markers).sort((left, right) => left - right).forEach((minute) => {
 
 
 
 
 
 
 
 
326
  const top = (minute - dayStart) * PIXELS_PER_MINUTE;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  const tick = document.createElement("div");
328
  tick.className = "timeline-axis-tick";
329
- tick.style.top = `${top}px`;
330
  tick.textContent = minutesToTime(minute);
331
  axisLayer.appendChild(tick);
332
-
333
- const line = document.createElement("div");
334
- line.className = `timeline-line ${minute % 60 === 0 ? "is-hour" : "is-slot"}`;
335
- line.style.top = `${top}px`;
336
- canvasLayer.appendChild(line);
337
  });
338
 
339
  const majorMap = new Map();
@@ -342,62 +417,76 @@
342
  majorMap.set(block.label, block);
343
  }
344
  });
 
345
  Array.from(majorMap.values()).forEach((block) => {
 
 
346
  const overlay = document.createElement("div");
347
  overlay.className = "timeline-major-block";
348
- overlay.style.top = `${(toMinutes(block.start) - dayStart) * PIXELS_PER_MINUTE}px`;
349
- overlay.style.height = `${Math.max((toMinutes(block.end) - toMinutes(block.start)) * PIXELS_PER_MINUTE, 88)}px`;
350
  overlay.innerHTML = `<span>${escapeHtml(block.label)}</span>`;
351
  canvasLayer.appendChild(overlay);
352
  });
353
 
354
  decorateItems(state.planner.scheduled_items).forEach((item) => {
355
- const block = document.createElement("article");
356
  const widthPercent = 100 / (item.columnCount || 1);
357
  const leftPercent = widthPercent * (item.column || 0);
 
358
  block.className = `planner-event ${item.kind === "course" ? "course-event" : "task-event"} ${item.completed ? "is-complete" : ""}`;
359
  block.style.top = `${(item.startMinutes - dayStart) * PIXELS_PER_MINUTE}px`;
360
  block.style.height = `${Math.max((item.endMinutes - item.startMinutes) * PIXELS_PER_MINUTE, 54)}px`;
361
  block.style.left = `${leftPercent}%`;
362
  block.style.width = `calc(${widthPercent}% - 8px)`;
363
- if (item.color) {
364
- block.style.setProperty("--event-accent", item.color);
365
- }
366
  if (item.kind === "course") {
 
 
 
367
  block.innerHTML = `
368
  <div class="planner-event-top">
369
  <strong>${escapeHtml(item.title)}</strong>
370
  <span class="planner-lock-badge">固定课程</span>
371
  </div>
372
  <div class="planner-event-meta">
373
- <span>${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
374
  <span>${escapeHtml(item.location || "")}</span>
375
  </div>
376
  `;
377
  } else {
378
  block.dataset.taskId = item.task_id;
379
- block.style.setProperty("--event-accent", item.completed ? "#66d0ff" : "");
380
  block.innerHTML = `
381
  <div class="planner-event-top">
382
  <strong>${escapeHtml(item.title)}</strong>
383
- <span>${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
384
  </div>
385
  <div class="planner-event-meta">
386
  <span>${escapeHtml(item.category_name)}</span>
387
  <span>进度 ${Math.round(item.progress_percent || 0)}%</span>
388
  </div>
389
- ${state.authenticated ? `<button class="planner-event-clear" type="button" data-clear-schedule="${item.task_id}">移出</button><div class="planner-event-resize" data-resize-task="${item.task_id}"></div>` : ""}
 
 
390
  `;
 
391
  if (state.authenticated) {
392
  block.addEventListener("pointerdown", (event) => {
393
  if (event.target.closest("[data-clear-schedule]")) {
394
  return;
395
  }
396
- const mode = event.target.closest("[data-resize-task]") ? "resize" : "move";
 
 
 
 
 
 
397
  const rect = getCanvasRect();
398
  if (!rect) {
399
  return;
400
  }
 
401
  state.interaction = {
402
  mode,
403
  block,
@@ -407,10 +496,12 @@
407
  duration: item.endMinutes - item.startMinutes,
408
  offsetY: event.clientY - (rect.top + ((item.startMinutes - dayStart) * PIXELS_PER_MINUTE)),
409
  };
 
410
  block.classList.add("is-dragging");
411
  });
412
  }
413
  }
 
414
  canvasLayer.appendChild(block);
415
  });
416
 
@@ -431,18 +522,29 @@
431
  preview.textContent = `${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}`;
432
  });
433
 
 
 
 
 
 
 
434
  canvasLayer.addEventListener("drop", async (event) => {
435
  if (!state.dragTaskId) {
436
  return;
437
  }
438
  event.preventDefault();
 
439
  if (!requireAuth()) {
 
440
  return;
441
  }
 
442
  const task = getTaskById(state.dragTaskId);
443
  if (!task) {
 
444
  return;
445
  }
 
446
  try {
447
  const duration = getTaskDuration(task);
448
  const startMinutes = clamp(clientYToMinutes(event.clientY), dayStart, dayEnd - duration);
@@ -463,20 +565,12 @@
463
  }
464
  });
465
 
466
- const preview = document.createElement("div");
467
- preview.className = "timeline-drop-preview";
468
- preview.id = "timelineDropPreview";
469
- preview.style.display = "none";
470
-
471
- const nowLine = document.createElement("div");
472
- nowLine.className = "timeline-now-line";
473
- nowLine.id = "timelineNowLine";
474
-
475
  plannerTimeline.appendChild(axisLayer);
476
  plannerTimeline.appendChild(slotLayer);
477
  plannerTimeline.appendChild(canvasLayer);
478
  plannerTimeline.appendChild(preview);
479
  plannerTimeline.appendChild(nowLine);
 
480
  updateNowLine();
481
  }
482
 
@@ -487,14 +581,16 @@
487
  plannerWeekday.textContent = state.planner.weekday || "";
488
  plannerAcademicWeek.textContent = state.planner.academic_label || "";
489
  plannerWindow.textContent = `${state.planner.settings.day_start} - ${state.planner.settings.day_end}`;
490
- plannerHeadlineNote.textContent = "时间轴已按你给出的校内节次排好,课程固定显示,任务可拖拽拉伸。";
491
  renderTaskPool();
492
  renderTimeline();
493
  }
494
 
495
  async function loadPlanner(targetDate, silent) {
496
  try {
497
- const payload = await requestJSON(`/api/planner?date=${encodeURIComponent(targetDate)}`, { method: "GET" });
 
 
498
  state.planner = payload.planner;
499
  state.selectedDate = payload.planner.selected_date;
500
  renderPlanner();
@@ -510,6 +606,7 @@
510
  if (pageButton) {
511
  setActivePage(Number(pageButton.dataset.goPage));
512
  }
 
513
  const clearButton = event.target.closest("[data-clear-schedule]");
514
  if (clearButton && (plannerTimeline.contains(clearButton) || plannerTaskPool.contains(clearButton))) {
515
  if (!requireAuth()) {
@@ -518,7 +615,8 @@
518
  requestJSON(`/api/tasks/${clearButton.dataset.clearSchedule}/schedule`, {
519
  method: "PATCH",
520
  body: JSON.stringify({ clear: true }),
521
- }).then(() => loadPlanner(state.selectedDate, true))
 
522
  .then(() => showToast("任务已移出时间表"))
523
  .catch((error) => showToast(error.message, "error"));
524
  }
@@ -555,30 +653,48 @@
555
  if (!state.interaction) {
556
  return;
557
  }
 
558
  const { dayStart, dayEnd } = getPlannerConfig();
559
  if (state.interaction.mode === "move") {
560
- const startMinutes = clamp(clientYToMinutes(event.clientY - state.interaction.offsetY + 8), dayStart, dayEnd - state.interaction.duration);
 
 
 
 
561
  state.interaction.startMinutes = startMinutes;
562
  state.interaction.endMinutes = startMinutes + state.interaction.duration;
 
 
 
 
 
 
 
 
563
  } else {
564
- state.interaction.endMinutes = clamp(clientYToMinutes(event.clientY), state.interaction.startMinutes + MIN_DURATION, dayEnd);
 
 
 
 
 
565
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
566
  }
 
567
  state.interaction.block.style.top = `${(state.interaction.startMinutes - dayStart) * PIXELS_PER_MINUTE}px`;
568
  state.interaction.block.style.height = `${Math.max((state.interaction.endMinutes - state.interaction.startMinutes) * PIXELS_PER_MINUTE, 54)}px`;
569
- const label = state.interaction.block.querySelector(".planner-event-top span");
570
- if (label) {
571
- label.textContent = `${minutesToTime(state.interaction.startMinutes)} - ${minutesToTime(state.interaction.endMinutes)}`;
572
- }
573
  });
574
 
575
  document.addEventListener("pointerup", () => {
576
  if (!state.interaction) {
577
  return;
578
  }
 
579
  const current = state.interaction;
580
  current.block.classList.remove("is-dragging");
581
  state.interaction = null;
 
582
  requestJSON(`/api/tasks/${current.taskId}/schedule`, {
583
  method: "PATCH",
584
  body: JSON.stringify({
@@ -586,8 +702,9 @@
586
  start_time: minutesToTime(current.startMinutes),
587
  end_time: minutesToTime(current.endMinutes),
588
  }),
589
- }).then(() => loadPlanner(state.selectedDate, true))
590
- .then(() => showToast("时间块已更新"))
 
591
  .catch((error) => showToast(error.message, "error"));
592
  });
593
 
@@ -598,15 +715,11 @@
598
  });
599
 
600
  plannerPrevDay.addEventListener("click", () => {
601
- const date = new Date(`${state.selectedDate}T00:00:00`);
602
- date.setDate(date.getDate() - 1);
603
- loadPlanner(date.toISOString().slice(0, 10));
604
  });
605
 
606
  plannerNextDay.addEventListener("click", () => {
607
- const date = new Date(`${state.selectedDate}T00:00:00`);
608
- date.setDate(date.getDate() + 1);
609
- loadPlanner(date.toISOString().slice(0, 10));
610
  });
611
 
612
  window.setInterval(() => {
@@ -614,7 +727,10 @@
614
  loadPlanner(state.selectedDate, true);
615
  }
616
  }, 45000);
 
617
  window.setInterval(updateNowLine, 60000);
 
 
618
  document.addEventListener("visibilitychange", () => {
619
  if (document.visibilityState === "visible" && state.activePage === 1) {
620
  loadPlanner(state.selectedDate, true);
 
2
  const bootstrap = window.__DRM_BOOTSTRAP__ || {};
3
  const state = {
4
  authenticated: !!bootstrap.authenticated,
5
+ planner: bootstrap.planner || {},
6
+ selectedDate: bootstrap.planner ? bootstrap.planner.selected_date : "",
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  activePage: 0,
8
  dragTaskId: null,
9
  interaction: null,
10
  };
11
 
12
  const PIXELS_PER_MINUTE = 1.16;
13
+ const AXIS_WIDTH = 96;
14
+ const SLOT_WIDTH = 156;
15
  const CANVAS_GAP = 18;
16
  const SNAP_MINUTES = 5;
17
  const MIN_DURATION = 15;
18
+ const AXIS_LABEL_MIN_GAP = 28;
19
 
20
  const pageTrack = document.getElementById("pageTrack");
21
  const plannerDateInput = document.getElementById("plannerDateInput");
 
32
  const loginModal = document.getElementById("loginModal");
33
  const toastStack = document.getElementById("toastStack");
34
 
35
+ if (!pageTrack || !plannerDateInput || !plannerPrevDay || !plannerNextDay || !plannerTaskPool || !plannerTimeline) {
36
  return;
37
  }
38
 
 
88
  return Math.min(Math.max(value, min), max);
89
  }
90
 
91
+ function mixColor(progress) {
92
+ if (progress <= 50) {
93
+ return "#73d883";
94
+ }
95
+ if (progress <= 80) {
96
+ return "#ffc857";
97
+ }
98
+ return "#ff6b5c";
99
+ }
100
+
101
  function toMinutes(value) {
102
  const [hour, minute] = String(value).split(":").map(Number);
103
  return (hour * 60) + minute;
 
113
  return Math.round(value / SNAP_MINUTES) * SNAP_MINUTES;
114
  }
115
 
116
+ function shiftDate(dateString, offsetDays) {
117
+ const [year, month, day] = String(dateString).split("-").map(Number);
118
+ const shifted = new Date(Date.UTC(year, month - 1, day + offsetDays));
119
+ return [
120
+ shifted.getUTCFullYear(),
121
+ String(shifted.getUTCMonth() + 1).padStart(2, "0"),
122
+ String(shifted.getUTCDate()).padStart(2, "0"),
123
+ ].join("-");
124
+ }
125
+
126
  function formatLocalDateTime(isoString) {
127
  const date = new Date(isoString);
128
  return new Intl.DateTimeFormat("zh-CN", {
 
136
  }
137
 
138
  function formatPlannerDate(dateString) {
139
+ const [year, month, day] = String(dateString).split("-").map(Number);
140
  return `${year} 年 ${String(month).padStart(2, "0")} 月 ${String(day).padStart(2, "0")} 日`;
141
  }
142
 
143
  function getPlannerConfig() {
144
+ const settings = state.planner.settings || {
145
+ day_start: "08:15",
146
+ day_end: "23:00",
147
+ default_task_duration_minutes: 45,
148
+ };
149
+ const dayStart = toMinutes(settings.day_start);
150
+ const dayEnd = toMinutes(settings.day_end);
151
  return {
152
  settings,
153
  dayStart,
 
157
  };
158
  }
159
 
160
+ function getPlannerHeight() {
161
+ return getPlannerConfig().totalMinutes * PIXELS_PER_MINUTE;
162
+ }
163
+
164
+ function getCanvasRect() {
165
+ const canvas = plannerTimeline.querySelector(".timeline-canvas-layer");
166
+ return canvas ? canvas.getBoundingClientRect() : null;
167
+ }
168
+
169
+ function clientYToMinutes(clientY) {
170
+ const rect = getCanvasRect();
171
+ const { dayStart, dayEnd } = getPlannerConfig();
172
+ if (!rect) {
173
+ return dayStart;
174
  }
175
+ const offsetY = clamp(clientY - rect.top, 0, rect.height);
176
+ const minutes = dayStart + (offsetY / PIXELS_PER_MINUTE);
177
+ return clamp(snapMinutes(minutes), dayStart, dayEnd);
178
  }
179
 
180
  function getTaskById(taskId) {
181
  return (state.planner.tasks || []).find((task) => task.id === taskId) || null;
182
  }
183
 
184
+ function getTaskDuration(task) {
185
+ if (task && task.schedule) {
186
+ return Math.max(MIN_DURATION, toMinutes(task.schedule.end_time) - toMinutes(task.schedule.start_time));
187
+ }
188
+ return Number((state.planner.settings && state.planner.settings.default_task_duration_minutes) || 45);
189
+ }
190
+
191
  function setActivePage(index) {
192
  state.activePage = clamp(index, 0, 1);
193
  pageTrack.style.transform = `translateX(-${state.activePage * 100}%)`;
 
200
  }
201
 
202
  function decorateItems(items) {
203
+ const prepared = (items || [])
204
+ .map((item) => ({
205
+ ...item,
206
+ startMinutes: toMinutes(item.start_time),
207
+ endMinutes: toMinutes(item.end_time),
208
+ }))
209
+ .sort((left, right) => (
210
+ left.startMinutes - right.startMinutes
211
+ || left.endMinutes - right.endMinutes
212
+ ));
213
 
214
  let active = [];
215
+ let currentGroup = [];
216
+ let currentGroupWidth = 0;
217
 
218
  function finalizeGroup() {
219
+ currentGroup.forEach((item) => {
220
+ item.columnCount = currentGroupWidth || 1;
221
  });
222
+ currentGroup = [];
223
+ currentGroupWidth = 0;
224
  }
225
 
226
  prepared.forEach((item) => {
227
  active = active.filter((activeItem) => activeItem.endMinutes > item.startMinutes);
228
+ if (!active.length && currentGroup.length) {
229
  finalizeGroup();
230
  }
231
+ const usedColumns = new Set(active.map((activeItem) => activeItem.column));
232
  let column = 0;
233
+ while (usedColumns.has(column)) {
234
  column += 1;
235
  }
236
  item.column = column;
237
  active.push(item);
238
+ currentGroup.push(item);
239
+ currentGroupWidth = Math.max(currentGroupWidth, active.length);
240
  });
241
+
242
+ if (currentGroup.length) {
243
  finalizeGroup();
244
  }
 
 
245
 
246
+ return prepared;
 
 
247
  }
248
 
249
+ function updateEventTimeLabel(block, startMinutes, endMinutes) {
250
+ const timeLabel = block.querySelector(".planner-event-time");
251
+ if (timeLabel) {
252
+ timeLabel.textContent = `${minutesToTime(startMinutes)} - ${minutesToTime(endMinutes)}`;
 
253
  }
 
 
 
 
 
 
 
254
  }
255
 
256
  function updateNowLine() {
 
268
  minute: "2-digit",
269
  hour12: false,
270
  }).formatToParts(new Date());
271
+
272
  const map = {};
273
  parts.forEach((part) => {
274
  if (part.type !== "literal") {
275
  map[part.type] = part.value;
276
  }
277
  });
278
+
279
  const today = `${map.year}-${map.month}-${map.day}`;
280
  const currentMinutes = (Number(map.hour) * 60) + Number(map.minute);
281
+
282
  if (today !== state.selectedDate || currentMinutes < dayStart || currentMinutes > dayEnd) {
283
  line.style.display = "none";
284
  return;
285
  }
286
+
287
  line.style.display = "block";
288
  line.style.left = `${canvasLeft}px`;
289
  line.style.right = "14px";
 
293
  function renderTaskPool() {
294
  const tasks = (state.planner.tasks || []).filter((task) => !task.completed);
295
  plannerTaskCount.textContent = `${tasks.length} 项`;
296
+
297
  if (!tasks.length) {
298
  plannerTaskPool.innerHTML = `
299
  <div class="planner-empty">
300
  <p>目前没有可安排的任务</p>
301
+ <span>先回第一页添加待办,再把它拖左侧时间表。</span>
302
  </div>
303
  `;
304
  return;
305
  }
306
+
307
  plannerTaskPool.innerHTML = tasks.map((task) => `
308
  <article class="planner-task-card ${task.schedule ? "is-scheduled" : ""}" draggable="${state.authenticated}" data-planner-task-id="${task.id}">
309
  <div class="planner-task-top">
 
322
 
323
  function renderTimeline() {
324
  const { dayStart, dayEnd, canvasLeft } = getPlannerConfig();
325
+ const timelineHeight = getPlannerHeight();
326
+
327
  plannerTimeline.innerHTML = "";
328
+ plannerTimeline.style.height = `${timelineHeight}px`;
329
  plannerTimeline.style.setProperty("--timeline-axis-width", `${AXIS_WIDTH}px`);
330
  plannerTimeline.style.setProperty("--timeline-slot-width", `${SLOT_WIDTH}px`);
331
  plannerTimeline.style.setProperty("--timeline-canvas-left", `${canvasLeft}px`);
332
 
333
  const axisLayer = document.createElement("div");
334
  axisLayer.className = "timeline-axis-layer";
335
+
336
  const slotLayer = document.createElement("div");
337
  slotLayer.className = "timeline-slot-layer";
338
+
339
  const canvasLayer = document.createElement("div");
340
  canvasLayer.className = "timeline-canvas-layer";
341
 
342
+ const preview = document.createElement("div");
343
+ preview.className = "timeline-drop-preview";
344
+ preview.id = "timelineDropPreview";
345
+ preview.style.display = "none";
346
+
347
+ const nowLine = document.createElement("div");
348
+ nowLine.className = "timeline-now-line";
349
+ nowLine.id = "timelineNowLine";
350
+
351
+ const lineMarkers = new Set([dayStart, dayEnd]);
352
+ const preferredAxisCandidates = new Set([dayStart, dayEnd]);
353
+ const hourAxisCandidates = new Set();
354
+
355
  (state.planner.time_slots || []).forEach((slot) => {
356
+ const slotStart = toMinutes(slot.start);
357
+ const slotEnd = toMinutes(slot.end);
358
+ lineMarkers.add(slotStart);
359
+ lineMarkers.add(slotEnd);
360
+ preferredAxisCandidates.add(slotStart);
361
+
362
  const band = document.createElement("div");
363
  band.className = "timeline-slot-band";
364
+ band.style.top = `${(slotStart - dayStart) * PIXELS_PER_MINUTE}px`;
365
+ band.style.height = `${Math.max((slotEnd - slotStart) * PIXELS_PER_MINUTE, 52)}px`;
366
+ band.innerHTML = `
367
+ <strong>${escapeHtml(slot.label)}</strong>
368
+ <span>${escapeHtml(slot.start)} - ${escapeHtml(slot.end)}</span>
369
+ `;
370
  slotLayer.appendChild(band);
371
  });
372
 
373
  for (let minute = Math.ceil(dayStart / 60) * 60; minute <= dayEnd; minute += 60) {
374
+ lineMarkers.add(minute);
375
+ hourAxisCandidates.add(minute);
376
  }
377
 
378
+ Array.from(lineMarkers).sort((left, right) => left - right).forEach((minute) => {
379
+ const line = document.createElement("div");
380
+ line.className = `timeline-line ${minute % 60 === 0 ? "is-hour" : "is-slot"}`;
381
+ line.style.top = `${(minute - dayStart) * PIXELS_PER_MINUTE}px`;
382
+ canvasLayer.appendChild(line);
383
+ });
384
+
385
+ const axisLabelMinutes = [];
386
+ const appendAxisLabel = (minute) => {
387
  const top = (minute - dayStart) * PIXELS_PER_MINUTE;
388
+ const previous = axisLabelMinutes[axisLabelMinutes.length - 1];
389
+ if (previous !== undefined) {
390
+ const previousTop = (previous - dayStart) * PIXELS_PER_MINUTE;
391
+ if (top - previousTop < AXIS_LABEL_MIN_GAP) {
392
+ return;
393
+ }
394
+ }
395
+ axisLabelMinutes.push(minute);
396
+ };
397
+
398
+ Array.from(preferredAxisCandidates)
399
+ .sort((left, right) => left - right)
400
+ .forEach(appendAxisLabel);
401
+
402
+ Array.from(hourAxisCandidates)
403
+ .sort((left, right) => left - right)
404
+ .forEach(appendAxisLabel);
405
+
406
+ axisLabelMinutes.forEach((minute) => {
407
  const tick = document.createElement("div");
408
  tick.className = "timeline-axis-tick";
409
+ tick.style.top = `${(minute - dayStart) * PIXELS_PER_MINUTE}px`;
410
  tick.textContent = minutesToTime(minute);
411
  axisLayer.appendChild(tick);
 
 
 
 
 
412
  });
413
 
414
  const majorMap = new Map();
 
417
  majorMap.set(block.label, block);
418
  }
419
  });
420
+
421
  Array.from(majorMap.values()).forEach((block) => {
422
+ const startMinutes = toMinutes(block.start);
423
+ const endMinutes = toMinutes(block.end);
424
  const overlay = document.createElement("div");
425
  overlay.className = "timeline-major-block";
426
+ overlay.style.top = `${(startMinutes - dayStart) * PIXELS_PER_MINUTE}px`;
427
+ overlay.style.height = `${Math.max((endMinutes - startMinutes) * PIXELS_PER_MINUTE, 88)}px`;
428
  overlay.innerHTML = `<span>${escapeHtml(block.label)}</span>`;
429
  canvasLayer.appendChild(overlay);
430
  });
431
 
432
  decorateItems(state.planner.scheduled_items).forEach((item) => {
 
433
  const widthPercent = 100 / (item.columnCount || 1);
434
  const leftPercent = widthPercent * (item.column || 0);
435
+ const block = document.createElement("article");
436
  block.className = `planner-event ${item.kind === "course" ? "course-event" : "task-event"} ${item.completed ? "is-complete" : ""}`;
437
  block.style.top = `${(item.startMinutes - dayStart) * PIXELS_PER_MINUTE}px`;
438
  block.style.height = `${Math.max((item.endMinutes - item.startMinutes) * PIXELS_PER_MINUTE, 54)}px`;
439
  block.style.left = `${leftPercent}%`;
440
  block.style.width = `calc(${widthPercent}% - 8px)`;
441
+
 
 
442
  if (item.kind === "course") {
443
+ if (item.color) {
444
+ block.style.setProperty("--event-accent", item.color);
445
+ }
446
  block.innerHTML = `
447
  <div class="planner-event-top">
448
  <strong>${escapeHtml(item.title)}</strong>
449
  <span class="planner-lock-badge">固定课程</span>
450
  </div>
451
  <div class="planner-event-meta">
452
+ <span class="planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
453
  <span>${escapeHtml(item.location || "")}</span>
454
  </div>
455
  `;
456
  } else {
457
  block.dataset.taskId = item.task_id;
458
+ block.style.setProperty("--event-accent", item.completed ? "#66d0ff" : mixColor(item.progress_percent || 0));
459
  block.innerHTML = `
460
  <div class="planner-event-top">
461
  <strong>${escapeHtml(item.title)}</strong>
462
+ <span class="planner-event-time">${escapeHtml(item.start_time)} - ${escapeHtml(item.end_time)}</span>
463
  </div>
464
  <div class="planner-event-meta">
465
  <span>${escapeHtml(item.category_name)}</span>
466
  <span>进度 ${Math.round(item.progress_percent || 0)}%</span>
467
  </div>
468
+ ${state.authenticated ? `<button class="planner-event-clear" type="button" data-clear-schedule="${item.task_id}">移出</button>` : ""}
469
+ ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-top" data-resize-task-start="${item.task_id}"></div>` : ""}
470
+ ${state.authenticated ? `<div class="planner-event-resize planner-event-resize-bottom" data-resize-task-end="${item.task_id}"></div>` : ""}
471
  `;
472
+
473
  if (state.authenticated) {
474
  block.addEventListener("pointerdown", (event) => {
475
  if (event.target.closest("[data-clear-schedule]")) {
476
  return;
477
  }
478
+
479
+ const mode = event.target.closest("[data-resize-task-start]")
480
+ ? "resize-start"
481
+ : event.target.closest("[data-resize-task-end]")
482
+ ? "resize-end"
483
+ : "move";
484
+
485
  const rect = getCanvasRect();
486
  if (!rect) {
487
  return;
488
  }
489
+
490
  state.interaction = {
491
  mode,
492
  block,
 
496
  duration: item.endMinutes - item.startMinutes,
497
  offsetY: event.clientY - (rect.top + ((item.startMinutes - dayStart) * PIXELS_PER_MINUTE)),
498
  };
499
+
500
  block.classList.add("is-dragging");
501
  });
502
  }
503
  }
504
+
505
  canvasLayer.appendChild(block);
506
  });
507
 
 
522
  preview.textContent = `${minutesToTime(startMinutes)} - ${minutesToTime(startMinutes + duration)}`;
523
  });
524
 
525
+ canvasLayer.addEventListener("dragleave", (event) => {
526
+ if (!canvasLayer.contains(event.relatedTarget)) {
527
+ preview.style.display = "none";
528
+ }
529
+ });
530
+
531
  canvasLayer.addEventListener("drop", async (event) => {
532
  if (!state.dragTaskId) {
533
  return;
534
  }
535
  event.preventDefault();
536
+ preview.style.display = "none";
537
  if (!requireAuth()) {
538
+ state.dragTaskId = null;
539
  return;
540
  }
541
+
542
  const task = getTaskById(state.dragTaskId);
543
  if (!task) {
544
+ state.dragTaskId = null;
545
  return;
546
  }
547
+
548
  try {
549
  const duration = getTaskDuration(task);
550
  const startMinutes = clamp(clientYToMinutes(event.clientY), dayStart, dayEnd - duration);
 
565
  }
566
  });
567
 
 
 
 
 
 
 
 
 
 
568
  plannerTimeline.appendChild(axisLayer);
569
  plannerTimeline.appendChild(slotLayer);
570
  plannerTimeline.appendChild(canvasLayer);
571
  plannerTimeline.appendChild(preview);
572
  plannerTimeline.appendChild(nowLine);
573
+
574
  updateNowLine();
575
  }
576
 
 
581
  plannerWeekday.textContent = state.planner.weekday || "";
582
  plannerAcademicWeek.textContent = state.planner.academic_label || "";
583
  plannerWindow.textContent = `${state.planner.settings.day_start} - ${state.planner.settings.day_end}`;
584
+ plannerHeadlineNote.textContent = "时间轴已按你给出的校内节次排好,课程固定显示,任务可拖拽并从上边缘或下边缘拉伸。";
585
  renderTaskPool();
586
  renderTimeline();
587
  }
588
 
589
  async function loadPlanner(targetDate, silent) {
590
  try {
591
+ const payload = await requestJSON(`/api/planner?date=${encodeURIComponent(targetDate)}`, {
592
+ method: "GET",
593
+ });
594
  state.planner = payload.planner;
595
  state.selectedDate = payload.planner.selected_date;
596
  renderPlanner();
 
606
  if (pageButton) {
607
  setActivePage(Number(pageButton.dataset.goPage));
608
  }
609
+
610
  const clearButton = event.target.closest("[data-clear-schedule]");
611
  if (clearButton && (plannerTimeline.contains(clearButton) || plannerTaskPool.contains(clearButton))) {
612
  if (!requireAuth()) {
 
615
  requestJSON(`/api/tasks/${clearButton.dataset.clearSchedule}/schedule`, {
616
  method: "PATCH",
617
  body: JSON.stringify({ clear: true }),
618
+ })
619
+ .then(() => loadPlanner(state.selectedDate, true))
620
  .then(() => showToast("任务已移出时间表"))
621
  .catch((error) => showToast(error.message, "error"));
622
  }
 
653
  if (!state.interaction) {
654
  return;
655
  }
656
+
657
  const { dayStart, dayEnd } = getPlannerConfig();
658
  if (state.interaction.mode === "move") {
659
+ const startMinutes = clamp(
660
+ clientYToMinutes(event.clientY - state.interaction.offsetY + 8),
661
+ dayStart,
662
+ dayEnd - state.interaction.duration
663
+ );
664
  state.interaction.startMinutes = startMinutes;
665
  state.interaction.endMinutes = startMinutes + state.interaction.duration;
666
+ } else if (state.interaction.mode === "resize-start") {
667
+ const startMinutes = clamp(
668
+ clientYToMinutes(event.clientY),
669
+ dayStart,
670
+ state.interaction.endMinutes - MIN_DURATION
671
+ );
672
+ state.interaction.startMinutes = startMinutes;
673
+ state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
674
  } else {
675
+ const endMinutes = clamp(
676
+ clientYToMinutes(event.clientY),
677
+ state.interaction.startMinutes + MIN_DURATION,
678
+ dayEnd
679
+ );
680
+ state.interaction.endMinutes = endMinutes;
681
  state.interaction.duration = state.interaction.endMinutes - state.interaction.startMinutes;
682
  }
683
+
684
  state.interaction.block.style.top = `${(state.interaction.startMinutes - dayStart) * PIXELS_PER_MINUTE}px`;
685
  state.interaction.block.style.height = `${Math.max((state.interaction.endMinutes - state.interaction.startMinutes) * PIXELS_PER_MINUTE, 54)}px`;
686
+ updateEventTimeLabel(state.interaction.block, state.interaction.startMinutes, state.interaction.endMinutes);
 
 
 
687
  });
688
 
689
  document.addEventListener("pointerup", () => {
690
  if (!state.interaction) {
691
  return;
692
  }
693
+
694
  const current = state.interaction;
695
  current.block.classList.remove("is-dragging");
696
  state.interaction = null;
697
+
698
  requestJSON(`/api/tasks/${current.taskId}/schedule`, {
699
  method: "PATCH",
700
  body: JSON.stringify({
 
702
  start_time: minutesToTime(current.startMinutes),
703
  end_time: minutesToTime(current.endMinutes),
704
  }),
705
+ })
706
+ .then(() => loadPlanner(state.selectedDate, true))
707
+ .then(() => showToast("规划时间已更新"))
708
  .catch((error) => showToast(error.message, "error"));
709
  });
710
 
 
715
  });
716
 
717
  plannerPrevDay.addEventListener("click", () => {
718
+ loadPlanner(shiftDate(state.selectedDate, -1));
 
 
719
  });
720
 
721
  plannerNextDay.addEventListener("click", () => {
722
+ loadPlanner(shiftDate(state.selectedDate, 1));
 
 
723
  });
724
 
725
  window.setInterval(() => {
 
727
  loadPlanner(state.selectedDate, true);
728
  }
729
  }, 45000);
730
+
731
  window.setInterval(updateNowLine, 60000);
732
+ window.addEventListener("resize", updateNowLine);
733
+
734
  document.addEventListener("visibilitychange", () => {
735
  if (document.visibilityState === "visible" && state.activePage === 1) {
736
  loadPlanner(state.selectedDate, true);
static/v020.css CHANGED
@@ -210,10 +210,15 @@
210
  .timeline-axis-tick {
211
  position: absolute;
212
  left: 0;
 
 
 
213
  transform: translateY(-50%);
214
- padding-left: 10px;
215
  color: rgba(238, 244, 251, 0.7);
216
  font-size: 0.82rem;
 
 
217
  }
218
 
219
  .timeline-slot-band {
@@ -335,13 +340,20 @@
335
  position: absolute;
336
  left: 18px;
337
  right: 18px;
338
- bottom: 8px;
339
  height: 8px;
340
  border-radius: 999px;
341
  background: rgba(255, 255, 255, 0.18);
342
  cursor: ns-resize;
343
  }
344
 
 
 
 
 
 
 
 
 
345
  .planner-event.is-dragging,
346
  .planner-task-card.is-dragging {
347
  opacity: 0.8;
 
210
  .timeline-axis-tick {
211
  position: absolute;
212
  left: 0;
213
+ right: 10px;
214
+ display: flex;
215
+ justify-content: flex-end;
216
  transform: translateY(-50%);
217
+ padding-right: 12px;
218
  color: rgba(238, 244, 251, 0.7);
219
  font-size: 0.82rem;
220
+ line-height: 1;
221
+ white-space: nowrap;
222
  }
223
 
224
  .timeline-slot-band {
 
340
  position: absolute;
341
  left: 18px;
342
  right: 18px;
 
343
  height: 8px;
344
  border-radius: 999px;
345
  background: rgba(255, 255, 255, 0.18);
346
  cursor: ns-resize;
347
  }
348
 
349
+ .planner-event-resize-top {
350
+ top: 8px;
351
+ }
352
+
353
+ .planner-event-resize-bottom {
354
+ bottom: 8px;
355
+ }
356
+
357
  .planner-event.is-dragging,
358
  .planner-task-card.is-dragging {
359
  opacity: 0.8;