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

Refactor admin pages and home cards

Browse files
Files changed (5) hide show
  1. app.py +130 -13
  2. static/admin-v020.js +414 -108
  3. static/v020.css +222 -6
  4. storage.py +35 -1
  5. templates/admin.html +183 -159
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
11
 
12
  BASE_DIR = Path(__file__).resolve().parent
13
  STORE_PATH = BASE_DIR / "data" / "store.json"
@@ -70,6 +70,13 @@ WEEKDAYS = [
70
  "星期日",
71
  ]
72
  WEEKDAY_SHORT = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
 
 
 
 
 
 
 
73
 
74
 
75
  def get_password() -> str:
@@ -150,6 +157,68 @@ def normalize_time_string(raw_value: str) -> str:
150
  return minutes_to_hhmm(parse_time_to_minutes(raw_value))
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  def compute_task_progress(task: dict) -> float:
154
  if task.get("completed"):
155
  return 100.0
@@ -231,11 +300,14 @@ def parse_schedule_settings(payload: dict) -> dict:
231
  if default_duration < 30 or default_duration > 240:
232
  raise ValueError(MSG_INVALID_DURATION)
233
 
 
 
234
  return {
235
  "semester_start": semester_start,
236
  "day_start": day_start,
237
  "day_end": day_end,
238
  "default_task_duration_minutes": default_duration,
 
239
  }
240
 
241
 
@@ -330,6 +402,8 @@ def find_schedule_conflict(task_id: str, schedule: dict | None) -> str | None:
330
 
331
  def build_planner_payload(selected_date: date) -> dict:
332
  settings = store.get_schedule_settings()
 
 
333
  semester_start = date.fromisoformat(settings["semester_start"])
334
  delta_days = (selected_date - semester_start).days
335
  academic_week = (delta_days // 7) + 1 if delta_days >= 0 else 0
@@ -388,8 +462,8 @@ def build_planner_payload(selected_date: date) -> dict:
388
  "academic_week": academic_week,
389
  "academic_label": f"第 {academic_week} 周" if academic_week > 0 else "开学前",
390
  "settings": settings,
391
- "time_slots": CLASS_PERIODS,
392
- "major_blocks": MAJOR_BLOCKS,
393
  "tasks": task_list,
394
  "scheduled_items": scheduled_items,
395
  }
@@ -397,6 +471,8 @@ def build_planner_payload(selected_date: date) -> dict:
397
 
398
  def build_week_planner_payload(selected_date: date) -> dict:
399
  settings = store.get_schedule_settings()
 
 
400
  semester_start = date.fromisoformat(settings["semester_start"])
401
  week_start = get_week_start(selected_date)
402
  week_end = week_start + timedelta(days=6)
@@ -485,8 +561,8 @@ def build_week_planner_payload(selected_date: date) -> dict:
485
  "academic_week": academic_week,
486
  "academic_label": f"第 {academic_week} 周" if academic_week > 0 else "开学前",
487
  "settings": settings,
488
- "time_slots": CLASS_PERIODS,
489
- "major_blocks": MAJOR_BLOCKS,
490
  "tasks": task_list,
491
  "scheduled_items": scheduled_items,
492
  }
@@ -499,6 +575,39 @@ def inject_globals():
499
  }
500
 
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  @app.get("/")
503
  def index():
504
  login_required = request.args.get("login") == "required"
@@ -516,14 +625,22 @@ def index():
516
 
517
  @app.get("/admin")
518
  def admin():
519
- if not is_authed():
520
- return redirect(url_for("index", login="required", next=url_for("admin")))
521
- return render_template(
522
- "admin.html",
523
- categories=store.list_categories(),
524
- courses=store.list_courses(),
525
- schedule_settings=store.get_schedule_settings(),
526
- )
 
 
 
 
 
 
 
 
527
 
528
 
529
  @app.get("/api/planner")
 
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"
 
70
  "星期日",
71
  ]
72
  WEEKDAY_SHORT = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
73
+ MAJOR_BLOCK_GROUPS = [
74
+ ("第一大节", 0, 2),
75
+ ("第二大节", 2, 4),
76
+ ("第三大节", 4, 7),
77
+ ("第四大节", 7, 9),
78
+ ("第五大节", 9, 12),
79
+ ]
80
 
81
 
82
  def get_password() -> str:
 
157
  return minutes_to_hhmm(parse_time_to_minutes(raw_value))
158
 
159
 
160
+ def normalize_time_slots(raw_slots: list[dict]) -> list[dict[str, str]]:
161
+ if not isinstance(raw_slots, list) or not raw_slots:
162
+ raise ValueError(MSG_INVALID_TIME_RANGE)
163
+
164
+ normalized: list[dict[str, str]] = []
165
+ previous_end = None
166
+
167
+ for index, slot in enumerate(raw_slots, start=1):
168
+ label = str(slot.get("label", "")).strip() or f"第{index:02d}节课"
169
+ start = normalize_time_string(str(slot.get("start", "")).strip())
170
+ end = normalize_time_string(str(slot.get("end", "")).strip())
171
+ start_minutes = parse_time_to_minutes(start)
172
+ end_minutes = parse_time_to_minutes(end)
173
+
174
+ if start_minutes >= end_minutes:
175
+ raise ValueError(MSG_INVALID_TIME_RANGE)
176
+ if previous_end is not None and start_minutes < previous_end:
177
+ raise ValueError(MSG_INVALID_TIME_RANGE)
178
+
179
+ normalized.append(
180
+ {
181
+ "label": label,
182
+ "start": start,
183
+ "end": end,
184
+ }
185
+ )
186
+ previous_end = end_minutes
187
+
188
+ return normalized
189
+
190
+
191
+ def get_configured_time_slots(settings: dict | None = None) -> list[dict[str, str]]:
192
+ source = settings or store.get_schedule_settings()
193
+ raw_slots = source.get("time_slots") or [
194
+ {
195
+ "label": slot["label"],
196
+ "start": slot["start"],
197
+ "end": slot["end"],
198
+ }
199
+ for slot in CLASS_PERIODS
200
+ ]
201
+ return normalize_time_slots(raw_slots)
202
+
203
+
204
+ def build_major_blocks_from_time_slots(time_slots: list[dict[str, str]]) -> list[dict[str, str]]:
205
+ major_blocks: list[dict[str, str]] = []
206
+ for label, start_index, end_index in MAJOR_BLOCK_GROUPS:
207
+ if len(time_slots) <= start_index:
208
+ continue
209
+ block_slots = time_slots[start_index:min(end_index, len(time_slots))]
210
+ if not block_slots:
211
+ continue
212
+ major_blocks.append(
213
+ {
214
+ "label": label,
215
+ "start": block_slots[0]["start"],
216
+ "end": block_slots[-1]["end"],
217
+ }
218
+ )
219
+ return major_blocks
220
+
221
+
222
  def compute_task_progress(task: dict) -> float:
