Codex commited on
Commit
d3b98cb
·
1 Parent(s): cbd13dc

Polish admin tools and planner sidebar

Browse files
Files changed (4) hide show
  1. app.py +13 -2
  2. static/admin-v020.js +435 -274
  3. static/v020.css +176 -9
  4. templates/admin.html +135 -119
app.py CHANGED
@@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo
7
 
8
  from flask import Flask, jsonify, redirect, render_template, request, session, url_for
9
 
10
- from storage import ReminderStore, beijing_now, build_default_time_slots
11
 
12
  BASE_DIR = Path(__file__).resolve().parent
13
  STORE_PATH = BASE_DIR / "data" / "store.json"
@@ -219,6 +219,17 @@ def build_major_blocks_from_time_slots(time_slots: list[dict[str, str]]) -> list
219
  return major_blocks
220
 
221
 
 
 
 
 
 
 
 
 
 
 
 
222
  def compute_task_progress(task: dict) -> float:
223
  if task.get("completed"):
224
  return 100.0
@@ -604,7 +615,7 @@ def render_admin_page(active_page: str):
604
  categories=store.list_categories(),
605
  courses=store.list_courses(),
606
  schedule_settings=schedule_settings,
607
- default_time_slots=build_default_time_slots(),
608
  )
609
 
610
 
 
7
 
8
  from flask import Flask, jsonify, redirect, render_template, request, session, url_for
9
 
10
+ from storage import DEFAULT_PERIOD_TIMES, ReminderStore, beijing_now, build_default_time_slots
11
 
12
  BASE_DIR = Path(__file__).resolve().parent
13
  STORE_PATH = BASE_DIR / "data" / "store.json"
 
219
  return major_blocks
220
 
221
 
222
+ def build_clean_default_time_slots() -> list[dict[str, str]]:
223
+ return [
224
+ {
225
+ "label": f"第{index:02d}节课",
226
+ "start": start,
227
+ "end": end,
228
+ }
229
+ for index, (start, end) in sorted(DEFAULT_PERIOD_TIMES.items())
230
+ ]
231
+
232
+
233
  def compute_task_progress(task: dict) -> float:
234
  if task.get("completed"):
235
  return 100.0
 
615
  categories=store.list_categories(),
616
  courses=store.list_courses(),
617
  schedule_settings=schedule_settings,
618
+ default_time_slots=build_clean_default_time_slots(),
619
  )
620
 
621
 
static/admin-v020.js CHANGED
@@ -9,24 +9,25 @@
9
  scheduleEditor: {
10
  segments: [],
11
  interaction: null,
12
- rangeStart: 0,
13
- rangeEnd: 0,
14
- pixelsPerMinute: 1.1,
15
  },
16
  };
17
 
18
  const toastStack = document.getElementById("toastStack");
19
- const courseGrid = document.getElementById("courseGrid");
20
- const courseModal = document.getElementById("courseModal");
21
- const courseForm = document.getElementById("courseForm");
22
- const openCourseModalButton = document.getElementById("openCourseModalButton");
23
- const createCategoryForm = document.getElementById("createCategoryForm");
24
- const adminGrid = document.getElementById("adminGrid");
25
  const scheduleSettingsForm = document.getElementById("scheduleSettingsForm");
26
  const scheduleEditorAxis = document.getElementById("scheduleEditorAxis");
27
  const scheduleEditorTrack = document.getElementById("scheduleEditorTrack");
 
28
  const scheduleSegmentCount = document.getElementById("scheduleSegmentCount");
29
  const resetTimelineButton = document.getElementById("resetTimelineButton");
 
 
 
 
 
 
 
30
 
31
  if (!toastStack) {
32
  return;
@@ -54,16 +55,8 @@
54
  return payload;
55
  }
56
 
57
- function openModal(modal) {
58
- if (modal) {
59
- modal.classList.add("is-open");
60
- }
61
- }
62
-
63
- function closeModal(modal) {
64
- if (modal) {
65
- modal.classList.remove("is-open");
66
- }
67
  }
68
 
69
  function toMinutes(value) {
@@ -81,10 +74,6 @@
81
  return Math.round(value / step) * step;
82
  }
83
 
