Codex commited on
Commit ·
cbd13dc
1
Parent(s): 52aca0e
Refactor admin pages and home cards
Browse files- app.py +130 -13
- static/admin-v020.js +414 -108
- static/v020.css +222 -6
- storage.py +35 -1
- 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":
|
| 392 |
-
"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":
|
| 489 |
-
"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 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 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 (!
|
| 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">
|
| 100 |
</div>
|
| 101 |
<p class="admin-card-copy">删除分类会同时移除其下全部任务,请谨慎操作。</p>
|
| 102 |
-
<
|
|
|
|
|
|
|
| 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">点击
|
| 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 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
});
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 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 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
});
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
}
|
| 164 |
-
|
| 165 |
-
closeModal(courseModal);
|
| 166 |
-
} catch (error) {
|
| 167 |
-
showToast(error.message, "error");
|
| 168 |
}
|
| 169 |
-
});
|
| 170 |
|
| 171 |
-
|
| 172 |
-
const
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
return;
|
| 180 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
| 184 |
return;
|
| 185 |
}
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
| 196 |
}
|
| 197 |
-
});
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 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 |
-
|
| 217 |
-
const
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
return;
|
| 220 |
}
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
| 225 |
});
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
}
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
}
|
| 234 |
-
});
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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:
|
| 126 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 183 |
display: grid;
|
| 184 |
grid-template-rows: auto minmax(0, 1fr);
|
| 185 |
}
|
| 186 |
|
| 187 |
-
.column-header {
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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">
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
</div>
|
|
|
|
| 14 |
<div class="action-group">
|
| 15 |
<a class="ghost-link" href="{{ url_for('index') }}">返回主页</a>
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
</div>
|
| 18 |
-
</section>
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
<p class="section-note">课程周次会基于学期开始日期自动换算,首页时间表显示范围默认是 08:15 到 23:00。</p>
|
| 27 |
-
</div>
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
<
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
-
<p class="section-note">如果你之前发给我的课程表没有保存在本地,可以先在这里手动录入,后续我也可以继续帮你批量导入。</p>
|
| 79 |
-
</div>
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 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">
|
| 88 |
-
<h2>{{
|
| 89 |
</div>
|
| 90 |
-
<span class="task-count">{{
|
| 91 |
</div>
|
| 92 |
-
<p class="admin-card-copy">
|
| 93 |
-
<p class="admin-card-copy">{{ course.location if course.location else "未填写地点" }}</p>
|
| 94 |
<div class="admin-card-actions">
|
| 95 |
-
<button class="
|
| 96 |
-
<button class="danger-button" type="button" data-delete-course="{{ course.id }}">删除课程</button>
|
| 97 |
</div>
|
| 98 |
</article>
|
| 99 |
{% endfor %}
|
| 100 |
-
|
| 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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
<
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
</section>
|
| 132 |
-
|
| 133 |
-
</main>
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 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>
|
| 166 |
-
<input id="
|
| 167 |
</label>
|
| 168 |
<label>
|
| 169 |
-
<span>
|
| 170 |
-
<input id="
|
| 171 |
</label>
|
| 172 |
-
</div>
|
| 173 |
-
<div class="split-fields">
|
| 174 |
<label>
|
| 175 |
-
<span>
|
| 176 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
<label>
|
| 179 |
-
<span>
|
| 180 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
</label>
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
<
|
| 186 |
-
|
| 187 |
-
|
| 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 |
-
|
|
|
|
| 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>
|