223
  if task.get("completed"):
224
  return 100.0
 
300
  if default_duration < 30 or default_duration > 240:
301
  raise ValueError(MSG_INVALID_DURATION)
302
 
303
+ time_slots = normalize_time_slots(payload.get("time_slots") or get_configured_time_slots())
304
+
305
  return {
306
  "semester_start": semester_start,
307
  "day_start": day_start,
308
  "day_end": day_end,
309
  "default_task_duration_minutes": default_duration,
310
+ "time_slots": time_slots,
311
  }
312
 
313
 
 
402
 
403
  def build_planner_payload(selected_date: date) -> dict:
404
  settings = store.get_schedule_settings()
405
+ time_slots = get_configured_time_slots(settings)
406
+ major_blocks = build_major_blocks_from_time_slots(time_slots)
407
  semester_start = date.fromisoformat(settings["semester_start"])
408
  delta_days = (selected_date - semester_start).days
409
  academic_week = (delta_days // 7) + 1 if delta_days >= 0 else 0
 
462
  "academic_week": academic_week,
463
  "academic_label": f"第 {academic_week} 周" if academic_week > 0 else "开学前",
464
  "settings": settings,
465
+ "time_slots": time_slots,
466
+ "major_blocks": major_blocks,
467
  "tasks": task_list,
468
  "scheduled_items": scheduled_items,
469
  }
 
471
 
472
  def build_week_planner_payload(selected_date: date) -> dict:
473
  settings = store.get_schedule_settings()
474
+ time_slots = get_configured_time_slots(settings)
475
+ major_blocks = build_major_blocks_from_time_slots(time_slots)
476
  semester_start = date.fromisoformat(settings["semester_start"])
477
  week_start = get_week_start(selected_date)
478
  week_end = week_start + timedelta(days=6)
 
561
  "academic_week": academic_week,
562
  "academic_label": f"第 {academic_week} 周" if academic_week > 0 else "开学前",
563
  "settings": settings,
564
+ "time_slots": time_slots,
565
+ "major_blocks": major_blocks,
566
  "tasks": task_list,
567
  "scheduled_items": scheduled_items,
568
  }
 
575
  }
576
 
577
 