84
- function clamp(value, min, max) {
85
- return Math.min(Math.max(value, min), max);
86
- }
87
-
88
  function formatWeekPattern(pattern) {
89
  if (pattern === "odd") {
90
  return "单周";
@@ -99,136 +88,112 @@
99
  return ["一", "二", "三", "四", "五", "六", "日"][Number(value) - 1] || "";
100
  }
101
 
102
- function courseCard(course) {
103
- return `
104
- <article class="admin-card admin-row-card course-card" data-course-id="${course.id}">
105
- <div class="admin-card-head">
106
- <div>
107
- <p class="column-label">Course</p>
108
- <h2>${course.title}</h2>
109
- </div>
110
- <span class="task-count">周${weekdayLabel(course.day_of_week)}</span>
111
- </div>
112
- <p class="admin-card-copy">${course.start_time} - ${course.end_time} · 第 ${course.start_week}-${course.end_week} 周 · ${formatWeekPattern(course.week_pattern)}</p>
113
- <p class="admin-card-copy">${course.location || "未填写地点"}</p>
114
- <div class="admin-card-actions">
115
- <button class="secondary-button" type="button" data-edit-course="${course.id}">编辑课程</button>
116
- <button class="danger-button" type="button" data-delete-course="${course.id}">删除课程</button>
117
- </div>
118
- </article>
119
- `;
120
- }
121
-
122
- function categoryCard(category) {
123
- const taskCount = Array.isArray(category.tasks) ? category.tasks.length : Number(category.task_count || 0);
124
- return `
125
- <article class="admin-card admin-row-card" data-category-id="${category.id}">
126
- <div class="admin-card-head">
127
- <div>
128
- <p class="column-label">Category</p>
129
- <h2>${category.name}</h2>
130
- </div>
131
- <span class="task-count">${taskCount} 项任务</span>
132
- </div>
133
- <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
134
- <div class="admin-card-actions">
135
- <button class="danger-button" type="button" data-delete-category="${category.id}">删除此清单</button>
136
- </div>
137
- </article>
138
- `;
139
- }
140
-
141
- function renderCourses() {
142
- if (!courseGrid) {
143
- return;
144
- }
145
- if (!state.courses.length) {
146
- courseGrid.innerHTML = `
147
- <article class="admin-card empty-admin-card">
148
- <h2>还没有固定课程</h2>
149
- <p class="admin-card-copy">点击上方“新增课程”即可录入课程,保存后周课表会自动按周显示。</p>
150
- </article>
151
- `;
152
- return;
153
- }
154
- courseGrid.innerHTML = state.courses.map(courseCard).join("");
155
- }
156
-
157
- function renderCategories() {
158
- if (!adminGrid) {
159
- return;
160
- }
161
- adminGrid.innerHTML = state.categories.map(categoryCard).join("");
162
- }
163
-
164
- function resetCourseForm(course) {
165
- document.getElementById("courseIdInput").value = course ? course.id : "";
166
- document.getElementById("courseTitleInput").value = course ? course.title : "";
167
- document.getElementById("courseLocationInput").value = course ? (course.location || "") : "";
168
- document.getElementById("courseWeekdayInput").value = course ? String(course.day_of_week) : "1";
169
- document.getElementById("courseStartTimeInput").value = course ? course.start_time : "08:15";
170
- document.getElementById("courseEndTimeInput").value = course ? course.end_time : "09:00";
171
- document.getElementById("courseStartWeekInput").value = course ? String(course.start_week) : "1";
172
- document.getElementById("courseEndWeekInput").value = course ? String(course.end_week) : "16";
173
- document.getElementById("courseWeekPatternInput").value = course ? course.week_pattern : "all";
174
- document.getElementById("courseModalTitle").textContent = course ? "编辑课程" : "新增课程";
175
- }
176
-
177
- function cloneSegments(segments) {
178
- return segments.map((segment) => ({ ...segment }));
179
  }
180
 
181
  function buildScheduleSegmentsFromSlots(timeSlots) {
182
  const slots = (timeSlots || [])
183
  .map((slot, index) => ({
184
- label: slot.label || `第${String(index + 1).padStart(2, "0")}节课`,
185
- startMinutes: toMinutes(slot.start),
186
- endMinutes: toMinutes(slot.end),
187
- }))
188
- .sort((left, right) => left.startMinutes - right.startMinutes);
189
 
190
  const segments = [];
191
  slots.forEach((slot, index) => {
192
  if (index > 0) {
193
- const previous = slots[index - 1];
194
- if (slot.startMinutes > previous.endMinutes) {
195
- segments.push({
196
- id: `break-${index}`,
197
- kind: "break",
198
- label: `课间 ${index}`,
199
- startMinutes: previous.endMinutes,
200
- endMinutes: slot.startMinutes,
201
- });
202
- }
203
  }
204
  segments.push({
205
  id: `class-${index + 1}`,
206
  kind: "class",
207
  label: slot.label,
208
- startMinutes: slot.startMinutes,
209
- endMinutes: slot.endMinutes,
210
  });
211
  });
212
  return segments;
213
  }
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  function getEditorPixelsPerMinute() {
216
- return state.scheduleEditor.pixelsPerMinute || 1.1;
217
  }
218
 
219
  function segmentMinDuration(segment) {
220
  return segment.kind === "break" ? 5 : 25;
221
  }
222
 
223
- function buildScheduleAxisMarks(startMinutes, endMinutes, segments) {
224
- const marks = new Set([startMinutes, endMinutes]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  segments.forEach((segment) => {
226
  marks.add(segment.startMinutes);
227
  marks.add(segment.endMinutes);
228
  });
229
-
230
- let hour = Math.floor(startMinutes / 60) * 60;
231
- while (hour <= endMinutes) {
232
  marks.add(hour);
233
  hour += 60;
234
  }
@@ -236,73 +201,119 @@
236
  }
237
 
238
  function renderScheduleEditor() {
239
- if (!scheduleEditorAxis || !scheduleEditorTrack) {
240
  return;
241
  }
242
 
 
243
  const segments = state.scheduleEditor.segments;
 
244
  if (!segments.length) {
245
  scheduleEditorAxis.innerHTML = "";
246
  scheduleEditorTrack.innerHTML = "";
 
247
  if (scheduleSegmentCount) {
248
  scheduleSegmentCount.textContent = "0 段";
249
  }
250
  return;
251
  }
252
 
253
- const firstStart = segments[0].startMinutes;
254
- const lastEnd = segments[segments.length - 1].endMinutes;
255
- state.scheduleEditor.rangeStart = firstStart;
256
- state.scheduleEditor.rangeEnd = lastEnd;
257
- const totalMinutes = Math.max(lastEnd - firstStart, 1);
258
- const height = Math.max(Math.round(totalMinutes * getEditorPixelsPerMinute()), 760);
259
 
260
  scheduleEditorAxis.innerHTML = "";
261
  scheduleEditorTrack.innerHTML = "";
262
  scheduleEditorAxis.style.height = `${height}px`;
263
  scheduleEditorTrack.style.height = `${height}px`;
264
 
265
- const marks = buildScheduleAxisMarks(firstStart, lastEnd, segments);
266
- marks.forEach((minute, index) => {
 
267
  const tick = document.createElement("div");
268
  tick.className = "schedule-editor-tick";
269
  if (index === 0) {
270
  tick.classList.add("is-leading");
271
- }
272
- if (index === marks.length - 1) {
273
  tick.classList.add("is-terminal");
274
  }
275
- tick.style.top = `${Math.round((minute - firstStart) * getEditorPixelsPerMinute())}px`;
276
  tick.textContent = minutesToTime(minute);
277
  scheduleEditorAxis.appendChild(tick);
278
 
279
  const line = document.createElement("div");
280
  line.className = "schedule-editor-line";
281
- line.style.top = `${Math.round((minute - firstStart) * getEditorPixelsPerMinute())}px`;
282
  scheduleEditorTrack.appendChild(line);
283
  });
284
 
285
  segments.forEach((segment, index) => {
286
  const block = document.createElement("article");
287
  block.className = `schedule-editor-segment ${segment.kind}`;
288
- block.style.top = `${Math.round((segment.startMinutes - firstStart) * getEditorPixelsPerMinute())}px`;
289
- block.style.height = `${Math.max(Math.round((segment.endMinutes - segment.startMinutes) * getEditorPixelsPerMinute()), 28)}px`;
290
  block.innerHTML = `
291
  <div class="schedule-editor-segment-copy">
292
  <strong>${segment.label}</strong>
293
  <span>${minutesToTime(segment.startMinutes)} - ${minutesToTime(segment.endMinutes)}</span>
294
  </div>
295
  <div class="schedule-editor-segment-kind">${segment.kind === "class" ? "上课" : "课间"}</div>
296
- <button class="schedule-editor-resize" type="button" data-segment-index="${index}" aria-label="调整时长"></button>
 
297
  `;
298
  scheduleEditorTrack.appendChild(block);
299
  });
300
 
 
301
  if (scheduleSegmentCount) {
302
  scheduleSegmentCount.textContent = `${segments.length} 段`;
303
  }
304
  }
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  function beginScheduleResize(event, index) {
307
  const segment = state.scheduleEditor.segments[index];
308
  if (!segment) {
@@ -313,7 +324,7 @@
313
  index,
314
  pointerId: event.pointerId,
315
  startY: event.clientY,
316
- snapshot: cloneSegments(state.scheduleEditor.segments),
317
  };
318
  }
319
 
@@ -322,50 +333,37 @@
322
  if (!interaction) {
323
  return;
324
  }
325
- const deltaMinutes = snapMinutes((clientY - interaction.startY) / getEditorPixelsPerMinute());
326
- const segments = cloneSegments(interaction.snapshot);
327
  const target = segments[interaction.index];
328
- const currentDuration = target.endMinutes - target.startMinutes;
329
- const nextDuration = Math.max(segmentMinDuration(target), currentDuration + deltaMinutes);
330
- const appliedDelta = nextDuration - currentDuration;
331
-
332
- target.endMinutes = target.startMinutes + nextDuration;
333
- for (let cursor = interaction.index + 1; cursor < segments.length; cursor += 1) {
334
- segments[cursor].startMinutes += appliedDelta;
335
- segments[cursor].endMinutes += appliedDelta;
336
- }
337
-
338
  state.scheduleEditor.segments = segments;
339
  renderScheduleEditor();
340
  }
341
 
342
  function endScheduleResize() {
343
- if (!state.scheduleEditor.interaction) {
344
- return;
345
- }
346
  state.scheduleEditor.interaction = null;
347
  }
348
 
349
  function collectSchedulePayload() {
350
- const classSegments = state.scheduleEditor.segments.filter((segment) => segment.kind === "class");
351
- const firstStart = classSegments.length ? classSegments[0].startMinutes : toMinutes(document.getElementById("dayStartInput").value);
352
- const lastEnd = classSegments.length ? classSegments[classSegments.length - 1].endMinutes : toMinutes(document.getElementById("dayEndInput").value);
353
-
354
- const currentDayStart = toMinutes(document.getElementById("dayStartInput").value);
355
- const currentDayEnd = toMinutes(document.getElementById("dayEndInput").value);
356
- const nextDayStart = Math.min(currentDayStart, firstStart);
357
- const nextDayEnd = Math.max(currentDayEnd, lastEnd);
358
 
359
- document.getElementById("dayStartInput").value = minutesToTime(nextDayStart);
360
- document.getElementById("dayEndInput").value = minutesToTime(nextDayEnd);
 
 
 
361
 
362
  return {
363
  semester_start: document.getElementById("semesterStartInput").value,
364
- day_start: minutesToTime(nextDayStart),
365
- day_end: minutesToTime(nextDayEnd),
366
  default_task_duration_minutes: Number(document.getElementById("defaultDurationInput").value),
367
- time_slots: classSegments.map((segment) => ({
368
- label: segment.label,
369
  start: minutesToTime(segment.startMinutes),
370
  end: minutesToTime(segment.endMinutes),
371
  })),
@@ -373,86 +371,250 @@
373
  }
374
 
375
  function resetScheduleEditor(useDefault) {
376
- const source = useDefault ? state.defaultTimeSlots : (state.scheduleSettings.time_slots || []);
377
  state.scheduleEditor.segments = buildScheduleSegmentsFromSlots(source);
378
  renderScheduleEditor();
379
  }
380
 
381
- function initCoursesPage() {
382
- if (!courseForm || !courseGrid) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  return;
384
  }
 
 
 
 
 
385
 
386
- if (openCourseModalButton) {
387
- openCourseModalButton.addEventListener("click", () => {
388
- resetCourseForm(null);
389
- openModal(courseModal);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  });
391
  }
392
 
393
- courseForm.addEventListener("submit", async (event) => {
 
 
 
394
  event.preventDefault();
395
- const courseId = document.getElementById("courseIdInput").value;
396
- const payload = {
397
- title: document.getElementById("courseTitleInput").value.trim(),
398
- location: document.getElementById("courseLocationInput").value.trim(),
399
- day_of_week: Number(document.getElementById("courseWeekdayInput").value),
400
- start_time: document.getElementById("courseStartTimeInput").value,
401
- end_time: document.getElementById("courseEndTimeInput").value,
402
- start_week: Number(document.getElementById("courseStartWeekInput").value),
403
- end_week: Number(document.getElementById("courseEndWeekInput").value),
404
- week_pattern: document.getElementById("courseWeekPatternInput").value,
405
- };
406
 
407
- try {
408
- const payloadData = await requestJSON(courseId ? `/api/courses/${courseId}` : "/api/courses", {
409
- method: courseId ? "PATCH" : "POST",
410
- body: JSON.stringify(payload),
411
- });
412
- if (courseId) {
413
- state.courses = state.courses.map((course) => (course.id === courseId ? payloadData.course : course));
414
- showToast("课程已更新");
415
- } else {
416
- state.courses = [payloadData.course, ...state.courses];
417
- showToast("课程已创建");
418
- }
419
- renderCourses();
420
- closeModal(courseModal);
421
- } catch (error) {
422
- showToast(error.message, "error");
423
  }
424
  });
425
 
426
- courseGrid.addEventListener("click", async (event) => {
427
- const editButton = event.target.closest("[data-edit-course]");
428
- if (editButton) {
429
- const course = state.courses.find((item) => item.id === editButton.dataset.editCourse);
430
- if (course) {
431
- resetCourseForm(course);
432
- openModal(courseModal);
433
- }
434
  return;
435
  }
 
 
 
 
 
436
 
437
- const deleteButton = event.target.closest("[data-delete-course]");
438
- if (!deleteButton) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  return;
440
  }
 
 
441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  try {
443
- await requestJSON(`/api/courses/${deleteButton.dataset.deleteCourse}`, {
444
- method: "DELETE",
445
- body: JSON.stringify({}),
 
446
  });
447
- state.courses = state.courses.filter((course) => course.id !== deleteButton.dataset.deleteCourse);
448
- renderCourses();
449
- showToast("课程已删除");
 
450
  } catch (error) {
451
  showToast(error.message, "error");
452
  }
453
  });
454
-
455
- renderCourses();
456
  }
457
 
458
  function initListsPage() {
@@ -460,6 +622,8 @@
460
  return;
461
  }
462
 
 
 