578
+ def build_admin_tabs(active_page: str) -> list[dict[str, str | bool]]:
579
+ tabs = [
580
+ ("schedule", "时间表设置", "admin_schedule"),
581
+ ("lists", "清单管理", "admin_lists"),
582
+ ("courses", "课程管理", "admin_courses"),
583
+ ]
584
+ return [
585
+ {
586
+ "key": key,
587
+ "label": label,
588
+ "href": url_for(endpoint),
589
+ "active": key == active_page,
590
+ }
591
+ for key, label, endpoint in tabs
592
+ ]
593
+
594
+
595
+ def render_admin_page(active_page: str):
596
+ if not is_authed():
597
+ return redirect(url_for("index", login="required", next=request.path))
598
+
599
+ schedule_settings = store.get_schedule_settings()
600
+ return render_template(
601
+ "admin.html",
602
+ admin_page=active_page,
603
+ admin_tabs=build_admin_tabs(active_page),
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
+
611
  @app.get("/")
612
  def index():
613
  login_required = request.args.get("login") == "required"
 
625
 
626
  @app.get("/admin")
627
  def admin():
628
+ return redirect(url_for("admin_schedule"))
629
+
630
+
631
+ @app.get("/admin/schedule")
632
+ def admin_schedule():
633
+ return render_admin_page("schedule")
634
+
635
+
636
+ @app.get("/admin/lists")
637
+ def admin_lists():
638
+ return render_admin_page("lists")
639
+
640
+
641
+ @app.get("/admin/courses")
642
+ def admin_courses():
643
+ return render_admin_page("courses")
644
 
645
 
646
  @app.get("/api/planner")
static/admin-v020.js CHANGED
@@ -1,26 +1,38 @@
1
  (function () {
2
  const bootstrap = window.__ADMIN_BOOTSTRAP__ || {};
3
  const state = {
 
4
  courses: bootstrap.courses || [],
 
 
 
 
 
 
 
 
 
 
5
  };
6
 
7
  const toastStack = document.getElementById("toastStack");
8
  const courseGrid = document.getElementById("courseGrid");
9
  const courseModal = document.getElementById("courseModal");
10
  const courseForm = document.getElementById("courseForm");
11
- const scheduleSettingsForm = document.getElementById("scheduleSettingsForm");
12
  const openCourseModalButton = document.getElementById("openCourseModalButton");
13
  const createCategoryForm = document.getElementById("createCategoryForm");
14
  const adminGrid = document.getElementById("adminGrid");
 
 
 
 
 
15
 
16
- if (!courseGrid || !courseForm || !scheduleSettingsForm || !createCategoryForm || !adminGrid) {
17
  return;
18
  }
19
 
20
  function showToast(message, kind = "success") {
21
- if (!toastStack) {
22
- return;
23
- }
24
  const toast = document.createElement("div");
25
  toast.className = `toast ${kind}`;
26
  toast.textContent = message;
@@ -54,6 +66,25 @@
54
  }
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  function formatWeekPattern(pattern) {
58
  if (pattern === "odd") {
59
  return "单周";
@@ -70,7 +101,7 @@
70
 
71
  function courseCard(course) {
72
  return `
73
- <article class="admin-card course-card" data-course-id="${course.id}">
74
  <div class="admin-card-head">
75
  <div>
76
  <p class="column-label">Course</p>
@@ -89,27 +120,33 @@
89
  }
90
 
91
  function categoryCard(category) {
 
92
  return `
93
- <article class="admin-card" data-category-id="${category.id}">
94
  <div class="admin-card-head">
95
  <div>
96
  <p class="column-label">Category</p>
97
  <h2>${category.name}</h2>
98
  </div>
99
- <span class="task-count">0 项任务</span>
100
  </div>
101
  <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
102
- <button class="danger-button" type="button" data-delete-category="${category.id}">删除此清单</button>
 
 
103
  </article>
104
  `;
105
  }
106
 
107
  function renderCourses() {
 
 
 
108
  if (!state.courses.length) {
109
  courseGrid.innerHTML = `
110
  <article class="admin-card empty-admin-card">
111
  <h2>还没有固定课程</h2>
112
- <p class="admin-card-copy">点击“新增课程”即可录入课首页第二页会自动按学期开始日期和次生成显示。</p>
113
  </article>
114
  `;
115
  return;
@@ -117,6 +154,13 @@
117
  courseGrid.innerHTML = state.courses.map(courseCard).join("");
118
  }
119
 
 
 
 
 
 
 
 
120
  function resetCourseForm(course) {
121
  document.getElementById("courseIdInput").value = course ? course.id : "";
122
  document.getElementById("courseTitleInput").value = course ? course.title : "";
@@ -130,127 +174,387 @@
130
  document.getElementById("courseModalTitle").textContent = course ? "编辑课程" : "新增课程";
131
  }
132
 
133
- openCourseModalButton.addEventListener("click", () => {
134
- resetCourseForm(null);
135
- openModal(courseModal);
136
- });
137
 
138
- courseForm.addEventListener("submit", async (event) => {
139
- event.preventDefault();
140
- const courseId = document.getElementById("courseIdInput").value;
141
- const payload = {
142
- title: document.getElementById("courseTitleInput").value.trim(),
143
- location: document.getElementById("courseLocationInput").value.trim(),
144
- day_of_week: Number(document.getElementById("courseWeekdayInput").value),
145
- start_time: document.getElementById("courseStartTimeInput").value,
146
- end_time: document.getElementById("courseEndTimeInput").value,
147
- start_week: Number(document.getElementById("courseStartWeekInput").value),
148
- end_week: Number(document.getElementById("courseEndWeekInput").value),
149
- week_pattern: document.getElementById("courseWeekPatternInput").value,
150
- };
151
 
152
- try {
153
- const payloadData = await requestJSON(courseId ? `/api/courses/${courseId}` : "/api/courses", {
154
- method: courseId ? "PATCH" : "POST",
155
- body: JSON.stringify(payload),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  });
157
- if (courseId) {
158
- state.courses = state.courses.map((course) => (course.id === courseId ? payloadData.course : course));
159
- showToast("课程已更新");
160
- } else {
161
- state.courses.unshift(payloadData.course);
162
- showToast("课程已创建");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  }
164
- renderCourses();
165
- closeModal(courseModal);
166
- } catch (error) {
167
- showToast(error.message, "error");
168
  }
169
- });
170
 
171
- courseGrid.addEventListener("click", async (event) => {
172
- const editButton = event.target.closest("[data-edit-course]");
173
- if (editButton) {
174
- const course = state.courses.find((item) => item.id === editButton.dataset.editCourse);
175
- if (course) {
176
- resetCourseForm(course);
177
- openModal(courseModal);
 
 
 
 
 
 
 
 
 
 
 
178
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  return;
180
  }
 
 
 
 
 
 
 
 
181
 
182
- const deleteButton = event.target.closest("[data-delete-course]");
183
- if (!deleteButton) {
 
184
  return;
185
  }
186
- try {
187
- await requestJSON(`/api/courses/${deleteButton.dataset.deleteCourse}`, {
188
- method: "DELETE",
189
- body: JSON.stringify({}),
190
- });
191
- state.courses = state.courses.filter((course) => course.id !== deleteButton.dataset.deleteCourse);
192
- renderCourses();
193
- showToast("课程已删除");
194
- } catch (error) {
195
- showToast(error.message, "error");
 
196
  }
197
- });
198
 
199
- createCategoryForm.addEventListener("submit", async (event) => {
200
- event.preventDefault();
201
- const nameInput = document.getElementById("newCategoryName");
202
- const name = nameInput.value.trim();
203
- try {
204
- const payload = await requestJSON("/api/categories", {
205
- method: "POST",
206
- body: JSON.stringify({ name }),
207
- });
208
- adminGrid.insertAdjacentHTML("beforeend", categoryCard(payload.category));
209
- nameInput.value = "";
210
- showToast("新分类已创建");
211
- } catch (error) {
212
- showToast(error.message, "error");
213
  }
214
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
- adminGrid.addEventListener("click", async (event) => {
217
- const button = event.target.closest("[data-delete-category]");
218
- if (!button) {
 
 
 
 
 
219
  return;
220
  }
221
- try {
222
- await requestJSON(`/api/categories/${button.dataset.deleteCategory}`, {
223
- method: "DELETE",
224
- body: JSON.stringify({}),
 
225
  });
226
- const card = button.closest(".admin-card");
227
- if (card) {
228
- card.remove();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  }
230
- showToast("分类已删除");
231
- } catch (error) {
232
- showToast(error.message, "error");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  }
234
- });
235
 
236
- scheduleSettingsForm.addEventListener("submit", async (event) => {
237
- event.preventDefault();
238
- const payload = {
239
- semester_start: document.getElementById("semesterStartInput").value,
240
- day_start: document.getElementById("dayStartInput").value,
241
- day_end: document.getElementById("dayEndInput").value,
242
- default_task_duration_minutes: Number(document.getElementById("defaultDurationInput").value),
243
- };
244
- try {
245
- await requestJSON("/api/settings/schedule", {
246
- method: "PATCH",
247
- body: JSON.stringify(payload),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  });
249
- showToast("时间表设置已保存");
250
- } catch (error) {
251
- showToast(error.message, "error");
252
  }
253
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
  document.addEventListener("click", (event) => {
256
  const closeTarget = event.target.closest("[data-close-modal]");
@@ -262,5 +566,7 @@
262
  }
263
  });
264
 
265
- renderCourses();
 
 
266
  })();
 
1
  (function () {
2
  const bootstrap = window.__ADMIN_BOOTSTRAP__ || {};
3
  const state = {
4
+ adminPage: bootstrap.adminPage || "schedule",
5
  courses: bootstrap.courses || [],
6
+ categories: bootstrap.categories || [],
7
+ scheduleSettings: bootstrap.scheduleSettings || {},
8
+ defaultTimeSlots: bootstrap.defaultTimeSlots || [],
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;
33
  }
34
 
35
  function showToast(message, kind = "success") {
 
 
 
36
  const toast = document.createElement("div");
37
  toast.className = `toast ${kind}`;
38
  toast.textContent = message;
 
66
  }
67
  }
68
 
69
+ function toMinutes(value) {
70
+ const [hour, minute] = String(value).split(":").map(Number);
71
+ return (hour * 60) + minute;
72
+ }
73
+
74
+ function minutesToTime(totalMinutes) {
75
+ const hour = Math.floor(totalMinutes / 60);
76
+ const minute = totalMinutes % 60;
77
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
78
+ }
79
+
80
+ function snapMinutes(value, step = 5) {
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 "单周";
 
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>
 
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;
 
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 : "";
 
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
+ }
235
+ return Array.from(marks).sort((left, right) => left - right);
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) {
309
  return;
310
  }
311
+ event.preventDefault();
312
+ state.scheduleEditor.interaction = {
313
+ index,
314
+ pointerId: event.pointerId,
315
+ startY: event.clientY,
316
+ snapshot: cloneSegments(state.scheduleEditor.segments),
317
+ };
318
+ }
319
 
320
+ function updateScheduleResize(clientY) {
321
+ const interaction = state.scheduleEditor.interaction;
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
+ })),
372
+ };
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() {
459
+ if (!createCategoryForm || !adminGrid) {
460
+ return;
461
  }
 
462
 
463
+ createCategoryForm.addEventListener("submit", async (event) => {
464
+ event.preventDefault();
465
+ const nameInput = document.getElementById("newCategoryName");
466
+ const name = nameInput.value.trim();
467
+ try {
468
+ const payload = await requestJSON("/api/categories", {
469
+ method: "POST",
470
+ body: JSON.stringify({ name }),
471
+ });
472
+ state.categories = [...state.categories, payload.category];
473
+ renderCategories();
474
+ nameInput.value = "";
475
+ showToast("新分类已创建");
476
+ } catch (error) {
477
+ showToast(error.message, "error");
478
+ }
479
+ });
480
+
481
+ adminGrid.addEventListener("click", async (event) => {
482
+ const button = event.target.closest("[data-delete-category]");
483
+ if (!button) {
484
+ return;
485
+ }
486
+ try {
487
+ await requestJSON(`/api/categories/${button.dataset.deleteCategory}`, {
488
+ method: "DELETE",
489
+ body: JSON.stringify({}),
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]");
 
566
  }
567
  });
568
 
569
+ initCoursesPage();
570
+ initListsPage();
571
+ initSchedulePage();
572
  })();
static/v020.css CHANGED
@@ -122,13 +122,20 @@ body.planner-interacting .page-planner {
122
  .page-home .hero-card {
123
  min-height: 0;
124
  padding: 22px 24px;
125
- display: grid;
126
- place-items: center;
 
127
  }
128
 
129
  .clock-wrap {
 
130
  gap: 6px;
131
  padding: 6px 0 4px;
 
 
 
 
 
132
  }
133
 
134
  .clock-display {
@@ -154,6 +161,14 @@ body.planner-interacting .page-planner {
154
  overflow: hidden;
155
  }
156
 
 
 
 
 
 
 
 
 
157
  .board-section .section-header {
158
  margin-bottom: 0;
159
  }
@@ -179,24 +194,45 @@ body.planner-interacting .page-planner {
179
  min-height: 0;
180
  height: 100%;
181
  max-height: 100%;
182
- overflow: hidden;
 
183
  display: grid;
184
  grid-template-rows: auto minmax(0, 1fr);
185
  }
186
 
187
- .column-header {
188
- gap: 12px;
 
 
 
 
189
  }
190
 
191
  .column-title {
192
  font-size: 1.2rem;
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  .task-list {
196
  display: flex;
197
  flex-direction: column;
198
  gap: 10px;
199
- margin-top: 12px;
200
  min-height: 0;
201
  height: 100%;
202
  max-height: 100%;
@@ -918,6 +954,170 @@ body.planner-interacting .page-planner {
918
  flex: 1;
919
  }
920
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
  @media (max-width: 1180px) {
922
  body {
923
  overflow: auto;
@@ -944,6 +1144,18 @@ body.planner-interacting .page-planner {
944
  justify-content: center;
945
  }
946
 
 
 
 
 
 
 
 
 
 
 
 
 
947
  .page-viewport,
948
  .page-track,
949
  .page-slide,
@@ -966,6 +1178,10 @@ body.planner-interacting .page-planner {
966
  .timeline-scroll {
967
  height: auto;
968
  }
 
 
 
 
969
  }
970
 
971
  @media (max-width: 560px) {
 
122
  .page-home .hero-card {
123
  min-height: 0;
124
  padding: 22px 24px;
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
  }
129
 
130
  .clock-wrap {
131
+ width: 100%;
132
  gap: 6px;
133
  padding: 6px 0 4px;
134
+ display: grid;
135
+ justify-items: center;
136
+ align-content: center;
137
+ text-align: center;
138
+ margin: 0 auto;
139
  }
140
 
141
  .clock-display {
 
161
  overflow: hidden;
162
  }
163
 
164
+ .page-home .board-section {
165
+ grid-template-rows: minmax(0, 1fr);
166
+ }
167
+
168
+ .page-home .board-section > .section-header {
169
+ display: none;
170
+ }
171
+
172
  .board-section .section-header {
173
  margin-bottom: 0;
174
  }
 
194
  min-height: 0;
195
  height: 100%;
196
  max-height: 100%;
197
+ position: relative;
198
+ overflow: visible;
199
  display: grid;
200
  grid-template-rows: auto minmax(0, 1fr);
201
  }
202
 
203
+ .page-home .column-header {
204
+ position: relative;
205
+ display: block;
206
+ min-height: 58px;
207
+ padding-right: 112px;
208
+ z-index: 12;
209
  }
210
 
211
  .column-title {
212
  font-size: 1.2rem;
213
  }
214
 
215
+ .page-home .column-actions {
216
+ position: absolute;
217
+ top: 0;
218
+ right: 0;
219
+ z-index: 24;
220
+ }
221
+
222
+ .page-home .menu-wrap {
223
+ position: relative;
224
+ z-index: 28;
225
+ }
226
+
227
+ .page-home .menu-panel {
228
+ z-index: 60;
229
+ }
230
+
231
  .task-list {
232
  display: flex;
233
  flex-direction: column;
234
  gap: 10px;
235
+ margin-top: 0;
236
  min-height: 0;
237
  height: 100%;
238
  max-height: 100%;
 
954
  flex: 1;
955
  }
956
 
957
+ .admin-layout-v2 {
958
+ width: min(1480px, calc(100% - 28px));
959
+ }
960
+
961
+ .admin-page-nav {
962
+ display: inline-flex;
963
+ gap: 8px;
964
+ padding: 6px;
965
+ border-radius: 999px;
966
+ background: rgba(255, 255, 255, 0.05);
967
+ }
968
+
969
+ .admin-page-tab {
970
+ min-height: 42px;
971
+ padding: 0 16px;
972
+ border-radius: 999px;
973
+ display: inline-flex;
974
+ align-items: center;
975
+ justify-content: center;
976
+ color: var(--muted-strong);
977
+ }
978
+
979
+ .admin-page-tab.is-active {
980
+ background: linear-gradient(135deg, rgba(123, 231, 234, 0.2) 0%, rgba(97, 210, 159, 0.18) 100%);
981
+ color: var(--text);
982
+ }
983
+
984
+ .admin-stack {
985
+ display: grid;
986
+ gap: 16px;
987
+ }
988
+
989
+ .admin-row-card {
990
+ padding: 20px 22px;
991
+ }
992
+
993
+ .course-list .course-card {
994
+ border-radius: 22px;
995
+ }
996
+
997
+ .admin-schedule-grid {
998
+ grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
999
+ align-items: start;
1000
+ }
1001
+
1002
+ .schedule-form-card {
1003
+ position: sticky;
1004
+ top: 28px;
1005
+ }
1006
+
1007
+ .schedule-editor-card {
1008
+ padding: 22px;
1009
+ }
1010
+
1011
+ .schedule-editor-shell {
1012
+ display: grid;
1013
+ grid-template-columns: 92px minmax(0, 1fr);
1014
+ gap: 14px;
1015
+ margin-top: 18px;
1016
+ }
1017
+
1018
+ .schedule-editor-axis,
1019
+ .schedule-editor-track {
1020
+ position: relative;
1021
+ min-height: 760px;
1022
+ }
1023
+
1024
+ .schedule-editor-axis {
1025
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
1026
+ }
1027
+
1028
+ .schedule-editor-track {
1029
+ border-radius: 24px;
1030
+ background:
1031
+ linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.01) 100%),
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,
1038
+ .schedule-editor-line {
1039
+ position: absolute;
1040
+ }
1041
+
1042
+ .schedule-editor-tick {
1043
+ left: 0;
1044
+ right: 0;
1045
+ transform: translateY(-50%);
1046
+ padding-right: 12px;
1047
+ text-align: right;
1048
+ color: rgba(238, 244, 251, 0.78);
1049
+ font-size: 0.8rem;
1050
+ font-variant-numeric: tabular-nums;
1051
+ }
1052
+
1053
+ .schedule-editor-tick.is-leading {
1054
+ transform: translateY(0);
1055
+ }
1056
+
1057
+ .schedule-editor-tick.is-terminal {
1058
+ transform: translateY(-100%);
1059
+ }
1060
+
1061
+ .schedule-editor-line {
1062
+ left: 0;
1063
+ right: 0;
1064
+ height: 1px;
1065
+ background: rgba(255, 255, 255, 0.07);
1066
+ }
1067
+
1068
+ .schedule-editor-segment {
1069
+ position: absolute;
1070
+ left: 14px;
1071
+ right: 14px;
1072
+ border-radius: 18px;
1073
+ border: 1px solid rgba(255, 255, 255, 0.08);
1074
+ box-shadow: 0 16px 30px rgba(0, 0, 0, 0.2);
1075
+ overflow: hidden;
1076
+ }
1077
+
1078
+ .schedule-editor-segment.class {
1079
+ background: linear-gradient(180deg, rgba(123, 231, 234, 0.16) 0%, rgba(255, 255, 255, 0.05) 100%);
1080
+ }
1081
+
1082
+ .schedule-editor-segment.break {
1083
+ background: linear-gradient(180deg, rgba(255, 200, 87, 0.14) 0%, rgba(255, 255, 255, 0.04) 100%);
1084
+ }
1085
+
1086
+ .schedule-editor-segment-copy {
1087
+ padding: 12px 14px 18px;
1088
+ display: grid;
1089
+ gap: 6px;
1090
+ }
1091
+
1092
+ .schedule-editor-segment-copy strong {
1093
+ font-size: 0.94rem;
1094
+ line-height: 1.15;
1095
+ }
1096
+
1097
+ .schedule-editor-segment-copy span,
1098
+ .schedule-editor-segment-kind {
1099
+ color: var(--muted);
1100
+ font-size: 0.8rem;
1101
+ }
1102
+
1103
+ .schedule-editor-segment-kind {
1104
+ position: absolute;
1105
+ top: 12px;
1106
+ right: 14px;
1107
+ }
1108
+
1109
+ .schedule-editor-resize {
1110
+ position: absolute;
1111
+ left: 14px;
1112
+ right: 14px;
1113
+ bottom: 6px;
1114
+ height: 8px;
1115
+ border: 0;
1116
+ border-radius: 999px;
1117
+ background: linear-gradient(90deg, rgba(255, 255, 255, 0.85), rgba(123, 231, 234, 0.9));
1118
+ cursor: ns-resize;
1119
+ }
1120
+
1121
  @media (max-width: 1180px) {
1122
  body {
1123
  overflow: auto;
 
1144
  justify-content: center;
1145
  }
1146
 
1147
+ .admin-schedule-grid {
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
+ }
1158
+
1159
  .page-viewport,
1160
  .page-track,
1161
  .page-slide,
 
1178
  .timeline-scroll {
1179
  height: auto;
1180
  }
1181
+
1182
+ .schedule-editor-shell {
1183
+ grid-template-columns: 72px minmax(0, 1fr);
1184
+ }
1185
  }
1186
 
1187
  @media (max-width: 560px) {
storage.py CHANGED
@@ -37,6 +37,22 @@ DEFAULT_PERIOD_TIMES = {
37
  11: ("20:15", "21:00"),
38
  12: ("21:10", "21:55"),
39
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  DEFAULT_IMPORTED_COURSES = [
41
  {
42
  "title": "形势与政策-2_20",
@@ -322,7 +338,25 @@ class ReminderStore:
322
 
323
  for key, value in DEFAULT_SCHEDULE_SETTINGS.items():
324
  if key not in settings:
325
- settings[key] = value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  changed = True
327
 
328
  if course_seed_version != DEFAULT_COURSE_SEED_VERSION:
 
37
  11: ("20:15", "21:00"),
38
  12: ("21:10", "21:55"),
39
  }
40
+
41
+
42
+ def build_default_time_slots() -> list[dict[str, str]]:
43
+ slots: list[dict[str, str]] = []
44
+ for index, (start, end) in DEFAULT_PERIOD_TIMES.items():
45
+ slots.append(
46
+ {
47
+ "label": f"第{index:02d}节课",
48
+ "start": start,
49
+ "end": end,
50
+ }
51
+ )
52
+ return slots
53
+
54
+
55
+ DEFAULT_SCHEDULE_SETTINGS["time_slots"] = build_default_time_slots()
56
  DEFAULT_IMPORTED_COURSES = [
57
  {
58
  "title": "形势与政策-2_20",
 
338
 
339
  for key, value in DEFAULT_SCHEDULE_SETTINGS.items():
340
  if key not in settings:
341
+ settings[key] = deepcopy(value)
342
+ changed = True
343
+
344
+ time_slots = settings.get("time_slots")
345
+ if not isinstance(time_slots, list) or not time_slots:
346
+ settings["time_slots"] = build_default_time_slots()
347
+ changed = True
348
+ else:
349
+ normalized_time_slots = []
350
+ for index, slot in enumerate(time_slots, start=1):
351
+ normalized_time_slots.append(
352
+ {
353
+ "label": str(slot.get("label", "")).strip() or f"第{index:02d}节课",
354
+ "start": str(slot.get("start", DEFAULT_PERIOD_TIMES.get(index, ("08:15", "09:00"))[0])),
355
+ "end": str(slot.get("end", DEFAULT_PERIOD_TIMES.get(index, ("08:15", "09:00"))[1])),
356
+ }
357
+ )
358
+ if normalized_time_slots != time_slots:
359
+ settings["time_slots"] = normalized_time_slots
360
  changed = True
361
 
362
  if course_seed_version != DEFAULT_COURSE_SEED_VERSION:
templates/admin.html CHANGED
@@ -4,204 +4,228 @@
4
  <link rel="stylesheet" href="{{ url_for('static', filename='v020.css') }}">
5
  {% endblock %}
6
  {% block body %}
7
- <main class="admin-layout">
8
  <section class="admin-hero card-surface">
9
  <div>
10
- <p class="section-kicker">管理后台</p>
11
- <h1>维护清单分类、课程和时间表设置</h1>
12
- <p class="admin-copy">课程属于固定时间块,只能在这里创建、编辑和删除,首页第二页会自动按周次生成。</p>
 
 
 
 
 
 
 
 
13
  </div>
 
14
  <div class="action-group">
15
  <a class="ghost-link" href="{{ url_for('index') }}">返回主页</a>
16
- <button class="pill-button accent" id="openCourseModalButton" type="button">新增课程</button>
 
 
17
  </div>
18
- </section>
19
 
20
- <section class="admin-section">
21
- <div class="section-header">
22
- <div>
23
- <p class="section-kicker">时间表设置</p>
24
- <h2>定义学期起始日和日程显示范围</h2>
25
- </div>
26
- <p class="section-note">课程周次会基于学期开始日期自动换算,首页时间表显示范围默认是 08:15 到 23:00。</p>
27
- </div>
28
 
29
- <div class="admin-grid admin-grid-tight">
30
- <article class="admin-card">
31
- <div class="admin-card-head">
32
- <div>
33
- <p class="column-label">Planner Settings</p>
34
- <h2>时间表基础参数</h2>
 
 
 
35
  </div>
36
- </div>
37
- <form id="scheduleSettingsForm" class="modal-form compact">
38
- <label>
39
- <span>学期开始日期</span>
40
- <input id="semesterStartInput" name="semester_start" type="date" value="{{ schedule_settings.semester_start }}">
41
- </label>
42
- <label>
43
- <span>显示开始时间</span>
44
- <input id="dayStartInput" name="day_start" type="time" value="{{ schedule_settings.day_start }}">
45
- </label>
46
- <label>
47
- <span>显示结束时间</span>
48
- <input id="dayEndInput" name="day_end" type="time" value="{{ schedule_settings.day_end }}">
49
- </label>
50
- <label>
51
- <span>默认任务时长(分钟)</span>
52
- <input id="defaultDurationInput" name="default_task_duration_minutes" type="number" min="30" max="240" step="15" value="{{ schedule_settings.default_task_duration_minutes }}">
53
- </label>
54
- <button class="primary-button" type="submit">保存时间表设置</button>
55
- </form>
56
- </article>
57
 
58
- <article class="admin-card create-card">
59
- <div class="create-icon">+</div>
60
- <h2>新建一个清单</h2>
61
- <form id="createCategoryForm" class="modal-form compact">
62
- <label>
63
- <span>分类名称</span>
64
- <input id="newCategoryName" name="name" type="text" maxlength="20" placeholder="例如:健康管理">
65
- </label>
66
- <button class="primary-button" type="submit">立即创建</button>
67
- </form>
68
- </article>
69
- </div>
70
- </section>
 
 
 
 
 
 
 
 
 
 
71
 
72
- <section class="admin-section">
73
- <div class="section-header">
74
- <div>
75
- <p class="section-kicker">固定课程</p>
76
- <h2>这些课程会自出现在第二页时表中</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  </div>
78
- <p class="section-note">如果你之前发给我的课程表没有保存在本地,可以先在这里手动录入,后续我也可以继续帮你批量导入。</p>
79
- </div>
80
 
81
- <section class="admin-grid" id="courseGrid">
82
- {% if courses %}
83
- {% for course in courses %}
84
- <article class="admin-card course-card" data-course-id="{{ course.id }}">
85
  <div class="admin-card-head">
86
  <div>
87
- <p class="column-label">Course</p>
88
- <h2>{{ course.title }}</h2>
89
  </div>
90
- <span class="task-count">{{ ["一","二","三","四","五","六","日"][course.day_of_week - 1] }}</span>
91
  </div>
92
- <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>
93
- <p class="admin-card-copy">{{ course.location if course.location else "未填写地点" }}</p>
94
  <div class="admin-card-actions">
95
- <button class="secondary-button" type="button" data-edit-course="{{ course.id }}">编辑课程</button>
96
- <button class="danger-button" type="button" data-delete-course="{{ course.id }}">删除课程</button>
97
  </div>
98
  </article>
99
  {% endfor %}
100
- {% else %}
101
- <article class="admin-card empty-admin-card">
102
- <h2>还没有固定课程</h2>
103
- <p class="admin-card-copy">点击右上角“新增课程”即可录入课表,首页第二页会自动按学期开始日期和周次生成显示。</p>
104
- </article>
105
- {% endif %}
106
  </section>
107
- </section>
108
-
109
- <section class="admin-section">
110
- <div class="section-header">
111
- <div>
112
- <p class="section-kicker">清单分类</p>
113
- <h2>删除分类会同时移除其下全部任务</h2>
114
- </div>
115
- </div>
116
-
117
- <section class="admin-grid" id="adminGrid">
118
- {% for category in categories %}
119
- <article class="admin-card" data-category-id="{{ category.id }}">
120
- <div class="admin-card-head">
121
- <div>
122
- <p class="column-label">Category</p>
123
- <h2>{{ category.name }}</h2>
124
- </div>
125
- <span class="task-count">{{ category.tasks|length }} 项任务</span>
126
- </div>
127
- <p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
128
- <button class="danger-button" type="button" data-delete-category="{{ category.id }}">删除此清单</button>
129
- </article>
130
- {% endfor %}
 
 
 
 
131
  </section>
132
- </section>
133
- </main>
134
 
135
- <div class="modal-backdrop" id="courseModal">
136
- <div class="modal-card">
137
- <div class="modal-head">
138
- <p class="modal-kicker">固定课程</p>
139
- <h3 id="courseModalTitle">新增课程</h3>
140
- </div>
141
- <form class="modal-form" id="courseForm">
142
- <input id="courseIdInput" name="course_id" type="hidden">
143
- <label>
144
- <span>课程名称</span>
145
- <input id="courseTitleInput" name="title" type="text" maxlength="40" placeholder="例如:高等数学">
146
- </label>
147
- <label>
148
- <span>上课地点</span>
149
- <input id="courseLocationInput" name="location" type="text" maxlength="40" placeholder="例如:教学楼 A302">
150
- </label>
151
- <label>
152
- <span>星期几</span>
153
- <select id="courseWeekdayInput" name="day_of_week">
154
- <option value="1">星期一</option>
155
- <option value="2">星期二</option>
156
- <option value="3">星期三</option>
157
- <option value="4">星期四</option>
158
- <option value="5">星期五</option>
159
- <option value="6">星期六</option>
160
- <option value="7">星期日</option>
161
- </select>
162
- </label>
163
- <div class="split-fields">
164
  <label>
165
- <span>开始时间</span>
166
- <input id="courseStartTimeInput" name="start_time" type="time">
167
  </label>
168
  <label>
169
- <span>结束时间</span>
170
- <input id="courseEndTimeInput" name="end_time" type="time">
171
  </label>
172
- </div>
173
- <div class="split-fields">
174
  <label>
175
- <span>开始周数</span>
176
- <input id="courseStartWeekInput" name="start_week" type="number" min="1" max="30" value="1">
 
 
 
 
 
 
 
 
177
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  <label>
179
- <span>结束</span>
180
- <input id="courseEndWeekInput" name="end_week" type="number" min="1" max="30" value="16">
 
 
 
 
181
  </label>
182
- </div>
183
- <label>
184
- <span>单双周</span>
185
- <select id="courseWeekPatternInput" name="week_pattern">
186
- <option value="all">每周</option>
187
- <option value="odd">单周</option>
188
- <option value="even">双周</option>
189
- </select>
190
- </label>
191
- <div class="form-actions">
192
- <button class="secondary-button" type="button" data-close-modal="courseModal">取消</button>
193
- <button class="primary-button" type="submit">保存课程</button>
194
- </div>
195
- </form>
196
  </div>
197
- </div>
198
 
199
- <div class="toast-stack" id="toastStack"></div>
 
200
  {% endblock %}
201
  {% block scripts %}
202
  <script>
203
  window.__ADMIN_BOOTSTRAP__ = {
 
204
  courses: {{ courses|tojson }},
 
 
 
205
  };
206
  </script>
207
  <script src="{{ url_for('static', filename='admin-v020.js') }}"></script>
 
4
  <link rel="stylesheet" href="{{ url_for('static', filename='v020.css') }}">
5
  {% endblock %}
6
  {% block body %}
7
+ <main class="admin-layout admin-layout-v2" data-admin-page="{{ admin_page }}">
8
  <section class="admin-hero card-surface">
9
  <div>
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
 
37
+ {% if admin_page == "schedule" %}
38
+ <section class="admin-section">
39
+ <div class="admin-grid admin-grid-tight admin-schedule-grid">
40
+ <article class="admin-card schedule-form-card">
41
+ <div class="admin-card-head">
42
+ <div>
43
+ <p class="column-label">Basic Settings</p>
44
+ <h2>基础参数</h2>
45
+ </div>
46
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
+ <form id="scheduleSettingsForm" class="modal-form compact">
49
+ <label>
50
+ <span>学期开始日期</span>
51
+ <input id="semesterStartInput" name="semester_start" type="date" value="{{ schedule_settings.semester_start }}">
52
+ </label>
53
+ <label>
54
+ <span>课表显示开始</span>
55
+ <input id="dayStartInput" name="day_start" type="time" value="{{ schedule_settings.day_start }}">
56
+ </label>
57
+ <label>
58
+ <span>课表显示结束</span>
59
+ <input id="dayEndInput" name="day_end" type="time" value="{{ schedule_settings.day_end }}">
60
+ </label>
61
+ <label>
62
+ <span>默认任务时长(分钟)</span>
63
+ <input id="defaultDurationInput" name="default_task_duration_minutes" type="number" min="30" max="240" step="15" value="{{ schedule_settings.default_task_duration_minutes }}">
64
+ </label>
65
+ <div class="form-actions">
66
+ <button class="secondary-button" id="resetTimelineButton" type="button">恢复默认节次</button>
67
+ <button class="primary-button" type="submit">保存时间表设置</button>
68
+ </div>
69
+ </form>
70
+ </article>
71
 
72
+ <article class="admin-card schedule-editor-card">
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>
98
+ <input id="newCategoryName" name="name" type="text" maxlength="20" placeholder="例如:项目推进">
99
+ </label>
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>
220
  {% endblock %}
221
  {% block scripts %}
222
  <script>
223
  window.__ADMIN_BOOTSTRAP__ = {
224
+ adminPage: {{ admin_page|tojson }},
225
  courses: {{ courses|tojson }},
226
+ categories: {{ categories|tojson }},
227
+ scheduleSettings: {{ schedule_settings|tojson }},
228
+ defaultTimeSlots: {{ default_time_slots|tojson }},
229
  };
230
  </script>
231
  <script src="{{ url_for('static', filename='admin-v020.js') }}"></script>