463
  createCategoryForm.addEventListener("submit", async (event) => {
464
  event.preventDefault();
465
  const nameInput = document.getElementById("newCategoryName");
@@ -472,7 +636,7 @@
472
  state.categories = [...state.categories, payload.category];
473
  renderCategories();
474
  nameInput.value = "";
475
- showToast("新分类已创建");
476
  } catch (error) {
477
  showToast(error.message, "error");
478
  }
@@ -490,83 +654,80 @@
490
  });
491
  state.categories = state.categories.filter((category) => category.id !== button.dataset.deleteCategory);
492
  renderCategories();
493
- showToast("分类已删除");
494
  } catch (error) {
495
  showToast(error.message, "error");
496
  }
497
  });
498
-
499
- renderCategories();
500
  }
501
 
502
- function initSchedulePage() {
503
- if (!scheduleSettingsForm || !scheduleEditorAxis || !scheduleEditorTrack) {
504
  return;
505
  }
506
 
507
- resetScheduleEditor(false);
 
 
508
 
509
- scheduleEditorTrack.addEventListener("pointerdown", (event) => {
510
- const handle = event.target.closest("[data-segment-index]");
511
- if (!handle) {
512
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  }
514
- beginScheduleResize(event, Number(handle.dataset.segmentIndex));
515
  });
516
 
517
- document.addEventListener("pointermove", (event) => {
518
- if (!state.scheduleEditor.interaction || event.pointerId !== state.scheduleEditor.interaction.pointerId) {
 
 
 
 
 
519
  return;
520
  }
521
- event.preventDefault();
522
- updateScheduleResize(event.clientY);
523
- });
524
 
525
- document.addEventListener("pointerup", (event) => {
526
- if (!state.scheduleEditor.interaction || event.pointerId !== state.scheduleEditor.interaction.pointerId) {
527
  return;
528
  }
529
- endScheduleResize();
530
- });
531
-
532
- document.addEventListener("pointercancel", endScheduleResize);
533
-
534
- if (resetTimelineButton) {
535
- resetTimelineButton.addEventListener("click", () => {
536
- resetScheduleEditor(true);
537
- showToast("已恢复默认节次,记得保存");
538
- });
539
- }
540
 
541
- scheduleSettingsForm.addEventListener("submit", async (event) => {
542
- event.preventDefault();
543
  try {
544
- const payload = collectSchedulePayload();
545
- const result = await requestJSON("/api/settings/schedule", {
546
- method: "PATCH",
547
- body: JSON.stringify(payload),
548
  });
549
- state.scheduleSettings = result.settings;
550
- state.scheduleEditor.segments = buildScheduleSegmentsFromSlots(result.settings.time_slots || []);
551
- renderScheduleEditor();
552
- showToast("时间表设置保存");
553
  } catch (error) {
554
  showToast(error.message, "error");
555
  }
556
  });
557
  }
558
 
559
- document.addEventListener("click", (event) => {
560
- const closeTarget = event.target.closest("[data-close-modal]");
561
- if (closeTarget) {
562
- closeModal(document.getElementById(closeTarget.dataset.closeModal));
563
- }
564
- if (event.target.classList.contains("modal-backdrop")) {
565
- closeModal(event.target);
566
- }
567
- });
568
-
569
- initCoursesPage();
570
- initListsPage();
571
  initSchedulePage();
 
 
572
  })();
 
9
  scheduleEditor: {
10
  segments: [],
11
  interaction: null,
12
+ dragKind: null,
13
+ pixelsPerMinute: 1.65,
 
14
  },
15
  };
16
 
17
  const toastStack = document.getElementById("toastStack");
 
 
 
 
 
 
18
  const scheduleSettingsForm = document.getElementById("scheduleSettingsForm");
19
  const scheduleEditorAxis = document.getElementById("scheduleEditorAxis");
20
  const scheduleEditorTrack = document.getElementById("scheduleEditorTrack");
21
+ const scheduleEditorDropzone = document.getElementById("scheduleEditorDropzone");
22
  const scheduleSegmentCount = document.getElementById("scheduleSegmentCount");
23
  const resetTimelineButton = document.getElementById("resetTimelineButton");
24
+ const schedulePalette = document.getElementById("schedulePalette");
25
+ const createCategoryForm = document.getElementById("createCategoryForm");
26
+ const adminGrid = document.getElementById("adminGrid");
27
+ const courseGrid = document.getElementById("courseGrid");
28
+ const courseForm = document.getElementById("courseForm");
29
+ const resetCourseEditorButton = document.getElementById("resetCourseEditorButton");
30
+ const courseEditorHeading = document.getElementById("courseEditorHeading");
31
 
32
  if (!toastStack) {
33
  return;
 
55
  return payload;
56
  }
57
 
58
+ function clamp(value, min, max) {
59
+ return Math.min(Math.max(value, min), max);
 
 
 
 
 
 
 
 
60
  }
61
 
62
  function toMinutes(value) {
 
74
  return Math.round(value / step) * step;
75
  }
76
 
 
 
 
 
77
  function formatWeekPattern(pattern) {
78
  if (pattern === "odd") {
79
  return "单周";
 
88
  return ["一", "二", "三", "四", "五", "六", "日"][Number(value) - 1] || "";
89
  }
90
 
91
+ function getConfiguredTimeSlots() {
92
+ const slots = Array.isArray(state.scheduleSettings.time_slots) && state.scheduleSettings.time_slots.length
93
+ ? state.scheduleSettings.time_slots
94
+ : state.defaultTimeSlots;
95
+ return slots.map((slot, index) => ({
96
+ label: slot.label || `第${String(index + 1).padStart(2, "0")}节课`,
97
+ start: slot.start,
98
+ end: slot.end,
99
+ }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  }
101
 
102
  function buildScheduleSegmentsFromSlots(timeSlots) {
103
  const slots = (timeSlots || [])
104
  .map((slot, index) => ({
105
+ kind: "class",
106
+ label: `第${String(index + 1).padStart(2, "0")}节课`,
107
+ durationMinutes: Math.max(25, toMinutes(slot.end) - toMinutes(slot.start)),
108
+ }));
 
109
 
110
  const segments = [];
111
  slots.forEach((slot, index) => {
112
  if (index > 0) {
113
+ const previous = timeSlots[index - 1];
114
+ const gap = Math.max(5, toMinutes(timeSlots[index].start) - toMinutes(previous.end));
115
+ segments.push({
116
+ id: `break-${index}`,
117
+ kind: "break",
118
+ label: `课间 ${index}`,
119
+ durationMinutes: gap,
120
+ });
 
 
121
  }
122
  segments.push({
123
  id: `class-${index + 1}`,
124
  kind: "class",
125
  label: slot.label,
126
+ durationMinutes: slot.durationMinutes,
 
127
  });
128
  });
129
  return segments;
130
  }
131
 
132
+ function renumberSegments(segments) {
133
+ let classIndex = 1;
134
+ let breakIndex = 1;
135
+ segments.forEach((segment) => {
136
+ if (segment.kind === "class") {
137
+ segment.label = `第${String(classIndex).padStart(2, "0")}节课`;
138
+ classIndex += 1;
139
+ } else {
140
+ segment.label = `课间 ${breakIndex}`;
141
+ breakIndex += 1;
142
+ }
143
+ });
144
+ }
145
+
146
+ function reflowSegments() {
147
+ const baseStart = toMinutes(document.getElementById("dayStartInput").value);
148
+ let cursor = baseStart;
149
+ renumberSegments(state.scheduleEditor.segments);
150
+ state.scheduleEditor.segments.forEach((segment) => {
151
+ segment.startMinutes = cursor;
152
+ segment.endMinutes = cursor + segment.durationMinutes;
153
+ cursor = segment.endMinutes;
154
+ });
155
+ }
156
+
157
  function getEditorPixelsPerMinute() {
158
+ return state.scheduleEditor.pixelsPerMinute || 1.65;
159
  }
160
 
161
  function segmentMinDuration(segment) {
162
  return segment.kind === "break" ? 5 : 25;
163
  }
164
 
165
+ function validateSegmentOrder(segments) {
166
+ if (!segments.length) {
167
+ return "请至少保留一节课";
168
+ }
169
+ if (segments[0].kind !== "class") {
170
+ return "时间表必须从课程开始";
171
+ }
172
+ if (segments[segments.length - 1].kind !== "class") {
173
+ return "时间表必须以课程结束";
174
+ }
175
+ for (let index = 1; index < segments.length; index += 1) {
176
+ if (segments[index].kind === segments[index - 1].kind) {
177
+ return "课程后面必须是休息,休息后面必须是课程";
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
183
+ function buildScheduleAxisMarks() {
184
+ const segments = state.scheduleEditor.segments;
185
+ if (!segments.length) {
186
+ return [];
187
+ }
188
+ const marks = new Set();
189
+ marks.add(segments[0].startMinutes);
190
+ marks.add(segments[segments.length - 1].endMinutes);
191
  segments.forEach((segment) => {
192
  marks.add(segment.startMinutes);
193
  marks.add(segment.endMinutes);
194
  });
195
+ let hour = Math.floor(segments[0].startMinutes / 60) * 60;
196
+ while (hour <= segments[segments.length - 1].endMinutes) {
 
197
  marks.add(hour);
198
  hour += 60;
199
  }
 
201
  }
202
 
203
  function renderScheduleEditor() {
204
+ if (!scheduleEditorAxis || !scheduleEditorTrack || !scheduleEditorDropzone) {
205
  return;
206
  }
207
 
208
+ reflowSegments();
209
  const segments = state.scheduleEditor.segments;
210
+
211
  if (!segments.length) {
212
  scheduleEditorAxis.innerHTML = "";
213
  scheduleEditorTrack.innerHTML = "";
214
+ scheduleEditorTrack.appendChild(scheduleEditorDropzone);
215
  if (scheduleSegmentCount) {
216
  scheduleSegmentCount.textContent = "0 段";
217
  }
218
  return;
219
  }
220
 
221
+ const startMinutes = segments[0].startMinutes;
222
+ const endMinutes = segments[segments.length - 1].endMinutes;
223
+ const totalMinutes = Math.max(endMinutes - startMinutes, 1);
224
+ const height = Math.max(Math.round(totalMinutes * getEditorPixelsPerMinute()), 980);
 
 
225
 
226
  scheduleEditorAxis.innerHTML = "";
227
  scheduleEditorTrack.innerHTML = "";
228
  scheduleEditorAxis.style.height = `${height}px`;
229
  scheduleEditorTrack.style.height = `${height}px`;
230
 
231
+ buildScheduleAxisMarks().forEach((minute, index, marks) => {
232
+ const top = Math.round((minute - startMinutes) * getEditorPixelsPerMinute());
233
+
234
  const tick = document.createElement("div");
235
  tick.className = "schedule-editor-tick";
236
  if (index === 0) {
237
  tick.classList.add("is-leading");
238
+ } else if (index === marks.length - 1) {
 
239
  tick.classList.add("is-terminal");
240
  }
241
+ tick.style.top = `${top}px`;
242
  tick.textContent = minutesToTime(minute);
243
  scheduleEditorAxis.appendChild(tick);
244
 
245
  const line = document.createElement("div");
246
  line.className = "schedule-editor-line";
247
+ line.style.top = `${top}px`;
248
  scheduleEditorTrack.appendChild(line);
249
  });
250
 
251
  segments.forEach((segment, index) => {
252
  const block = document.createElement("article");
253
  block.className = `schedule-editor-segment ${segment.kind}`;
254
+ block.style.top = `${Math.round((segment.startMinutes - startMinutes) * getEditorPixelsPerMinute())}px`;
255
+ block.style.height = `${Math.max(Math.round(segment.durationMinutes * getEditorPixelsPerMinute()), 36)}px`;
256
  block.innerHTML = `
257
  <div class="schedule-editor-segment-copy">
258
  <strong>${segment.label}</strong>
259
  <span>${minutesToTime(segment.startMinutes)} - ${minutesToTime(segment.endMinutes)}</span>
260
  </div>
261
  <div class="schedule-editor-segment-kind">${segment.kind === "class" ? "上课" : "课间"}</div>
262
+ ${segment.kind === "class" ? `<button class="schedule-editor-delete" type="button" data-delete-segment="${index}">删除</button>` : ""}
263
+ <button class="schedule-editor-resize" type="button" data-resize-segment="${index}" aria-label="调整时长"></button>
264
  `;
265
  scheduleEditorTrack.appendChild(block);
266
  });
267
 
268
+ scheduleEditorTrack.appendChild(scheduleEditorDropzone);
269
  if (scheduleSegmentCount) {
270
  scheduleSegmentCount.textContent = `${segments.length} 段`;
271
  }
272
  }
273
 
274
+ function appendSegment(kind) {
275
+ const segments = state.scheduleEditor.segments;
276
+ if (!segments.length && kind !== "class") {
277
+ showToast("时间表必须从课程开始", "error");
278
+ return;
279
+ }
280
+ if (segments.length && segments[segments.length - 1].kind === kind) {
281
+ showToast("课程后面必须是休息,休息后面必须是课程", "error");
282
+ return;
283
+ }
284
+
285
+ segments.push({
286
+ id: `${kind}-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
287
+ kind,
288
+ label: kind === "class" ? "新课程" : "新课间",
289
+ durationMinutes: kind === "class" ? 45 : 10,
290
+ });
291
+ renderScheduleEditor();
292
+ }
293
+
294
+ function deleteClassSegment(index) {
295
+ const segments = [...state.scheduleEditor.segments];
296
+ const target = segments[index];
297
+ if (!target || target.kind !== "class") {
298
+ return;
299
+ }
300
+
301
+ if (index === 0) {
302
+ segments.splice(index, segments[index + 1]?.kind === "break" ? 2 : 1);
303
+ } else if (index === segments.length - 1) {
304
+ segments.splice(index - 1, segments[index - 1]?.kind === "break" ? 2 : 1);
305
+ } else {
306
+ segments.splice(index, 1);
307
+ if (segments[index - 1] && segments[index] && segments[index - 1].kind === "break" && segments[index].kind === "break") {
308
+ segments[index - 1].durationMinutes += segments[index].durationMinutes;
309
+ segments.splice(index, 1);
310
+ }
311
+ }
312
+
313
+ state.scheduleEditor.segments = segments.filter(Boolean);
314
+ renderScheduleEditor();
315
+ }
316
+
317
  function beginScheduleResize(event, index) {
318
  const segment = state.scheduleEditor.segments[index];
319
  if (!segment) {
 
324
  index,
325
  pointerId: event.pointerId,
326
  startY: event.clientY,
327
+ snapshot: state.scheduleEditor.segments.map((item) => ({ ...item })),
328
  };
329
  }
330
 
 
333
  if (!interaction) {
334
  return;
335
  }
336
+ const segments = interaction.snapshot.map((item) => ({ ...item }));
 
337
  const target = segments[interaction.index];
338
+ const deltaMinutes = snapMinutes((clientY - interaction.startY) / getEditorPixelsPerMinute());
339
+ target.durationMinutes = Math.max(segmentMinDuration(target), target.durationMinutes + deltaMinutes);
 
 
 
 
 
 
 
 
340
  state.scheduleEditor.segments = segments;
341
  renderScheduleEditor();
342
  }
343
 
344
  function endScheduleResize() {
 
 
 
345
  state.scheduleEditor.interaction = null;
346
  }
347
 
348
  function collectSchedulePayload() {
349
+ const error = validateSegmentOrder(state.scheduleEditor.segments);
350
+ if (error) {
351
+ throw new Error(error);
352
+ }
 
 
 
 
353
 
354
+ reflowSegments();
355
+ const classSegments = state.scheduleEditor.segments.filter((segment) => segment.kind === "class");
356
+ const dayStart = document.getElementById("dayStartInput").value;
357
+ const dayEnd = minutesToTime(classSegments[classSegments.length - 1].endMinutes);
358
+ document.getElementById("dayEndInput").value = dayEnd;
359
 
360
  return {
361
  semester_start: document.getElementById("semesterStartInput").value,
362
+ day_start: dayStart,
363
+ day_end: dayEnd,
364
  default_task_duration_minutes: Number(document.getElementById("defaultDurationInput").value),
365
+ time_slots: classSegments.map((segment, index) => ({
366
+ label: `第${String(index + 1).padStart(2, "0")}节课`,
367
  start: minutesToTime(segment.startMinutes),
368
  end: minutesToTime(segment.endMinutes),
369
  })),
 
371
  }
372
 
373
  function resetScheduleEditor(useDefault) {
374
+ const source = useDefault ? state.defaultTimeSlots : getConfiguredTimeSlots();
375
  state.scheduleEditor.segments = buildScheduleSegmentsFromSlots(source);
376
  renderScheduleEditor();
377
  }
378
 
379
+ function buildPeriodOptions() {
380
+ const slots = getConfiguredTimeSlots();
381
+ return slots.map((slot, index) => ({
382
+ value: String(index + 1),
383
+ label: `第${String(index + 1).padStart(2, "0")}节 · ${slot.start}-${slot.end}`,
384
+ }));
385
+ }
386
+
387
+ function findPeriodByBoundary(boundary, edge) {
388
+ const slots = getConfiguredTimeSlots();
389
+ const index = slots.findIndex((slot) => slot[edge] === boundary);
390
+ return index >= 0 ? String(index + 1) : "1";
391
+ }
392
+
393
+ function populateCoursePeriodOptions() {
394
+ const startSelect = document.getElementById("courseStartPeriodInput");
395
+ const endSelect = document.getElementById("courseEndPeriodInput");
396
+ if (!startSelect || !endSelect) {
397
  return;
398
  }
399
+ const options = buildPeriodOptions();
400
+ const markup = options.map((option) => `<option value="${option.value}">${option.label}</option>`).join("");
401
+ startSelect.innerHTML = markup;
402
+ endSelect.innerHTML = markup;
403
+ }
404
 
405
+ function courseCard(course) {
406
+ return `
407
+ <article class="admin-card admin-row-card course-card" data-course-id="${course.id}">
408
+ <div class="admin-card-head course-row-head">
409
+ <div>
410
+ <p class="column-label">Course</p>
411
+ <h2>${course.title}</h2>
412
+ </div>
413
+ <div class="course-row-actions">
414
+ <span class="task-count">周${weekdayLabel(course.day_of_week)}</span>
415
+ <button class="secondary-button" type="button" data-edit-course="${course.id}">编辑课程</button>
416
+ <button class="danger-button" type="button" data-delete-course="${course.id}">删除课程</button>
417
+ </div>
418
+ </div>
419
+ <p class="admin-card-copy">${course.start_time} - ${course.end_time} · 第 ${course.start_week}-${course.end_week} 周 · ${formatWeekPattern(course.week_pattern)}</p>
420
+ <p class="admin-card-copy">${course.location || "未填写地点"}</p>
421
+ </article>
422
+ `;
423
+ }
424
+
425
+ function renderCourses() {
426
+ if (!courseGrid) {
427
+ return;
428
+ }
429
+ if (!state.courses.length) {
430
+ courseGrid.innerHTML = `
431
+ <article class="admin-card empty-admin-card">
432
+ <h2>还没有固定课程</h2>
433
+ <p class="admin-card-copy">在左侧填写课程信息并保存后,周课表会自动按周显示。</p>
434
+ </article>
435
+ `;
436
+ return;
437
+ }
438
+ courseGrid.innerHTML = state.courses.map(courseCard).join("");
439
+ }
440
+
441
+ function resetCourseForm(course = null) {
442
+ const slots = getConfiguredTimeSlots();
443
+ document.getElementById("courseIdInput").value = course ? course.id : "";
444
+ document.getElementById("courseTitleInput").value = course ? course.title : "";
445
+ document.getElementById("courseLocationInput").value = course ? (course.location || "") : "";
446
+ document.getElementById("courseWeekdayInput").value = course ? String(course.day_of_week) : "1";
447
+ document.getElementById("courseStartPeriodInput").value = course ? findPeriodByBoundary(course.start_time, "start") : "1";
448
+ document.getElementById("courseEndPeriodInput").value = course ? findPeriodByBoundary(course.end_time, "end") : String(Math.min(2, slots.length || 1));
449
+ document.getElementById("courseStartWeekInput").value = course ? String(course.start_week) : "1";
450
+ document.getElementById("courseEndWeekInput").value = course ? String(course.end_week) : "16";
451
+ document.getElementById("courseWeekPatternInput").value = course ? course.week_pattern : "all";
452
+ if (courseEditorHeading) {
453
+ courseEditorHeading.textContent = course ? "编辑课程" : "新增课程";
454
+ }
455
+ }
456
+
457
+ function collectCoursePayload() {
458
+ const slots = getConfiguredTimeSlots();
459
+ const startPeriod = Number(document.getElementById("courseStartPeriodInput").value);
460
+ const endPeriod = Number(document.getElementById("courseEndPeriodInput").value);
461
+ if (endPeriod < startPeriod) {
462
+ throw new Error("结束节次不能早于开始节次");
463
+ }
464
+ const startSlot = slots[startPeriod - 1];
465
+ const endSlot = slots[endPeriod - 1];
466
+ if (!startSlot || !endSlot) {
467
+ throw new Error("请选择有效的节次");
468
+ }
469
+
470
+ return {
471
+ title: document.getElementById("courseTitleInput").value.trim(),
472
+ location: document.getElementById("courseLocationInput").value.trim(),
473
+ day_of_week: Number(document.getElementById("courseWeekdayInput").value),
474
+ start_time: startSlot.start,
475
+ end_time: endSlot.end,
476
+ start_week: Number(document.getElementById("courseStartWeekInput").value),
477
+ end_week: Number(document.getElementById("courseEndWeekInput").value),
478
+ week_pattern: document.getElementById("courseWeekPatternInput").value,
479
+ };
480
+ }
481
+
482
+ function categoryCard(category) {
483
+ const taskCount = Array.isArray(category.tasks) ? category.tasks.length : Number(category.task_count || 0);
484
+ return `
485
+ <article class="admin-card admin-row-card list-row-card" data-category-id="${category.id}">
486
+ <div class="admin-row-main">
487
+ <div>
488
+ <p class="column-label">Category</p>
489
+ <h2>${category.name}</h2>
490
+ <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
491
+ </div>
492
+ <div class="admin-row-side">
493
+ <span class="task-count">${taskCount} 项任务</span>
494
+ <button class="danger-button" type="button" data-delete-category="${category.id}">删除此清单</button>
495
+ </div>
496
+ </div>
497
+ </article>
498
+ `;
499
+ }
500
+
501
+ function renderCategories() {
502
+ if (!adminGrid) {
503
+ return;
504
+ }
505
+ adminGrid.innerHTML = state.categories.map(categoryCard).join("");
506
+ }
507
+
508
+ function initSchedulePage() {
509
+ if (!scheduleSettingsForm || !scheduleEditorAxis || !scheduleEditorTrack || !scheduleEditorDropzone) {
510
+ return;
511
+ }
512
+
513
+ resetScheduleEditor(false);
514
+
515
+ if (schedulePalette) {
516
+ schedulePalette.addEventListener("dragstart", (event) => {
517
+ const card = event.target.closest("[data-palette-kind]");
518
+ if (!card) {
519
+ return;
520
+ }
521
+ state.scheduleEditor.dragKind = card.dataset.paletteKind;
522
+ event.dataTransfer.effectAllowed = "copy";
523
+ event.dataTransfer.setData("text/plain", state.scheduleEditor.dragKind);
524
+ });
525
+
526
+ schedulePalette.addEventListener("dragend", () => {
527
+ state.scheduleEditor.dragKind = null;
528
  });
529
  }
530
 
531
+ scheduleEditorTrack.addEventListener("dragover", (event) => {
532
+ if (!state.scheduleEditor.dragKind) {
533
+ return;
534
+ }
535
  event.preventDefault();
536
+ scheduleEditorDropzone.classList.add("is-active");
537
+ });
 
 
 
 
 
 
 
 
 
538
 
539
+ scheduleEditorTrack.addEventListener("dragleave", (event) => {
540
+ if (!scheduleEditorTrack.contains(event.relatedTarget)) {
541
+ scheduleEditorDropzone.classList.remove("is-active");
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  }
543
  });
544
 
545
+ scheduleEditorTrack.addEventListener("drop", (event) => {
546
+ if (!state.scheduleEditor.dragKind) {
 
 
 
 
 
 
547
  return;
548
  }
549
+ event.preventDefault();
550
+ scheduleEditorDropzone.classList.remove("is-active");
551
+ appendSegment(state.scheduleEditor.dragKind);
552
+ state.scheduleEditor.dragKind = null;
553
+ });
554
 
555
+ scheduleEditorTrack.addEventListener("click", (event) => {
556
+ const deleteButton = event.target.closest("[data-delete-segment]");
557
+ if (deleteButton) {
558
+ deleteClassSegment(Number(deleteButton.dataset.deleteSegment));
559
+ return;
560
+ }
561
+
562
+ const resizeHandle = event.target.closest("[data-resize-segment]");
563
+ if (resizeHandle) {
564
+ return;
565
+ }
566
+ });
567
+
568
+ scheduleEditorTrack.addEventListener("pointerdown", (event) => {
569
+ const resizeHandle = event.target.closest("[data-resize-segment]");
570
+ if (!resizeHandle) {
571
+ return;
572
+ }
573
+ beginScheduleResize(event, Number(resizeHandle.dataset.resizeSegment));
574
+ });
575
+
576
+ document.addEventListener("pointermove", (event) => {
577
+ if (!state.scheduleEditor.interaction || event.pointerId !== state.scheduleEditor.interaction.pointerId) {
578
+ return;
579
+ }
580
+ event.preventDefault();
581
+ updateScheduleResize(event.clientY);
582
+ });
583
+
584
+ document.addEventListener("pointerup", (event) => {
585
+ if (!state.scheduleEditor.interaction || event.pointerId !== state.scheduleEditor.interaction.pointerId) {
586
  return;
587
  }
588
+ endScheduleResize();
589
+ });
590
 
591
+ document.addEventListener("pointercancel", endScheduleResize);
592
+
593
+ if (resetTimelineButton) {
594
+ resetTimelineButton.addEventListener("click", () => {
595
+ resetScheduleEditor(true);
596
+ showToast("已恢复默认节次,记得保存");
597
+ });
598
+ }
599
+
600
+ document.getElementById("dayStartInput").addEventListener("change", renderScheduleEditor);
601
+
602
+ scheduleSettingsForm.addEventListener("submit", async (event) => {
603
+ event.preventDefault();
604
  try {
605
+ const payload = collectSchedulePayload();
606
+ const result = await requestJSON("/api/settings/schedule", {
607
+ method: "PATCH",
608
+ body: JSON.stringify(payload),
609
  });
610
+ state.scheduleSettings = result.settings;
611
+ resetScheduleEditor(false);
612
+ populateCoursePeriodOptions();
613
+ showToast("时间表设置已保存");
614
  } catch (error) {
615
  showToast(error.message, "error");
616
  }
617
  });
 
 
618
  }
619
 
620
  function initListsPage() {
 
622
  return;
623
  }
624
 
625
+ renderCategories();
626
+
627
  createCategoryForm.addEventListener("submit", async (event) => {
628
  event.preventDefault();
629
  const nameInput = document.getElementById("newCategoryName");
 
636
  state.categories = [...state.categories, payload.category];
637
  renderCategories();
638
  nameInput.value = "";
639
+ showToast("新清单已创建");
640
  } catch (error) {
641
  showToast(error.message, "error");
642
  }
 
654
  });
655
  state.categories = state.categories.filter((category) => category.id !== button.dataset.deleteCategory);
656
  renderCategories();
657
+ showToast("清单已删除");
658
  } catch (error) {
659
  showToast(error.message, "error");
660
  }
661
  });
 
 
662
  }
663
 
664
+ function initCoursesPage() {
665
+ if (!courseGrid || !courseForm) {
666
  return;
667
  }
668
 
669
+ populateCoursePeriodOptions();
670
+ resetCourseForm(null);
671
+ renderCourses();
672
 
673
+ if (resetCourseEditorButton) {
674
+ resetCourseEditorButton.addEventListener("click", () => resetCourseForm(null));
675
+ }
676
+
677
+ courseForm.addEventListener("submit", async (event) => {
678
+ event.preventDefault();
679
+ const courseId = document.getElementById("courseIdInput").value;
680
+ try {
681
+ const payload = collectCoursePayload();
682
+ const result = await requestJSON(courseId ? `/api/courses/${courseId}` : "/api/courses", {
683
+ method: courseId ? "PATCH" : "POST",
684
+ body: JSON.stringify(payload),
685
+ });
686
+ if (courseId) {
687
+ state.courses = state.courses.map((course) => (course.id === courseId ? result.course : course));
688
+ showToast("课程已更新");
689
+ } else {
690
+ state.courses = [result.course, ...state.courses];
691
+ showToast("课程已创建");
692
+ }
693
+ renderCourses();
694
+ resetCourseForm(null);
695
+ } catch (error) {
696
+ showToast(error.message, "error");
697
  }
 
698
  });
699
 
700
+ courseGrid.addEventListener("click", async (event) => {
701
+ const editButton = event.target.closest("[data-edit-course]");
702
+ if (editButton) {
703
+ const course = state.courses.find((item) => item.id === editButton.dataset.editCourse);
704
+ if (course) {
705
+ resetCourseForm(course);
706
+ }
707
  return;
708
  }
 
 
 
709
 
710
+ const deleteButton = event.target.closest("[data-delete-course]");
711
+ if (!deleteButton) {
712
  return;
713
  }
 
 
 
 
 
 
 
 
 
 
 
714
 
 
 
715
  try {
716
+ await requestJSON(`/api/courses/${deleteButton.dataset.deleteCourse}`, {
717
+ method: "DELETE",
718
+ body: JSON.stringify({}),
 
719
  });
720
+ state.courses = state.courses.filter((course) => course.id !== deleteButton.dataset.deleteCourse);
721
+ renderCourses();
722
+ resetCourseForm(null);
723
+ showToast("课程删除");
724
  } catch (error) {
725
  showToast(error.message, "error");
726
  }
727
  });
728
  }
729
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  initSchedulePage();
731
+ initListsPage();
732
+ initCoursesPage();
733
  })();
static/v020.css CHANGED
@@ -826,7 +826,7 @@ body.planner-interacting .page-planner {
826
  .planner-sidebar {
827
  padding: 14px;
828
  display: grid;
829
- gap: 12px;
830
  align-content: start;
831
  min-height: 0;
832
  grid-template-rows: auto minmax(0, 1fr);
@@ -843,7 +843,7 @@ body.planner-interacting .page-planner {
843
  .planner-task-pool {
844
  display: flex;
845
  flex-direction: column;
846
- gap: 10px;
847
  min-height: 0;
848
  height: 100%;
849
  max-height: 100%;
@@ -853,26 +853,34 @@ body.planner-interacting .page-planner {
853
  }
854
 
855
  .planner-task-card {
856
- padding: 12px;
857
- border-radius: 18px;
858
  border: 1px solid rgba(255, 255, 255, 0.08);
859
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.03) 100%);
860
  display: grid;
861
- gap: 10px;
862
  }
863
 
864
  .planner-task-card h4 {
865
  margin: 0;
866
- font-size: 0.95rem;
 
 
 
 
 
867
  }
868
 
869
  .planner-task-top {
870
- justify-content: space-between;
871
- gap: 12px;
 
 
872
  }
873
 
874
  .planner-task-tags {
875
- flex-wrap: wrap;
 
876
  gap: 8px;
877
  }
878
 
@@ -882,6 +890,18 @@ body.planner-interacting .page-planner {
882
  background: rgba(255, 255, 255, 0.05);
883
  color: var(--muted-strong);
884
  font-size: 0.76rem;
 
 
 
 
 
 
 
 
 
 
 
 
885
  }
886
 
887
  .planner-empty,
@@ -994,6 +1014,71 @@ body.planner-interacting .page-planner {
994
  border-radius: 22px;
995
  }
996
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
  .admin-schedule-grid {
998
  grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
999
  align-items: start;
@@ -1008,6 +1093,32 @@ body.planner-interacting .page-planner {
1008
  padding: 22px;
1009
  }
1010
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  .schedule-editor-shell {
1012
  display: grid;
1013
  grid-template-columns: 92px minmax(0, 1fr);
@@ -1032,6 +1143,7 @@ body.planner-interacting .page-planner {
1032
  rgba(3, 11, 20, 0.42);
1033
  border: 1px solid rgba(255, 255, 255, 0.06);
1034
  overflow: hidden;
 
1035
  }
1036
 
1037
  .schedule-editor-tick,
@@ -1106,6 +1218,18 @@ body.planner-interacting .page-planner {
1106
  right: 14px;
1107
  }
1108
 
 
 
 
 
 
 
 
 
 
 
 
 
1109
  .schedule-editor-resize {
1110
  position: absolute;
1111
  left: 14px;
@@ -1118,6 +1242,29 @@ body.planner-interacting .page-planner {
1118
  cursor: ns-resize;
1119
  }
1120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1121
  @media (max-width: 1180px) {
1122
  body {
1123
  overflow: auto;
@@ -1148,10 +1295,20 @@ body.planner-interacting .page-planner {
1148
  grid-template-columns: 1fr;
1149
  }
1150
 
 
 
 
 
 
1151
  .schedule-form-card {
1152
  position: static;
1153
  }
1154
 
 
 
 
 
 
1155
  .admin-page-nav {
1156
  flex-wrap: wrap;
1157
  }
@@ -1182,6 +1339,16 @@ body.planner-interacting .page-planner {
1182
  .schedule-editor-shell {
1183
  grid-template-columns: 72px minmax(0, 1fr);
1184
  }
 
 
 
 
 
 
 
 
 
 
1185
  }
1186
 
1187
  @media (max-width: 560px) {
 
826
  .planner-sidebar {
827
  padding: 14px;
828
  display: grid;
829
+ gap: 10px;
830
  align-content: start;
831
  min-height: 0;
832
  grid-template-rows: auto minmax(0, 1fr);
 
843
  .planner-task-pool {
844
  display: flex;
845
  flex-direction: column;
846
+ gap: 12px;
847
  min-height: 0;
848
  height: 100%;
849
  max-height: 100%;
 
853
  }
854
 
855
  .planner-task-card {
856
+ padding: 14px;
857
+ border-radius: 20px;
858
  border: 1px solid rgba(255, 255, 255, 0.08);
859
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.03) 100%);
860
  display: grid;
861
+ gap: 12px;
862
  }
863
 
864
  .planner-task-card h4 {
865
  margin: 0;
866
+ font-size: 0.98rem;
867
+ line-height: 1.18;
868
+ display: -webkit-box;
869
+ -webkit-box-orient: vertical;
870
+ -webkit-line-clamp: 2;
871
+ overflow: hidden;
872
  }
873
 
874
  .planner-task-top {
875
+ display: grid;
876
+ grid-template-columns: minmax(0, 1fr) auto;
877
+ align-items: start;
878
+ gap: 10px;
879
  }
880
 
881
  .planner-task-tags {
882
+ display: grid;
883
+ grid-template-columns: repeat(2, minmax(0, 1fr));
884
  gap: 8px;
885
  }
886
 
 
890
  background: rgba(255, 255, 255, 0.05);
891
  color: var(--muted-strong);
892
  font-size: 0.76rem;
893
+ white-space: nowrap;
894
+ overflow: hidden;
895
+ text-overflow: ellipsis;
896
+ }
897
+
898
+ .planner-task-tags span:last-child {
899
+ grid-column: 1 / -1;
900
+ }
901
+
902
+ .planner-task-category {
903
+ white-space: nowrap;
904
+ align-self: start;
905
  }
906
 
907
  .planner-empty,
 
1014
  border-radius: 22px;
1015
  }
1016
 
1017
+ .admin-lists-grid {
1018
+ grid-template-columns: minmax(320px, 360px) minmax(0, 1fr);
1019
+ align-items: start;
1020
+ }
1021
+
1022
+ .list-create-card {
1023
+ position: sticky;
1024
+ top: 28px;
1025
+ }
1026
+
1027
+ .list-management-stack {
1028
+ gap: 14px;
1029
+ }
1030
+
1031
+ .list-row-card {
1032
+ border-radius: 24px;
1033
+ }
1034
+
1035
+ .admin-row-main {
1036
+ display: grid;
1037
+ grid-template-columns: minmax(0, 1fr) auto;
1038
+ gap: 18px;
1039
+ align-items: start;
1040
+ }
1041
+
1042
+ .admin-row-side {
1043
+ display: grid;
1044
+ gap: 10px;
1045
+ justify-items: end;
1046
+ }
1047
+
1048
+ .course-management-grid {
1049
+ grid-template-columns: minmax(330px, 380px) minmax(0, 1fr);
1050
+ align-items: start;
1051
+ }
1052
+
1053
+ .course-editor-card {
1054
+ position: sticky;
1055
+ top: 28px;
1056
+ }
1057
+
1058
+ .course-row-head {
1059
+ align-items: start;
1060
+ }
1061
+
1062
+ .course-row-actions {
1063
+ display: flex;
1064
+ align-items: center;
1065
+ gap: 8px;
1066
+ flex-wrap: wrap;
1067
+ justify-content: flex-end;
1068
+ }
1069
+
1070
+ .course-list {
1071
+ gap: 12px;
1072
+ }
1073
+
1074
+ .course-card {
1075
+ padding: 16px 18px;
1076
+ }
1077
+
1078
+ .course-card .admin-card-copy {
1079
+ margin: 4px 0 0;
1080
+ }
1081
+
1082
  .admin-schedule-grid {
1083
  grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
1084
  align-items: start;
 
1093
  padding: 22px;
1094
  }
1095
 
1096
+ .schedule-palette {
1097
+ display: flex;
1098
+ gap: 12px;
1099
+ margin-top: 18px;
1100
+ flex-wrap: wrap;
1101
+ }
1102
+
1103
+ .schedule-palette-card {
1104
+ min-width: 200px;
1105
+ padding: 16px 18px;
1106
+ border-radius: 20px;
1107
+ border: 1px solid rgba(255, 255, 255, 0.08);
1108
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.03) 100%);
1109
+ display: grid;
1110
+ gap: 6px;
1111
+ cursor: grab;
1112
+ }
1113
+
1114
+ .schedule-palette-card.class {
1115
+ box-shadow: inset 4px 0 0 rgba(123, 231, 234, 0.78);
1116
+ }
1117
+
1118
+ .schedule-palette-card.break {
1119
+ box-shadow: inset 4px 0 0 rgba(255, 200, 87, 0.72);
1120
+ }
1121
+
1122
  .schedule-editor-shell {
1123
  display: grid;
1124
  grid-template-columns: 92px minmax(0, 1fr);
 
1143
  rgba(3, 11, 20, 0.42);
1144
  border: 1px solid rgba(255, 255, 255, 0.06);
1145
  overflow: hidden;
1146
+ min-height: 980px;
1147
  }
1148
 
1149
  .schedule-editor-tick,
 
1218
  right: 14px;
1219
  }
1220
 
1221
+ .schedule-editor-delete {
1222
+ position: absolute;
1223
+ right: 14px;
1224
+ bottom: 18px;
1225
+ min-height: 28px;
1226
+ padding: 0 10px;
1227
+ border: 0;
1228
+ border-radius: 999px;
1229
+ background: rgba(255, 107, 92, 0.16);
1230
+ color: #ff9388;
1231
+ }
1232
+
1233
  .schedule-editor-resize {
1234
  position: absolute;
1235
  left: 14px;
 
1242
  cursor: ns-resize;
1243
  }
1244
 
1245
+ .schedule-editor-dropzone {
1246
+ position: absolute;
1247
+ left: 14px;
1248
+ right: 14px;
1249
+ bottom: 14px;
1250
+ min-height: 64px;
1251
+ border-radius: 18px;
1252
+ border: 1px dashed rgba(123, 231, 234, 0.35);
1253
+ display: grid;
1254
+ place-items: center;
1255
+ color: var(--muted);
1256
+ background: rgba(123, 231, 234, 0.05);
1257
+ text-align: center;
1258
+ padding: 12px;
1259
+ pointer-events: none;
1260
+ }
1261
+
1262
+ .schedule-editor-dropzone.is-active {
1263
+ border-color: rgba(123, 231, 234, 0.75);
1264
+ background: rgba(123, 231, 234, 0.12);
1265
+ color: var(--muted-strong);
1266
+ }
1267
+
1268
  @media (max-width: 1180px) {
1269
  body {
1270
  overflow: auto;
 
1295
  grid-template-columns: 1fr;
1296
  }
1297
 
1298
+ .admin-lists-grid,
1299
+ .course-management-grid {
1300
+ grid-template-columns: 1fr;
1301
+ }
1302
+
1303
  .schedule-form-card {
1304
  position: static;
1305
  }
1306
 
1307
+ .list-create-card,
1308
+ .course-editor-card {
1309
+ position: static;
1310
+ }
1311
+
1312
  .admin-page-nav {
1313
  flex-wrap: wrap;
1314
  }
 
1339
  .schedule-editor-shell {
1340
  grid-template-columns: 72px minmax(0, 1fr);
1341
  }
1342
+
1343
+ .admin-row-main {
1344
+ grid-template-columns: 1fr;
1345
+ }
1346
+
1347
+ .admin-row-side,
1348
+ .course-row-actions {
1349
+ justify-items: start;
1350
+ justify-content: flex-start;
1351
+ }
1352
  }
1353
 
1354
  @media (max-width: 560px) {
templates/admin.html CHANGED
@@ -10,27 +10,24 @@
10
  <p class="section-kicker">后台管理</p>
11
  {% if admin_page == "schedule" %}
12
  <h1>时间表设置</h1>
13
- <p class="admin-copy">用时间轴方式调整上课课间时段,首页周课表会自动同步新的节次。</p>
14
  {% elif admin_page == "lists" %}
15
  <h1>清单管理</h1>
16
- <p class="admin-copy">集中维护首页的 todolist 分类,分类名称同步与第二页。</p>
17
  {% else %}
18
  <h1>课程管理</h1>
19
- <p class="admin-copy">课程竖直列表统一维护,保存后会自动出现在周课表中。</p>
20
  {% endif %}
21
  </div>
22
 
23
  <div class="action-group">
24
  <a class="ghost-link" href="{{ url_for('index') }}">返回主页</a>
25
- {% if admin_page == "courses" %}
26
- <button class="pill-button accent" id="openCourseModalButton" type="button">新增课程</button>
27
- {% endif %}
28
  </div>
29
 
30
  <nav class="admin-page-nav" aria-label="后台分页">
31
- {% for tab in admin_tabs %}
32
- <a class="admin-page-tab {% if tab.active %}is-active{% endif %}" href="{{ tab.href }}">{{ tab.label }}</a>
33
- {% endfor %}
34
  </nav>
35
  </section>
36
 
@@ -73,25 +70,40 @@
73
  <div class="admin-card-head">
74
  <div>
75
  <p class="column-label">Timeline Editor</p>
76
- <h2>拖动上课与课间调整节次</h2>
77
  </div>
78
  <span class="task-count" id="scheduleSegmentCount">0 段</span>
79
  </div>
80
- <p class="admin-card-copy">拖动每个上课块或课间块底部的手柄调整时长,后续时段会自动顺延。</p>
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  <div class="schedule-editor-shell">
83
  <div class="schedule-editor-axis" id="scheduleEditorAxis"></div>
84
- <div class="schedule-editor-track" id="scheduleEditorTrack"></div>
 
 
85
  </div>
86
  </article>
87
  </div>
88
  </section>
89
  {% elif admin_page == "lists" %}
90
  <section class="admin-section">
91
- <div class="admin-grid admin-grid-tight">
92
- <article class="admin-card create-card">
93
  <div class="create-icon">+</div>
94
  <h2>新建一个清单</h2>
 
95
  <form id="createCategoryForm" class="modal-form compact">
96
  <label>
97
  <span>分类名称</span>
@@ -100,120 +112,124 @@
100
  <button class="primary-button" type="submit">立即创建</button>
101
  </form>
102
  </article>
103
- </div>
104
 
105
- <section class="admin-stack" id="adminGrid">
106
- {% for category in categories %}
107
- <article class="admin-card admin-row-card" data-category-id="{{ category.id }}">
108
- <div class="admin-card-head">
109
- <div>
110
- <p class="column-label">Category</p>
111
- <h2>{{ category.name }}</h2>
112
- </div>
113
- <span class="task-count">{{ category.tasks|length }} 项任务</span>
114
- </div>
115
- <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
116
- <div class="admin-card-actions">
117
- <button class="danger-button" type="button" data-delete-category="{{ category.id }}">删除此清单</button>
118
- </div>
119
- </article>
120
- {% endfor %}
121
- </section>
122
- </section>
123
- {% else %}
124
- <section class="admin-section">
125
- <section class="admin-stack course-list" id="courseGrid">
126
- {% if courses %}
127
- {% for course in courses %}
128
- <article class="admin-card admin-row-card course-card" data-course-id="{{ course.id }}">
129
- <div class="admin-card-head">
130
  <div>
131
- <p class="column-label">Course</p>
132
- <h2>{{ course.title }}</h2>
 
 
 
 
 
133
  </div>
134
- <span class="task-count">周{{ ["一","二","三","四","五","六","日"][course.day_of_week - 1] }}</span>
135
- </div>
136
- <p class="admin-card-copy">{{ course.start_time }} - {{ course.end_time }} · 第 {{ course.start_week }}-{{ course.end_week }} 周 · {% if course.week_pattern == "odd" %}单周{% elif course.week_pattern == "even" %}双周{% else %}每周{% endif %}</p>
137
- <p class="admin-card-copy">{{ course.location if course.location else "未填写地点" }}</p>
138
- <div class="admin-card-actions">
139
- <button class="secondary-button" type="button" data-edit-course="{{ course.id }}">编辑课程</button>
140
- <button class="danger-button" type="button" data-delete-course="{{ course.id }}">删除课程</button>
141
  </div>
142
  </article>
143
  {% endfor %}
144
- {% else %}
145
- <article class="admin-card empty-admin-card">
146
- <h2>还没有固定课程</h2>
147
- <p class="admin-card-copy">点击上方“新增课程”即可录入课程,保存后周课表会自动按周显示。</p>
148
- </article>
149
- {% endif %}
150
- </section>
151
  </section>
152
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- <div class="modal-backdrop" id="courseModal">
155
- <div class="modal-card">
156
- <div class="modal-head">
157
- <p class="modal-kicker">固定课程</p>
158
- <h3 id="courseModalTitle">新增课程</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
- <form class="modal-form" id="courseForm">
161
- <input id="courseIdInput" name="course_id" type="hidden">
162
- <label>
163
- <span>课程名称</span>
164
- <input id="courseTitleInput" name="title" type="text" maxlength="40" placeholder="例如:高等数学">
165
- </label>
166
- <label>
167
- <span>上课地点</span>
168
- <input id="courseLocationInput" name="location" type="text" maxlength="40" placeholder="例如:教学楼 A302">
169
- </label>
170
- <label>
171
- <span>星期几</span>
172
- <select id="courseWeekdayInput" name="day_of_week">
173
- <option value="1">星期一</option>
174
- <option value="2">星期二</option>
175
- <option value="3">星期三</option>
176
- <option value="4">星期四</option>
177
- <option value="5">星期五</option>
178
- <option value="6">星期六</option>
179
- <option value="7">星期日</option>
180
- </select>
181
- </label>
182
- <div class="split-fields">
183
- <label>
184
- <span>开始时间</span>
185
- <input id="courseStartTimeInput" name="start_time" type="time">
186
- </label>
187
- <label>
188
- <span>结束时间</span>
189
- <input id="courseEndTimeInput" name="end_time" type="time">
190
- </label>
191
- </div>
192
- <div class="split-fields">
193
- <label>
194
- <span>开始周数</span>
195
- <input id="courseStartWeekInput" name="start_week" type="number" min="1" max="30" value="1">
196
- </label>
197
- <label>
198
- <span>结束周数</span>
199
- <input id="courseEndWeekInput" name="end_week" type="number" min="1" max="30" value="16">
200
- </label>
201
- </div>
202
- <label>
203
- <span>单双周</span>
204
- <select id="courseWeekPatternInput" name="week_pattern">
205
- <option value="all">每周</option>
206
- <option value="odd">单周</option>
207
- <option value="even">双周</option>
208
- </select>
209
- </label>
210
- <div class="form-actions">
211
- <button class="secondary-button" type="button" data-close-modal="courseModal">取消</button>
212
- <button class="primary-button" type="submit">保存课程</button>
213
- </div>
214
- </form>
215
- </div>
216
- </div>
217
 
218
  <div class="toast-stack" id="toastStack"></div>
219
  </main>
 
10
  <p class="section-kicker">后台管理</p>
11
  {% if admin_page == "schedule" %}
12
  <h1>时间表设置</h1>
13
+ <p class="admin-copy">通过拖拽上课块和课间重新安排整套节次时间。保存后首页周课表会自动同步。</p>
14
  {% elif admin_page == "lists" %}
15
  <h1>清单管理</h1>
16
+ <p class="admin-copy">统一维护首页的 todolist 分类,删除或新建都会即时反映页。</p>
17
  {% else %}
18
  <h1>课程管理</h1>
19
+ <p class="admin-copy">左侧新增或编辑课程,右侧用竖直列表快速检查全周课程配置。</p>
20
  {% endif %}
21
  </div>
22
 
23
  <div class="action-group">
24
  <a class="ghost-link" href="{{ url_for('index') }}">返回主页</a>
 
 
 
25
  </div>
26
 
27
  <nav class="admin-page-nav" aria-label="后台分页">
28
+ <a class="admin-page-tab {% if admin_page == 'schedule' %}is-active{% endif %}" href="{{ url_for('admin_schedule') }}">时间表设置</a>
29
+ <a class="admin-page-tab {% if admin_page == 'lists' %}is-active{% endif %}" href="{{ url_for('admin_lists') }}">清单管理</a>
30
+ <a class="admin-page-tab {% if admin_page == 'courses' %}is-active{% endif %}" href="{{ url_for('admin_courses') }}">课程管理</a>
31
  </nav>
32
  </section>
33
 
 
70
  <div class="admin-card-head">
71
  <div>
72
  <p class="column-label">Timeline Editor</p>
73
+ <h2>拖到时间轴上重新排布节次</h2>
74
  </div>
75
  <span class="task-count" id="scheduleSegmentCount">0 段</span>
76
  </div>
77
+
78
+ <div class="schedule-palette" id="schedulePalette">
79
+ <div class="schedule-palette-card class" draggable="true" data-palette-kind="class">
80
+ <strong>上课块</strong>
81
+ <span>拖入时间轴追加一节课</span>
82
+ </div>
83
+ <div class="schedule-palette-card break" draggable="true" data-palette-kind="break">
84
+ <strong>课间块</strong>
85
+ <span>拖入时间轴追加一段休息</span>
86
+ </div>
87
+ </div>
88
+
89
+ <p class="admin-card-copy">规则:时间段不能重叠,上课后必须是课间,课间后必须是上课。可调整时长、删除节课,并拖入新块扩展节次。</p>
90
 
91
  <div class="schedule-editor-shell">
92
  <div class="schedule-editor-axis" id="scheduleEditorAxis"></div>
93
+ <div class="schedule-editor-track" id="scheduleEditorTrack">
94
+ <div class="schedule-editor-dropzone" id="scheduleEditorDropzone">将上课块或课间块拖到这里追加到时间表末尾</div>
95
+ </div>
96
  </div>
97
  </article>
98
  </div>
99
  </section>
100
  {% elif admin_page == "lists" %}
101
  <section class="admin-section">
102
+ <div class="admin-grid admin-grid-tight admin-lists-grid">
103
+ <article class="admin-card create-card list-create-card">
104
  <div class="create-icon">+</div>
105
  <h2>新建一个清单</h2>
106
+ <p class="admin-card-copy">清单会直接同步到首页和第二页拖拽池。</p>
107
  <form id="createCategoryForm" class="modal-form compact">
108
  <label>
109
  <span>分类名称</span>
 
112
  <button class="primary-button" type="submit">立即创建</button>
113
  </form>
114
  </article>
 
115
 
116
+ <section class="admin-stack list-management-stack" id="adminGrid">
117
+ {% for category in categories %}
118
+ <article class="admin-card admin-row-card list-row-card" data-category-id="{{ category.id }}">
119
+ <div class="admin-row-main">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  <div>
121
+ <p class="column-label">Category</p>
122
+ <h2>{{ category.name }}</h2>
123
+ <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
124
+ </div>
125
+ <div class="admin-row-side">
126
+ <span class="task-count">{{ category.tasks|length }} 项任务</span>
127
+ <button class="danger-button" type="button" data-delete-category="{{ category.id }}">删除此清单</button>
128
  </div>
 
 
 
 
 
 
 
129
  </div>
130
  </article>
131
  {% endfor %}
132
+ </section>
133
+ </div>
 
 
 
 
 
134
  </section>
135
+ {% else %}
136
+ <section class="admin-section">
137
+ <div class="admin-grid admin-grid-tight course-management-grid">
138
+ <article class="admin-card course-editor-card">
139
+ <div class="admin-card-head">
140
+ <div>
141
+ <p class="column-label">Course Editor</p>
142
+ <h2 id="courseEditorHeading">新增课程</h2>
143
+ </div>
144
+ </div>
145
+ <p class="admin-card-copy">课程时间按“第几节到第几节”输入,系统会根据当前时间表自动换算为具体时间。</p>
146
+
147
+ <form class="modal-form compact" id="courseForm">
148
+ <input id="courseIdInput" name="course_id" type="hidden">
149
+ <label>
150
+ <span>课程名称</span>
151
+ <input id="courseTitleInput" name="title" type="text" maxlength="40" placeholder="例如:高等数学">
152
+ </label>
153
+ <label>
154
+ <span>上课地点</span>
155
+ <input id="courseLocationInput" name="location" type="text" maxlength="40" placeholder="例如:教学楼 A302">
156
+ </label>
157
+ <label>
158
+ <span>星期几</span>
159
+ <select id="courseWeekdayInput" name="day_of_week">
160
+ <option value="1">星期一</option>
161
+ <option value="2">星期二</option>
162
+ <option value="3">星期三</option>
163
+ <option value="4">星期四</option>
164
+ <option value="5">星期五</option>
165
+ <option value="6">星期六</option>
166
+ <option value="7">星期日</option>
167
+ </select>
168
+ </label>
169
+ <div class="split-fields">
170
+ <label>
171
+ <span>开始节次</span>
172
+ <select id="courseStartPeriodInput" name="start_period"></select>
173
+ </label>
174
+ <label>
175
+ <span>结束节次</span>
176
+ <select id="courseEndPeriodInput" name="end_period"></select>
177
+ </label>
178
+ </div>
179
+ <div class="split-fields">
180
+ <label>
181
+ <span>开始周数</span>
182
+ <input id="courseStartWeekInput" name="start_week" type="number" min="1" max="30" value="1">
183
+ </label>
184
+ <label>
185
+ <span>结束周数</span>
186
+ <input id="courseEndWeekInput" name="end_week" type="number" min="1" max="30" value="16">
187
+ </label>
188
+ </div>
189
+ <label>
190
+ <span>单双周</span>
191
+ <select id="courseWeekPatternInput" name="week_pattern">
192
+ <option value="all">每周</option>
193
+ <option value="odd">单周</option>
194
+ <option value="even">双周</option>
195
+ </select>
196
+ </label>
197
+ <div class="form-actions">
198
+ <button class="secondary-button" id="resetCourseEditorButton" type="button">清空表单</button>
199
+ <button class="primary-button" type="submit">保存课程</button>
200
+ </div>
201
+ </form>
202
+ </article>
203
 
204
+ <section class="admin-stack course-list" id="courseGrid">
205
+ {% if courses %}
206
+ {% for course in courses %}
207
+ <article class="admin-card admin-row-card course-card" data-course-id="{{ course.id }}">
208
+ <div class="admin-card-head course-row-head">
209
+ <div>
210
+ <p class="column-label">Course</p>
211
+ <h2>{{ course.title }}</h2>
212
+ </div>
213
+ <div class="course-row-actions">
214
+ <span class="task-count">周{{ ["一","二","三","四","五","六","日"][course.day_of_week - 1] }}</span>
215
+ <button class="secondary-button" type="button" data-edit-course="{{ course.id }}">编辑课程</button>
216
+ <button class="danger-button" type="button" data-delete-course="{{ course.id }}">删除课程</button>
217
+ </div>
218
+ </div>
219
+ <p class="admin-card-copy">{{ course.start_time }} - {{ course.end_time }} · 第 {{ course.start_week }}-{{ course.end_week }} 周 · {% if course.week_pattern == "odd" %}单周{% elif course.week_pattern == "even" %}双周{% else %}每周{% endif %}</p>
220
+ <p class="admin-card-copy">{{ course.location if course.location else "未填写地点" }}</p>
221
+ </article>
222
+ {% endfor %}
223
+ {% else %}
224
+ <article class="admin-card empty-admin-card">
225
+ <h2>还没有固定课程</h2>
226
+ <p class="admin-card-copy">在左侧填写课程信息并保存后,周课表会自动按周显示。</p>
227
+ </article>
228
+ {% endif %}
229
+ </section>
230
  </div>
231
+ </section>
232
+ {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
  <div class="toast-stack" id="toastStack"></div>
235
  </main>