cacode commited on
Commit
f256f5b
·
verified ·
1 Parent(s): a30f196

Split admin features into separate pages

Browse files
space_app.py CHANGED
@@ -1,844 +1,963 @@
1
- from __future__ import annotations
2
-
3
- import atexit
4
- import json
5
- import re
6
- import time
7
- from datetime import date as date_cls, time as time_cls
8
- from functools import wraps
9
- from typing import Callable
10
-
11
- from flask import Flask, Response, flash, g, jsonify, redirect, render_template, request, session, stream_with_context, url_for
12
-
13
- from core.config import AppConfig
14
- from core.db import (
15
- DEFAULT_REGISTRATION_CODE_MAX_USES,
16
- Database,
17
- MAX_REFRESH_INTERVAL_SECONDS,
18
- MIN_REFRESH_INTERVAL_SECONDS,
19
- normalize_registration_code,
20
- )
21
- from core.runtime_logging import configure_logging, get_logger
22
- from core.security import SecretBox, hash_password, verify_password
23
- from core.task_manager import TaskManager
24
-
25
-
26
- configure_logging()
27
- APP_LOGGER = get_logger("sacc.web")
28
-
29
- config = AppConfig.load()
30
- store = Database(
31
- config.db_path,
32
- default_parallel_limit=config.default_parallel_limit,
33
- mysql_ssl_ca_path=config.mysql_ssl_ca_path,
34
- )
35
- store.init_db()
36
- secret_box = SecretBox(config.encryption_key)
37
- task_manager = TaskManager(config=config, store=store, secret_box=secret_box)
38
-
39
-
40
- def _seed_legacy_user() -> None:
41
- if store.list_users():
42
- return
43
-
44
- legacy_path = config.root_dir / "user_data.json"
45
- if not legacy_path.exists():
46
- return
47
-
48
- try:
49
- payload = json.loads(legacy_path.read_text(encoding="utf-8"))
50
- except (OSError, json.JSONDecodeError):
51
- return
52
-
53
- student_id = str(payload.get("std_id", "")).strip()
54
- password = str(payload.get("password", "")).strip()
55
- if not student_id or not password:
56
- return
57
-
58
- user_id = store.create_user(student_id, secret_box.encrypt(password), "Legacy User")
59
- for source_key, category in (("a", "plan"), ("b", "free")):
60
- for course in payload.get("course", {}).get(source_key, []):
61
- course_id = str(course.get("course_id", "")).strip()
62
- course_index = str(course.get("course_index", "")).strip()
63
- if course_id and course_index:
64
- store.add_course(user_id, category, course_id, course_index)
65
-
66
-
67
- _seed_legacy_user()
68
- task_manager.start()
69
- atexit.register(task_manager.shutdown)
70
-
71
- app = Flask(__name__, template_folder="templates", static_folder="static")
72
- app.secret_key = config.session_secret
73
- app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
74
- APP_LOGGER.info(
75
- "Application bootstrap complete | data_dir=%s db_path=%s backend=%s chrome_binary=%s chromedriver_path=%s default_parallel_limit=%s schedule_timezone=%s",
76
- config.data_dir,
77
- config.db_path,
78
- config.database_backend,
79
- config.chrome_binary,
80
- config.chromedriver_path,
81
- config.default_parallel_limit,
82
- config.schedule_timezone,
83
- )
84
-
85
- CATEGORY_LABELS = {
86
- "plan": "方案选课",
87
- "free": "自由选课",
88
- }
89
- TASK_LABELS = {
90
- "pending": "排队中",
91
- "running": "执行中",
92
- "cancel_requested": "停止中",
93
- "completed": "已完成",
94
- "stopped": "已停止",
95
- "failed": "失败",
96
- }
97
-
98
- SKIPPED_REQUEST_LOG_PREFIXES = (
99
- "/static/",
100
- "/api/",
101
- )
102
- SKIPPED_REQUEST_LOG_PATHS = {
103
- "/favicon.ico",
104
- }
105
- COURSE_ID_PATTERN = re.compile(r"^[0-9A-Za-z]{1,32}$")
106
- COURSE_INDEX_PATTERN = re.compile(r"^[0-9A-Za-z]{1,8}$")
107
- REGISTRATION_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{6,64}$")
108
-
109
-
110
- def _current_actor_label() -> str:
111
- role = session.get("role", "guest")
112
- if role == "user":
113
- return f"user:{session.get('user_id', '-')}"
114
- if role == "admin":
115
- return f"admin:{session.get('admin_username', '-')}"
116
- return "guest"
117
-
118
-
119
- @app.before_request
120
- def before_request_logging() -> None:
121
- g.request_started_at = time.perf_counter()
122
-
123
-
124
- @app.after_request
125
- def after_request_logging(response):
126
- if request.path in SKIPPED_REQUEST_LOG_PATHS:
127
- return response
128
- if any(request.path.startswith(prefix) for prefix in SKIPPED_REQUEST_LOG_PREFIXES):
129
- return response
130
-
131
- started_at = getattr(g, "request_started_at", None)
132
- duration_ms = 0.0 if started_at is None else (time.perf_counter() - started_at) * 1000
133
- remote_addr = request.headers.get("x-forwarded-for") or request.remote_addr or "-"
134
- APP_LOGGER.info(
135
- "HTTP %s %s -> %s in %.1fms | actor=%s remote=%s",
136
- request.method,
137
- request.path,
138
- response.status_code,
139
- duration_ms,
140
- _current_actor_label(),
141
- remote_addr,
142
- )
143
- return response
144
-
145
-
146
- @app.context_processor
147
- def inject_globals() -> dict:
148
- return {
149
- "category_labels": CATEGORY_LABELS,
150
- "task_labels": TASK_LABELS,
151
- "session_role": session.get("role", "guest"),
152
- "refresh_interval_min": MIN_REFRESH_INTERVAL_SECONDS,
153
- "refresh_interval_max": MAX_REFRESH_INTERVAL_SECONDS,
154
- "default_refresh_interval_seconds": config.poll_interval_seconds,
155
- "default_registration_code_max_uses": DEFAULT_REGISTRATION_CODE_MAX_USES,
156
- }
157
-
158
-
159
- def _login_required(role: str) -> Callable:
160
- def decorator(view: Callable) -> Callable:
161
- @wraps(view)
162
- def wrapped(*args, **kwargs):
163
- current_role = session.get("role")
164
- if role == "user" and current_role != "user":
165
- flash("请先登录学生账号。", "warning")
166
- return redirect(url_for("login"))
167
- if role == "admin" and current_role != "admin":
168
- flash("请先登录管理员账号。", "warning")
169
- return redirect(url_for("admin_login"))
170
- return view(*args, **kwargs)
171
-
172
- return wrapped
173
-
174
- return decorator
175
-
176
-
177
- def _get_current_user() -> dict | None:
178
- user_id = session.get("user_id")
179
- if not user_id:
180
- return None
181
- return store.get_user(int(user_id))
182
-
183
-
184
- def _get_admin_identity() -> dict:
185
- return {
186
- "username": session.get("admin_username", ""),
187
- "is_super_admin": bool(session.get("is_super_admin", False)),
188
- }
189
-
190
-
191
- def _normalize_course_token(raw_value: str) -> str:
192
- return re.sub(r"\s+", "", str(raw_value or "")).upper()
193
-
194
-
195
- def _validate_course_target(course_id: str, course_index: str) -> tuple[str, str] | None:
196
- normalized_course_id = _normalize_course_token(course_id)
197
- normalized_course_index = _normalize_course_token(course_index)
198
- if not COURSE_ID_PATTERN.fullmatch(normalized_course_id):
199
- return None
200
- if not COURSE_INDEX_PATTERN.fullmatch(normalized_course_index):
201
- return None
202
- return normalized_course_id, normalized_course_index
203
-
204
-
205
- def _parse_refresh_interval(raw_value: str | None, *, default: int) -> int:
206
- raw_text = str(raw_value or "").strip()
207
- if not raw_text:
208
- return default
209
- try:
210
- interval = int(raw_text)
211
- except ValueError as exc:
212
- raise ValueError(f"刷新间隔必须是 {MIN_REFRESH_INTERVAL_SECONDS} 到 {MAX_REFRESH_INTERVAL_SECONDS} 之间的整数。") from exc
213
- if interval < MIN_REFRESH_INTERVAL_SECONDS or interval > MAX_REFRESH_INTERVAL_SECONDS:
214
- raise ValueError(f"刷新间隔必须在 {MIN_REFRESH_INTERVAL_SECONDS} {MAX_REFRESH_INTERVAL_SECONDS} 秒之间。")
215
- return interval
216
-
217
-
218
- def _parse_registration_code_max_uses(raw_value: str | None) -> int:
219
- raw_text = str(raw_value or "").strip()
220
- if not raw_text:
221
- return DEFAULT_REGISTRATION_CODE_MAX_USES
222
- try:
223
- value = int(raw_text)
224
- except ValueError as exc:
225
- raise ValueError("注册码可用次数必须是 1 到 99 之间的整数。") from exc
226
- if value < 1 or value > 99:
227
- raise ValueError("注册码可用次数必须在 1 99 之间。")
228
- return value
229
-
230
-
231
- def _parse_iso_date(raw_value: str | None, label: str) -> str:
232
- raw_text = str(raw_value or "").strip()
233
- if not raw_text:
234
- raise ValueError(f"{label}不能为空。")
235
- try:
236
- return date_cls.fromisoformat(raw_text).isoformat()
237
- except ValueError as exc:
238
- raise ValueError(f"{label}格式无效,请使用 YYYY-MM-DD。") from exc
239
-
240
-
241
- def _parse_clock_time(raw_value: str | None, label: str) -> str:
242
- raw_text = str(raw_value or "").strip()
243
- if not raw_text:
244
- raise ValueError(f"{label}不能为空。")
245
- try:
246
- return time_cls.fromisoformat(raw_text).strftime("%H:%M")
247
- except ValueError as exc:
248
- raise ValueError(f"{label}格式无效,请使用 HH:MM。") from exc
249
-
250
-
251
- def _parse_schedule_form(form) -> dict:
252
- enabled = str(form.get("schedule_enabled", "")).lower() in {"1", "true", "on", "yes"}
253
- start_date_raw = form.get("start_date", "")
254
- end_date_raw = form.get("end_date", "")
255
- daily_start_time_raw = form.get("daily_start_time", "")
256
- daily_stop_time_raw = form.get("daily_stop_time", "")
257
- has_any_value = enabled or any(str(value or "").strip() for value in (start_date_raw, end_date_raw, daily_start_time_raw, daily_stop_time_raw))
258
- if not has_any_value:
259
- return {
260
- "is_enabled": False,
261
- "start_date": None,
262
- "end_date": None,
263
- "daily_start_time": None,
264
- "daily_stop_time": None,
265
- }
266
-
267
- start_date = _parse_iso_date(start_date_raw, "开始日期")
268
- end_date = _parse_iso_date(end_date_raw, "结束日期")
269
- daily_start_time = _parse_clock_time(daily_start_time_raw, "启动时间")
270
- daily_stop_time = _parse_clock_time(daily_stop_time_raw, "每日停止时间")
271
- if end_date < start_date:
272
- raise ValueError("结束日期不能早于开始日期。")
273
- if daily_stop_time <= daily_start_time:
274
- raise ValueError("每日停止时间必须晚于每日启动时间。")
275
- return {
276
- "is_enabled": enabled,
277
- "start_date": start_date,
278
- "end_date": end_date,
279
- "daily_start_time": daily_start_time,
280
- "daily_stop_time": daily_stop_time,
281
- }
282
-
283
-
284
- def _user_owns_course(user_id: int, course_target_id: int) -> bool:
285
- return any(course["id"] == course_target_id for course in store.list_courses_for_user(user_id))
286
-
287
-
288
- def _build_user_dashboard_context(user: dict) -> dict:
289
- return {
290
- "current_user": user,
291
- "courses": store.list_courses_for_user(user["id"]),
292
- "task": store.get_latest_task_for_user(user["id"]),
293
- "schedule": store.get_user_schedule(user["id"]),
294
- "recent_logs": store.list_recent_logs(user_id=user["id"], limit=config.logs_page_size),
295
- }
296
-
297
-
298
- def _build_admin_dashboard_context() -> dict:
299
- users = store.list_users()
300
- for user in users:
301
- user["courses"] = store.list_courses_for_user(user["id"])
302
- user["latest_task"] = store.get_latest_task_for_user(user["id"])
303
- user["schedule"] = store.get_user_schedule(user["id"])
304
- admin_identity = _get_admin_identity()
305
- return {
306
- "users": users,
307
- "admins": store.list_admins(),
308
- "registration_codes": store.list_registration_codes(limit=60),
309
- "stats": store.get_admin_stats(),
310
- "recent_tasks": store.list_recent_tasks(limit=18),
311
- "recent_logs": store.list_recent_logs(limit=config.logs_page_size),
312
- "parallel_limit": store.get_parallel_limit(),
313
- "default_refresh_interval_seconds": config.poll_interval_seconds,
314
- "is_super_admin": admin_identity["is_super_admin"],
315
- "admin_identity": admin_identity,
316
- }
317
-
318
-
319
- def _queue_task_for_user(user: dict, *, requested_by: str, requested_by_role: str) -> tuple[dict, bool]:
320
- return task_manager.queue_task(user["id"], requested_by=requested_by, requested_by_role=requested_by_role)
321
-
322
-
323
- def _latest_log_id(logs: list[dict]) -> int:
324
- if not logs:
325
- return 0
326
- return int(logs[-1]["id"])
327
-
328
-
329
- @app.get("/")
330
- def index():
331
- if session.get("role") == "user":
332
- return redirect(url_for("dashboard"))
333
- if session.get("role") == "admin":
334
- return redirect(url_for("admin_dashboard"))
335
- return redirect(url_for("login"))
336
-
337
-
338
- @app.route("/login", methods=["GET", "POST"])
339
- def login():
340
- if request.method == "POST":
341
- student_id = request.form.get("student_id", "").strip()
342
- password = request.form.get("password", "")
343
- user = store.get_user_by_student_id(student_id)
344
- if user is None:
345
- flash("没有找到该学号对应的账号。如果你有注册码,请先完成注册。", "danger")
346
- return render_template("login.html")
347
- if not user["is_active"]:
348
- flash("该账号已被管理员禁用。", "danger")
349
- return render_template("login.html")
350
- try:
351
- stored_password = secret_box.decrypt(user["password_encrypted"])
352
- except Exception:
353
- flash("账号数据损坏,请联系管理员重置密码。", "danger")
354
- return render_template("login.html")
355
- if stored_password != password:
356
- flash("学号或密码不正确。", "danger")
357
- return render_template("login.html")
358
-
359
- session.clear()
360
- session["role"] = "user"
361
- session["user_id"] = user["id"]
362
- return redirect(url_for("dashboard"))
363
-
364
- return render_template("login.html")
365
-
366
-
367
- @app.route("/register", methods=["GET", "POST"])
368
- def register():
369
- if request.method == "POST":
370
- registration_code = normalize_registration_code(request.form.get("registration_code", ""))
371
- student_id = request.form.get("student_id", "").strip()
372
- password = request.form.get("password", "").strip()
373
- display_name = request.form.get("display_name", "").strip()
374
-
375
- if not REGISTRATION_CODE_PATTERN.fullmatch(registration_code):
376
- flash("请输入有效的注册码。", "danger")
377
- return render_template("register.html")
378
- if not student_id.isdigit() or not password:
379
- flash("请填写学号和教务处密码。", "danger")
380
- return render_template("register.html")
381
-
382
- try:
383
- store.register_user_with_code(
384
- registration_code,
385
- student_id,
386
- secret_box.encrypt(password),
387
- display_name,
388
- refresh_interval_seconds=config.poll_interval_seconds,
389
- )
390
- except ValueError as exc:
391
- flash(str(exc), "danger")
392
- return render_template("register.html")
393
-
394
- flash("注册成功,请使用学号和教务处密码登录。", "success")
395
- return redirect(url_for("login"))
396
-
397
- return render_template("register.html")
398
-
399
-
400
- @app.post("/logout")
401
- def logout():
402
- session.clear()
403
- return redirect(url_for("login"))
404
-
405
-
406
- @app.route("/admin", methods=["GET", "POST"])
407
- def admin_login():
408
- if request.method == "POST":
409
- username = request.form.get("username", "").strip()
410
- password = request.form.get("password", "")
411
- is_super_admin = username == config.super_admin_username and password == config.super_admin_password
412
- admin_row = store.get_admin_by_username(username)
413
- is_regular_admin = bool(admin_row and verify_password(admin_row["password_hash"], password))
414
- if not is_super_admin and not is_regular_admin:
415
- flash("管理员账号或密码错误。", "danger")
416
- return render_template("admin_login.html")
417
-
418
- session.clear()
419
- session["role"] = "admin"
420
- session["admin_username"] = username
421
- session["is_super_admin"] = is_super_admin
422
- return redirect(url_for("admin_dashboard"))
423
-
424
- return render_template("admin_login.html")
425
-
426
-
427
- @app.post("/admin/logout")
428
- def admin_logout():
429
- session.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  return redirect(url_for("admin_login"))
431
-
432
- @app.get("/dashboard")
433
- @_login_required("user")
434
- def dashboard():
435
- user = _get_current_user()
436
- if user is None:
437
- session.clear()
438
- return redirect(url_for("login"))
439
- return render_template("dashboard.html", **_build_user_dashboard_context(user))
440
-
441
-
442
- @app.post("/dashboard/profile")
443
- @_login_required("user")
444
- def update_profile():
445
- user = _get_current_user()
446
- if user is None:
447
- session.clear()
448
- return redirect(url_for("login"))
449
-
450
- password = request.form.get("password", "").strip()
451
- display_name = request.form.get("display_name", "").strip()
452
- if not password:
453
- flash("密码不能为空。", "danger")
454
- return redirect(url_for("dashboard"))
455
-
456
- store.update_user(user["id"], password_encrypted=secret_box.encrypt(password), display_name=display_name)
457
- flash("账号信息已更新。", "success")
458
- return redirect(url_for("dashboard"))
459
-
460
-
461
- @app.post("/dashboard/settings/runtime")
462
- @_login_required("user")
463
- def update_runtime_settings():
464
- user = _get_current_user()
465
- if user is None:
466
- session.clear()
467
- return redirect(url_for("login"))
468
-
469
- try:
470
- refresh_interval_seconds = _parse_refresh_interval(
471
- request.form.get("refresh_interval_seconds"),
472
- default=int(user.get("refresh_interval_seconds") or config.poll_interval_seconds),
473
- )
474
- except ValueError as exc:
475
- flash(str(exc), "danger")
476
- return redirect(url_for("dashboard"))
477
-
478
- store.update_user(user["id"], refresh_interval_seconds=refresh_interval_seconds)
479
- flash(f"未命中课程后的刷新间隔已更新为 {refresh_interval_seconds} 秒。", "success")
480
- return redirect(url_for("dashboard"))
481
-
482
-
483
- @app.post("/dashboard/courses")
484
- @_login_required("user")
485
- def add_course():
486
- user = _get_current_user()
487
- if user is None:
488
- session.clear()
489
- return redirect(url_for("login"))
490
-
491
- category = request.form.get("category", "free")
492
- course_id = request.form.get("course_id", "")
493
- course_index = request.form.get("course_index", "")
494
- normalized_target = _validate_course_target(course_id, course_index)
495
- if normalized_target is None:
496
- flash("课程号需要使用 1-32 位字母或数字,课序号需要使用 1-8 位字母或数字。", "danger")
497
- return redirect(url_for("dashboard"))
498
-
499
- normalized_course_id, normalized_course_index = normalized_target
500
- store.add_course(user["id"], category, normalized_course_id, normalized_course_index)
501
- flash("课程已加入任务列表。", "success")
502
- return redirect(url_for("dashboard"))
503
-
504
-
505
- @app.post("/dashboard/courses/<int:course_target_id>/delete")
506
- @_login_required("user")
507
- def delete_course(course_target_id: int):
508
- user = _get_current_user()
509
- if user is None:
510
- session.clear()
511
- return redirect(url_for("login"))
512
- if not _user_owns_course(user["id"], course_target_id):
513
- flash("不能删除不属于当前账号的课程。", "danger")
514
- return redirect(url_for("dashboard"))
515
- store.delete_course(course_target_id)
516
- flash("课程已移除。", "success")
517
- return redirect(url_for("dashboard"))
518
-
519
-
520
- @app.post("/dashboard/task/start")
521
- @_login_required("user")
522
- def start_task():
523
- user = _get_current_user()
524
- if user is None:
525
- session.clear()
526
- return redirect(url_for("login"))
527
- task, created = _queue_task_for_user(user, requested_by=user["student_id"], requested_by_role="user")
528
- flash("任务已启动。" if created else f"已有任务处于 {TASK_LABELS.get(task['status'], task['status'])} 状态。", "success" if created else "warning")
529
- return redirect(url_for("dashboard"))
530
-
531
-
532
- @app.post("/dashboard/task/stop")
533
- @_login_required("user")
534
- def stop_task():
535
- user = _get_current_user()
536
- if user is None:
537
- session.clear()
538
- return redirect(url_for("login"))
539
- active_task = store.find_active_task_for_user(user["id"])
540
- if active_task and task_manager.stop_task(active_task["id"]):
541
- flash("停止请求已发送。", "success")
542
- else:
543
- flash("当前没有可停止的任务。", "warning")
544
- return redirect(url_for("dashboard"))
545
-
546
-
547
- @app.get("/admin/dashboard")
548
- @_login_required("admin")
549
- def admin_dashboard():
550
- return render_template("admin_dashboard.html", **_build_admin_dashboard_context())
551
-
552
-
553
- @app.post("/admin/settings/parallel-limit")
554
- @_login_required("admin")
555
- def update_parallel_limit():
556
- try:
557
- parallel_limit = max(1, min(8, int(request.form.get("parallel_limit", str(config.default_parallel_limit)))))
558
- except ValueError:
559
- flash("并行数必须是 1 到 8 的整数。", "danger")
560
- return redirect(url_for("admin_dashboard"))
561
- store.set_parallel_limit(parallel_limit)
562
- flash(f"并行数已更新为 {parallel_limit}。", "success")
563
- return redirect(url_for("admin_dashboard"))
564
-
565
-
566
- @app.post("/admin/users")
567
- @_login_required("admin")
568
- def create_user():
569
- student_id = request.form.get("student_id", "").strip()
570
- password = request.form.get("password", "").strip()
571
- display_name = request.form.get("display_name", "").strip()
572
- try:
573
- refresh_interval_seconds = _parse_refresh_interval(
574
- request.form.get("refresh_interval_seconds"),
575
- default=config.poll_interval_seconds,
576
- )
577
- except ValueError as exc:
578
- flash(str(exc), "danger")
579
- return redirect(url_for("admin_dashboard"))
580
- if not student_id.isdigit() or not password:
581
- flash("请填写有效的学号和密码。", "danger")
582
- return redirect(url_for("admin_dashboard"))
583
- if store.get_user_by_student_id(student_id):
584
- flash("该学号已经存在。", "warning")
585
- return redirect(url_for("admin_dashboard"))
586
- store.create_user(
587
- student_id,
588
- secret_box.encrypt(password),
589
- display_name,
590
- refresh_interval_seconds=refresh_interval_seconds,
591
- )
592
- flash("用户已创建。", "success")
593
- return redirect(url_for("admin_dashboard"))
594
-
595
-
596
- @app.post("/admin/users/<int:user_id>/update")
597
- @_login_required("admin")
598
- def update_user(user_id: int):
599
- user = store.get_user(user_id)
600
- if user is None:
601
- flash("用户不存在。", "danger")
602
- return redirect(url_for("admin_dashboard"))
603
- display_name = request.form.get("display_name", user["display_name"]).strip()
604
- password = request.form.get("password", "").strip()
605
- try:
606
- refresh_interval_seconds = _parse_refresh_interval(
607
- request.form.get("refresh_interval_seconds"),
608
- default=int(user.get("refresh_interval_seconds") or config.poll_interval_seconds),
609
- )
610
- except ValueError as exc:
611
- flash(str(exc), "danger")
612
- return redirect(url_for("admin_dashboard"))
613
- if password:
614
- store.update_user(
615
- user_id,
616
- display_name=display_name,
617
- password_encrypted=secret_box.encrypt(password),
618
- refresh_interval_seconds=refresh_interval_seconds,
619
- )
620
- else:
621
- store.update_user(user_id, display_name=display_name, refresh_interval_seconds=refresh_interval_seconds)
622
- flash("用户信息已更新。", "success")
623
- return redirect(url_for("admin_dashboard"))
624
-
625
-
626
- @app.post("/admin/users/<int:user_id>/toggle")
627
- @_login_required("admin")
628
- def toggle_user(user_id: int):
629
- updated = store.toggle_user_active(user_id)
630
- if updated is None:
631
- flash("用户不存在。", "danger")
632
- else:
633
- flash("用户状态已切换。", "success")
634
- return redirect(url_for("admin_dashboard"))
635
-
636
-
637
- @app.post("/admin/users/<int:user_id>/delete")
638
- @_login_required("admin")
639
- def delete_user_by_admin(user_id: int):
640
- user = store.get_user(user_id)
641
- if user is None:
642
- flash("用户不存在。", "danger")
643
- return redirect(url_for("admin_dashboard"))
644
- active_task = store.find_active_task_for_user(user_id)
645
- if active_task is not None:
646
- flash("请先停止该用户当前任务,再删除用户。", "danger")
647
- return redirect(url_for("admin_dashboard"))
648
- store.delete_user(user_id)
649
- flash("用户及其课程、日志、定时设置已删除。", "success")
650
- return redirect(url_for("admin_dashboard"))
651
-
652
-
653
- @app.post("/admin/users/<int:user_id>/schedule")
654
- @_login_required("admin")
655
- def update_user_schedule(user_id: int):
656
- if store.get_user(user_id) is None:
657
- flash("用户不存在。", "danger")
658
- return redirect(url_for("admin_dashboard"))
659
- try:
660
- schedule_payload = _parse_schedule_form(request.form)
661
- except ValueError as exc:
662
- flash(str(exc), "danger")
663
- return redirect(url_for("admin_dashboard"))
664
- store.upsert_user_schedule(user_id, **schedule_payload)
665
- flash("定时启动终止设置已更新。", "success")
666
- return redirect(url_for("admin_dashboard"))
667
-
668
-
669
- @app.post("/admin/users/<int:user_id>/courses")
670
- @_login_required("admin")
671
- def admin_add_course(user_id: int):
672
- if store.get_user(user_id) is None:
673
- flash("用户不存在。", "danger")
674
- return redirect(url_for("admin_dashboard"))
675
- category = request.form.get("category", "free")
676
- course_id = request.form.get("course_id", "")
677
- course_index = request.form.get("course_index", "")
678
- normalized_target = _validate_course_target(course_id, course_index)
679
- if normalized_target is None:
680
- flash("课程号需要使用 1-32 位字母或数字,课序号需要使用 1-8 位字母或数字。", "danger")
681
- return redirect(url_for("admin_dashboard"))
682
- normalized_course_id, normalized_course_index = normalized_target
683
- store.add_course(user_id, category, normalized_course_id, normalized_course_index)
684
- flash("课程已添加到对应用户。", "success")
685
- return redirect(url_for("admin_dashboard"))
686
-
687
-
688
- @app.post("/admin/courses/<int:course_target_id>/delete")
689
- @_login_required("admin")
690
- def admin_delete_course(course_target_id: int):
691
- store.delete_course(course_target_id)
692
- flash("课程已删除。", "success")
693
- return redirect(url_for("admin_dashboard"))
694
-
695
-
696
- @app.post("/admin/users/<int:user_id>/task/start")
697
- @_login_required("admin")
698
- def admin_start_user_task(user_id: int):
699
- user = store.get_user(user_id)
700
- if user is None:
701
- flash("用户不存在。", "danger")
702
- return redirect(url_for("admin_dashboard"))
703
- admin_identity = _get_admin_identity()
704
- task, created = _queue_task_for_user(user, requested_by=admin_identity["username"], requested_by_role="admin")
705
- flash("任务已加入队列。" if created else f"该用户已有任务处于 {TASK_LABELS.get(task['status'], task['status'])} 状态。", "success" if created else "warning")
706
- return redirect(url_for("admin_dashboard"))
707
-
708
-
709
- @app.post("/admin/users/<int:user_id>/task/stop")
710
- @_login_required("admin")
711
- def admin_stop_user_task(user_id: int):
712
- active_task = store.find_active_task_for_user(user_id)
713
- if active_task and task_manager.stop_task(active_task["id"]):
714
- flash("已发送停止请求。", "success")
715
- else:
716
- flash("当前没有可停止任务。", "warning")
717
- return redirect(url_for("admin_dashboard"))
718
-
719
-
720
- @app.post("/admin/admins")
721
- @_login_required("admin")
722
- def create_admin():
723
- if not session.get("is_super_admin", False):
724
- flash("只有超级管理员可以新增管理员。", "danger")
725
- return redirect(url_for("admin_dashboard"))
726
- username = request.form.get("username", "").strip()
727
- password = request.form.get("password", "").strip()
728
- if not username or not password:
729
- flash("请填写管理员账号和密码。", "danger")
730
- return redirect(url_for("admin_dashboard"))
731
- if username == config.super_admin_username or store.get_admin_by_username(username):
732
- flash("该管理员账号已存在。", "warning")
733
- return redirect(url_for("admin_dashboard"))
734
- store.create_admin(username, hash_password(password))
735
- flash("管理员已创建。", "success")
736
- return redirect(url_for("admin_dashboard"))
737
-
738
-
739
- @app.post("/admin/registration-codes")
740
- @_login_required("admin")
741
- def create_registration_code():
742
- note = request.form.get("note", "").strip()
743
- try:
744
- max_uses = _parse_registration_code_max_uses(request.form.get("max_uses"))
745
- except ValueError as exc:
746
- flash(str(exc), "danger")
747
- return redirect(url_for("admin_dashboard"))
748
- admin_identity = _get_admin_identity()
749
- created = store.create_registration_code(created_by=admin_identity["username"], note=note, max_uses=max_uses)
750
- flash(f"注册码已创建:{created['code']}", "success")
751
- return redirect(url_for("admin_dashboard"))
752
-
753
-
754
- @app.post("/admin/registration-codes/<int:registration_code_id>/toggle")
755
- @_login_required("admin")
756
- def toggle_registration_code(registration_code_id: int):
757
- updated = store.toggle_registration_code_active(registration_code_id)
758
- if updated is None:
759
- flash("注册码不存在。", "danger")
760
- else:
761
- flash("注册码状态已更新。", "success")
762
- return redirect(url_for("admin_dashboard"))
763
-
764
- @app.get("/api/user/status")
765
- @_login_required("user")
766
- def user_status():
767
- user = _get_current_user()
768
- if user is None:
769
- return jsonify({"ok": False}), 401
770
- task = store.get_latest_task_for_user(user["id"])
771
- return jsonify(
772
- {
773
- "ok": True,
774
- "task": task,
775
- "courses": store.list_courses_for_user(user["id"]),
776
- "user": {
777
- "refresh_interval_seconds": int(user.get("refresh_interval_seconds") or config.poll_interval_seconds),
778
- },
779
- }
780
- )
781
-
782
-
783
- @app.get("/api/admin/status")
784
- @_login_required("admin")
785
- def admin_status():
786
- return jsonify(
787
- {
788
- "ok": True,
789
- "stats": store.get_admin_stats(),
790
- "parallel_limit": store.get_parallel_limit(),
791
- "recent_tasks": store.list_recent_tasks(limit=12),
792
- }
793
- )
794
-
795
-
796
- @app.get("/api/user/logs/stream")
797
- @_login_required("user")
798
- def stream_user_logs():
799
- user = _get_current_user()
800
- if user is None:
801
- return jsonify({"ok": False}), 401
802
- last_id = int(request.args.get("last_id", _latest_log_id(store.list_recent_logs(user_id=user["id"], limit=1))))
803
-
804
- @stream_with_context
805
- def generate():
806
- current_last_id = last_id
807
- while True:
808
- logs = store.list_logs_after(current_last_id, user_id=user["id"], limit=60)
809
- if logs:
810
- for log in logs:
811
- current_last_id = int(log["id"])
812
- yield f"id: {log['id']}\ndata: {json.dumps(log, ensure_ascii=False)}\n\n"
813
- else:
814
- yield ": keep-alive\n\n"
815
- time.sleep(1)
816
-
817
- response = Response(generate(), mimetype="text/event-stream")
818
- response.headers["Cache-Control"] = "no-cache"
819
- response.headers["X-Accel-Buffering"] = "no"
820
- return response
821
-
822
-
823
- @app.get("/api/admin/logs/stream")
824
- @_login_required("admin")
825
- def stream_admin_logs():
826
- last_id = int(request.args.get("last_id", _latest_log_id(store.list_recent_logs(limit=1))))
827
-
828
- @stream_with_context
829
- def generate():
830
- current_last_id = last_id
831
- while True:
832
- logs = store.list_logs_after(current_last_id, limit=80)
833
- if logs:
834
- for log in logs:
835
- current_last_id = int(log["id"])
836
- yield f"id: {log['id']}\ndata: {json.dumps(log, ensure_ascii=False)}\n\n"
837
- else:
838
- yield ": keep-alive\n\n"
839
- time.sleep(1)
840
-
841
- response = Response(generate(), mimetype="text/event-stream")
842
- response.headers["Cache-Control"] = "no-cache"
843
- response.headers["X-Accel-Buffering"] = "no"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  return response
 
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ import re
6
+ import time
7
+ from datetime import date as date_cls, time as time_cls
8
+ from functools import wraps
9
+ from typing import Callable
10
+ from urllib.parse import urlparse
11
+
12
+ from flask import Flask, Response, flash, g, jsonify, redirect, render_template, request, session, stream_with_context, url_for
13
+
14
+ from core.config import AppConfig
15
+ from core.db import (
16
+ DEFAULT_REGISTRATION_CODE_MAX_USES,
17
+ Database,
18
+ MAX_REFRESH_INTERVAL_SECONDS,
19
+ MIN_REFRESH_INTERVAL_SECONDS,
20
+ normalize_registration_code,
21
+ )
22
+ from core.runtime_logging import configure_logging, get_logger
23
+ from core.security import SecretBox, hash_password, verify_password
24
+ from core.task_manager import TaskManager
25
+
26
+
27
+ configure_logging()
28
+ APP_LOGGER = get_logger("sacc.web")
29
+
30
+ config = AppConfig.load()
31
+ store = Database(
32
+ config.db_path,
33
+ default_parallel_limit=config.default_parallel_limit,
34
+ mysql_ssl_ca_path=config.mysql_ssl_ca_path,
35
+ )
36
+ store.init_db()
37
+ secret_box = SecretBox(config.encryption_key)
38
+ task_manager = TaskManager(config=config, store=store, secret_box=secret_box)
39
+
40
+
41
+ def _seed_legacy_user() -> None:
42
+ if store.list_users():
43
+ return
44
+
45
+ legacy_path = config.root_dir / "user_data.json"
46
+ if not legacy_path.exists():
47
+ return
48
+
49
+ try:
50
+ payload = json.loads(legacy_path.read_text(encoding="utf-8"))
51
+ except (OSError, json.JSONDecodeError):
52
+ return
53
+
54
+ student_id = str(payload.get("std_id", "")).strip()
55
+ password = str(payload.get("password", "")).strip()
56
+ if not student_id or not password:
57
+ return
58
+
59
+ user_id = store.create_user(student_id, secret_box.encrypt(password), "Legacy User")
60
+ for source_key, category in (("a", "plan"), ("b", "free")):
61
+ for course in payload.get("course", {}).get(source_key, []):
62
+ course_id = str(course.get("course_id", "")).strip()
63
+ course_index = str(course.get("course_index", "")).strip()
64
+ if course_id and course_index:
65
+ store.add_course(user_id, category, course_id, course_index)
66
+
67
+
68
+ _seed_legacy_user()
69
+ task_manager.start()
70
+ atexit.register(task_manager.shutdown)
71
+
72
+ app = Flask(__name__, template_folder="templates", static_folder="static")
73
+ app.secret_key = config.session_secret
74
+ app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
75
+ APP_LOGGER.info(
76
+ "Application bootstrap complete | data_dir=%s db_path=%s backend=%s chrome_binary=%s chromedriver_path=%s default_parallel_limit=%s schedule_timezone=%s",
77
+ config.data_dir,
78
+ config.db_path,
79
+ config.database_backend,
80
+ config.chrome_binary,
81
+ config.chromedriver_path,
82
+ config.default_parallel_limit,
83
+ config.schedule_timezone,
84
+ )
85
+
86
+ CATEGORY_LABELS = {
87
+ "plan": "方案选课",
88
+ "free": "自由选课",
89
+ }
90
+ TASK_LABELS = {
91
+ "pending": "排队中",
92
+ "running": "执行中",
93
+ "cancel_requested": "停止中",
94
+ "completed": "已完成",
95
+ "stopped": "已停止",
96
+ "failed": "失败",
97
+ }
98
+
99
+ SKIPPED_REQUEST_LOG_PREFIXES = (
100
+ "/static/",
101
+ "/api/",
102
+ )
103
+ SKIPPED_REQUEST_LOG_PATHS = {
104
+ "/favicon.ico",
105
+ }
106
+ COURSE_ID_PATTERN = re.compile(r"^[0-9A-Za-z]{1,32}$")
107
+ COURSE_INDEX_PATTERN = re.compile(r"^[0-9A-Za-z]{1,8}$")
108
+ REGISTRATION_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{6,64}$")
109
+
110
+
111
+ def _current_actor_label() -> str:
112
+ role = session.get("role", "guest")
113
+ if role == "user":
114
+ return f"user:{session.get('user_id', '-')}"
115
+ if role == "admin":
116
+ return f"admin:{session.get('admin_username', '-')}"
117
+ return "guest"
118
+
119
+
120
+ @app.before_request
121
+ def before_request_logging() -> None:
122
+ g.request_started_at = time.perf_counter()
123
+
124
+
125
+ @app.after_request
126
+ def after_request_logging(response):
127
+ if request.path in SKIPPED_REQUEST_LOG_PATHS:
128
+ return response
129
+ if any(request.path.startswith(prefix) for prefix in SKIPPED_REQUEST_LOG_PREFIXES):
130
+ return response
131
+
132
+ started_at = getattr(g, "request_started_at", None)
133
+ duration_ms = 0.0 if started_at is None else (time.perf_counter() - started_at) * 1000
134
+ remote_addr = request.headers.get("x-forwarded-for") or request.remote_addr or "-"
135
+ APP_LOGGER.info(
136
+ "HTTP %s %s -> %s in %.1fms | actor=%s remote=%s",
137
+ request.method,
138
+ request.path,
139
+ response.status_code,
140
+ duration_ms,
141
+ _current_actor_label(),
142
+ remote_addr,
143
+ )
144
+ return response
145
+
146
+
147
+ @app.context_processor
148
+ def inject_globals() -> dict:
149
+ return {
150
+ "category_labels": CATEGORY_LABELS,
151
+ "task_labels": TASK_LABELS,
152
+ "session_role": session.get("role", "guest"),
153
+ "refresh_interval_min": MIN_REFRESH_INTERVAL_SECONDS,
154
+ "refresh_interval_max": MAX_REFRESH_INTERVAL_SECONDS,
155
+ "default_refresh_interval_seconds": config.poll_interval_seconds,
156
+ "default_registration_code_max_uses": DEFAULT_REGISTRATION_CODE_MAX_USES,
157
+ }
158
+
159
+
160
+ def _login_required(role: str) -> Callable:
161
+ def decorator(view: Callable) -> Callable:
162
+ @wraps(view)
163
+ def wrapped(*args, **kwargs):
164
+ current_role = session.get("role")
165
+ if role == "user" and current_role != "user":
166
+ flash("请先登录学生账号。", "warning")
167
+ return redirect(url_for("login"))
168
+ if role == "admin" and current_role != "admin":
169
+ flash("请先登录管理员账号。", "warning")
170
+ return redirect(url_for("admin_login"))
171
+ return view(*args, **kwargs)
172
+
173
+ return wrapped
174
+
175
+ return decorator
176
+
177
+
178
+ def _get_current_user() -> dict | None:
179
+ user_id = session.get("user_id")
180
+ if not user_id:
181
+ return None
182
+ return store.get_user(int(user_id))
183
+
184
+
185
+ def _get_admin_identity() -> dict:
186
+ return {
187
+ "username": session.get("admin_username", ""),
188
+ "is_super_admin": bool(session.get("is_super_admin", False)),
189
+ }
190
+
191
+
192
+ def _normalize_course_token(raw_value: str) -> str:
193
+ return re.sub(r"\s+", "", str(raw_value or "")).upper()
194
+
195
+
196
+ def _validate_course_target(course_id: str, course_index: str) -> tuple[str, str] | None:
197
+ normalized_course_id = _normalize_course_token(course_id)
198
+ normalized_course_index = _normalize_course_token(course_index)
199
+ if not COURSE_ID_PATTERN.fullmatch(normalized_course_id):
200
+ return None
201
+ if not COURSE_INDEX_PATTERN.fullmatch(normalized_course_index):
202
+ return None
203
+ return normalized_course_id, normalized_course_index
204
+
205
+
206
+ def _parse_refresh_interval(raw_value: str | None, *, default: int) -> int:
207
+ raw_text = str(raw_value or "").strip()
208
+ if not raw_text:
209
+ return default
210
+ try:
211
+ interval = int(raw_text)
212
+ except ValueError as exc:
213
+ raise ValueError(f"刷新间隔必须是 {MIN_REFRESH_INTERVAL_SECONDS} {MAX_REFRESH_INTERVAL_SECONDS} 之间的整数。") from exc
214
+ if interval < MIN_REFRESH_INTERVAL_SECONDS or interval > MAX_REFRESH_INTERVAL_SECONDS:
215
+ raise ValueError(f"刷新间隔必须在 {MIN_REFRESH_INTERVAL_SECONDS} 到 {MAX_REFRESH_INTERVAL_SECONDS} 秒之间。")
216
+ return interval
217
+
218
+
219
+ def _parse_registration_code_max_uses(raw_value: str | None) -> int:
220
+ raw_text = str(raw_value or "").strip()
221
+ if not raw_text:
222
+ return DEFAULT_REGISTRATION_CODE_MAX_USES
223
+ try:
224
+ value = int(raw_text)
225
+ except ValueError as exc:
226
+ raise ValueError("注册码可用次数必须是 1 99 之间的整数。") from exc
227
+ if value < 1 or value > 99:
228
+ raise ValueError("注册码可用次数必须在 1 到 99 之间。")
229
+ return value
230
+
231
+
232
+ def _parse_iso_date(raw_value: str | None, label: str) -> str:
233
+ raw_text = str(raw_value or "").strip()
234
+ if not raw_text:
235
+ raise ValueError(f"{label}不能为空。")
236
+ try:
237
+ return date_cls.fromisoformat(raw_text).isoformat()
238
+ except ValueError as exc:
239
+ raise ValueError(f"{label}格式无效,请使用 YYYY-MM-DD。") from exc
240
+
241
+
242
+ def _parse_clock_time(raw_value: str | None, label: str) -> str:
243
+ raw_text = str(raw_value or "").strip()
244
+ if not raw_text:
245
+ raise ValueError(f"{label}不能为空。")
246
+ try:
247
+ return time_cls.fromisoformat(raw_text).strftime("%H:%M")
248
+ except ValueError as exc:
249
+ raise ValueError(f"{label}格式无效,请使用 HH:MM。") from exc
250
+
251
+
252
+ def _parse_schedule_form(form) -> dict:
253
+ enabled = str(form.get("schedule_enabled", "")).lower() in {"1", "true", "on", "yes"}
254
+ start_date_raw = form.get("start_date", "")
255
+ end_date_raw = form.get("end_date", "")
256
+ daily_start_time_raw = form.get("daily_start_time", "")
257
+ daily_stop_time_raw = form.get("daily_stop_time", "")
258
+ has_any_value = enabled or any(str(value or "").strip() for value in (start_date_raw, end_date_raw, daily_start_time_raw, daily_stop_time_raw))
259
+ if not has_any_value:
260
+ return {
261
+ "is_enabled": False,
262
+ "start_date": None,
263
+ "end_date": None,
264
+ "daily_start_time": None,
265
+ "daily_stop_time": None,
266
+ }
267
+
268
+ start_date = _parse_iso_date(start_date_raw, "开始日期")
269
+ end_date = _parse_iso_date(end_date_raw, "结束")
270
+ daily_start_time = _parse_clock_time(daily_start_time_raw, "每日启动时间")
271
+ daily_stop_time = _parse_clock_time(daily_stop_time_raw, "每日停止时间")
272
+ if end_date < start_date:
273
+ raise ValueError("结束���期不能早于开始日期。")
274
+ if daily_stop_time <= daily_start_time:
275
+ raise ValueError("每日停止时间必须晚于每日启动时间。")
276
+ return {
277
+ "is_enabled": enabled,
278
+ "start_date": start_date,
279
+ "end_date": end_date,
280
+ "daily_start_time": daily_start_time,
281
+ "daily_stop_time": daily_stop_time,
282
+ }
283
+
284
+
285
+ def _user_owns_course(user_id: int, course_target_id: int) -> bool:
286
+ return any(course["id"] == course_target_id for course in store.list_courses_for_user(user_id))
287
+
288
+
289
+ def _build_user_dashboard_context(user: dict) -> dict:
290
+ return {
291
+ "current_user": user,
292
+ "courses": store.list_courses_for_user(user["id"]),
293
+ "task": store.get_latest_task_for_user(user["id"]),
294
+ "schedule": store.get_user_schedule(user["id"]),
295
+ "recent_logs": store.list_recent_logs(user_id=user["id"], limit=config.logs_page_size),
296
+ }
297
+
298
+
299
+ def _load_admin_users(*, include_courses: bool = True, include_latest_task: bool = True, include_schedule: bool = True) -> list[dict]:
300
+ users = store.list_users()
301
+ for user in users:
302
+ if include_courses:
303
+ user["courses"] = store.list_courses_for_user(user["id"])
304
+ if include_latest_task:
305
+ user["latest_task"] = store.get_latest_task_for_user(user["id"])
306
+ if include_schedule:
307
+ user["schedule"] = store.get_user_schedule(user["id"])
308
+ return users
309
+
310
+
311
+ def _build_admin_base_context(active_page: str, *, page_title: str, page_description: str) -> dict:
312
+ admin_identity = _get_admin_identity()
313
+ return {
314
+ "admin_identity": admin_identity,
315
+ "is_super_admin": admin_identity["is_super_admin"],
316
+ "admin_page": active_page,
317
+ "page_title": page_title,
318
+ "page_description": page_description,
319
+ }
320
+
321
+
322
+ def _build_admin_dashboard_context() -> dict:
323
+ context = _build_admin_base_context(
324
+ "overview",
325
+ page_title="Overview",
326
+ page_description="Review system metrics, recent tasks, and admin-level settings.",
327
+ )
328
+ context.update(
329
+ {
330
+ "stats": store.get_admin_stats(),
331
+ "recent_tasks": store.list_recent_tasks(limit=18),
332
+ "parallel_limit": store.get_parallel_limit(),
333
+ "admins": store.list_admins(),
334
+ "status_url": url_for("admin_status"),
335
+ }
336
+ )
337
+ return context
338
+
339
+
340
+ def _build_admin_users_context() -> dict:
341
+ context = _build_admin_base_context(
342
+ "users",
343
+ page_title="Users",
344
+ page_description="Manage user accounts, course targets, and task operations.",
345
+ )
346
+ context.update(
347
+ {
348
+ "users": _load_admin_users(include_courses=True, include_latest_task=True, include_schedule=True),
349
+ }
350
+ )
351
+ return context
352
+
353
+
354
+ def _build_admin_schedules_context() -> dict:
355
+ context = _build_admin_base_context(
356
+ "schedules",
357
+ page_title="Schedules",
358
+ page_description="Configure per-user auto start and stop windows by date and time.",
359
+ )
360
+ users = _load_admin_users(include_courses=False, include_latest_task=True, include_schedule=True)
361
+ context.update(
362
+ {
363
+ "users": users,
364
+ "stats": store.get_admin_stats(),
365
+ }
366
+ )
367
+ return context
368
+
369
+
370
+ def _build_admin_registration_codes_context() -> dict:
371
+ context = _build_admin_base_context(
372
+ "registration_codes",
373
+ page_title="Codes",
374
+ page_description="Create registration codes and inspect their usage state.",
375
+ )
376
+ context.update(
377
+ {
378
+ "registration_codes": store.list_registration_codes(limit=60),
379
+ "stats": store.get_admin_stats(),
380
+ }
381
+ )
382
+ return context
383
+
384
+
385
+ def _build_admin_logs_context() -> dict:
386
+ recent_logs = store.list_recent_logs(limit=config.logs_page_size)
387
+ context = _build_admin_base_context(
388
+ "logs",
389
+ page_title="Logs",
390
+ page_description="Review live global logs for task execution and troubleshooting.",
391
+ )
392
+ context.update(
393
+ {
394
+ "recent_logs": recent_logs,
395
+ "log_stream_url": url_for("stream_admin_logs", last_id=recent_logs[-1]["id"] if recent_logs else 0),
396
+ }
397
+ )
398
+ return context
399
+
400
+
401
+ def _queue_task_for_user(user: dict, *, requested_by: str, requested_by_role: str) -> tuple[dict, bool]:
402
+ return task_manager.queue_task(user["id"], requested_by=requested_by, requested_by_role=requested_by_role)
403
+
404
+
405
+ def _latest_log_id(logs: list[dict]) -> int:
406
+ if not logs:
407
+ return 0
408
+ return int(logs[-1]["id"])
409
+
410
+
411
+ def _admin_post_redirect(default_endpoint: str):
412
+ target = (request.form.get("next") or request.referrer or "").strip()
413
+ if target:
414
+ parsed = urlparse(target)
415
+ same_host = not parsed.netloc or parsed.netloc == request.host
416
+ if same_host and (parsed.path or "").startswith("/admin"):
417
+ safe_target = parsed.path or url_for(default_endpoint)
418
+ if parsed.query:
419
+ safe_target = f"{safe_target}?{parsed.query}"
420
+ return redirect(safe_target)
421
+ return redirect(url_for(default_endpoint))
422
+
423
+
424
+ @app.get("/")
425
+ def index():
426
+ if session.get("role") == "user":
427
+ return redirect(url_for("dashboard"))
428
+ if session.get("role") == "admin":
429
+ return redirect(url_for("admin_dashboard"))
430
+ return redirect(url_for("login"))
431
+
432
+
433
+ @app.route("/login", methods=["GET", "POST"])
434
+ def login():
435
+ if request.method == "POST":
436
+ student_id = request.form.get("student_id", "").strip()
437
+ password = request.form.get("password", "")
438
+ user = store.get_user_by_student_id(student_id)
439
+ if user is None:
440
+ flash("没有找到该学号对应的账号。如果你有注册码,请先完成注册。", "danger")
441
+ return render_template("login.html")
442
+ if not user["is_active"]:
443
+ flash("该账号已被管理员禁用。", "danger")
444
+ return render_template("login.html")
445
+ try:
446
+ stored_password = secret_box.decrypt(user["password_encrypted"])
447
+ except Exception:
448
+ flash("账号数据损坏,请联系管理员重置密码。", "danger")
449
+ return render_template("login.html")
450
+ if stored_password != password:
451
+ flash("学号或密码不正确。", "danger")
452
+ return render_template("login.html")
453
+
454
+ session.clear()
455
+ session["role"] = "user"
456
+ session["user_id"] = user["id"]
457
+ return redirect(url_for("dashboard"))
458
+
459
+ return render_template("login.html")
460
+
461
+
462
+ @app.route("/register", methods=["GET", "POST"])
463
+ def register():
464
+ if request.method == "POST":
465
+ registration_code = normalize_registration_code(request.form.get("registration_code", ""))
466
+ student_id = request.form.get("student_id", "").strip()
467
+ password = request.form.get("password", "").strip()
468
+ display_name = request.form.get("display_name", "").strip()
469
+
470
+ if not REGISTRATION_CODE_PATTERN.fullmatch(registration_code):
471
+ flash("请输入有效的注册码。", "danger")
472
+ return render_template("register.html")
473
+ if not student_id.isdigit() or not password:
474
+ flash("请填写学号和教务处密码。", "danger")
475
+ return render_template("register.html")
476
+
477
+ try:
478
+ store.register_user_with_code(
479
+ registration_code,
480
+ student_id,
481
+ secret_box.encrypt(password),
482
+ display_name,
483
+ refresh_interval_seconds=config.poll_interval_seconds,
484
+ )
485
+ except ValueError as exc:
486
+ flash(str(exc), "danger")
487
+ return render_template("register.html")
488
+
489
+ flash("注册成功,请使用学号和教务处密码登录。", "success")
490
+ return redirect(url_for("login"))
491
+
492
+ return render_template("register.html")
493
+
494
+
495
+ @app.post("/logout")
496
+ def logout():
497
+ session.clear()
498
+ return redirect(url_for("login"))
499
+
500
+
501
+ @app.route("/admin", methods=["GET", "POST"])
502
+ def admin_login():
503
+ if request.method == "POST":
504
+ username = request.form.get("username", "").strip()
505
+ password = request.form.get("password", "")
506
+ is_super_admin = username == config.super_admin_username and password == config.super_admin_password
507
+ admin_row = store.get_admin_by_username(username)
508
+ is_regular_admin = bool(admin_row and verify_password(admin_row["password_hash"], password))
509
+ if not is_super_admin and not is_regular_admin:
510
+ flash("管理员账号或密码错误。", "danger")
511
+ return render_template("admin_login.html")
512
+
513
+ session.clear()
514
+ session["role"] = "admin"
515
+ session["admin_username"] = username
516
+ session["is_super_admin"] = is_super_admin
517
+ return redirect(url_for("admin_dashboard"))
518
+
519
+ return render_template("admin_login.html")
520
+
521
+
522
+ @app.post("/admin/logout")
523
+ def admin_logout():
524
+ session.clear()
525
  return redirect(url_for("admin_login"))
526
+
527
+ @app.get("/dashboard")
528
+ @_login_required("user")
529
+ def dashboard():
530
+ user = _get_current_user()
531
+ if user is None:
532
+ session.clear()
533
+ return redirect(url_for("login"))
534
+ return render_template("dashboard.html", **_build_user_dashboard_context(user))
535
+
536
+
537
+ @app.post("/dashboard/profile")
538
+ @_login_required("user")
539
+ def update_profile():
540
+ user = _get_current_user()
541
+ if user is None:
542
+ session.clear()
543
+ return redirect(url_for("login"))
544
+
545
+ password = request.form.get("password", "").strip()
546
+ display_name = request.form.get("display_name", "").strip()
547
+ if not password:
548
+ flash("密码不能为空。", "danger")
549
+ return redirect(url_for("dashboard"))
550
+
551
+ store.update_user(user["id"], password_encrypted=secret_box.encrypt(password), display_name=display_name)
552
+ flash("账号信息已更新。", "success")
553
+ return redirect(url_for("dashboard"))
554
+
555
+
556
+ @app.post("/dashboard/settings/runtime")
557
+ @_login_required("user")
558
+ def update_runtime_settings():
559
+ user = _get_current_user()
560
+ if user is None:
561
+ session.clear()
562
+ return redirect(url_for("login"))
563
+
564
+ try:
565
+ refresh_interval_seconds = _parse_refresh_interval(
566
+ request.form.get("refresh_interval_seconds"),
567
+ default=int(user.get("refresh_interval_seconds") or config.poll_interval_seconds),
568
+ )
569
+ except ValueError as exc:
570
+ flash(str(exc), "danger")
571
+ return redirect(url_for("dashboard"))
572
+
573
+ store.update_user(user["id"], refresh_interval_seconds=refresh_interval_seconds)
574
+ flash(f"未命中课程后的刷新间隔已更新为 {refresh_interval_seconds} 秒。", "success")
575
+ return redirect(url_for("dashboard"))
576
+
577
+
578
+ @app.post("/dashboard/courses")
579
+ @_login_required("user")
580
+ def add_course():
581
+ user = _get_current_user()
582
+ if user is None:
583
+ session.clear()
584
+ return redirect(url_for("login"))
585
+
586
+ category = request.form.get("category", "free")
587
+ course_id = request.form.get("course_id", "")
588
+ course_index = request.form.get("course_index", "")
589
+ normalized_target = _validate_course_target(course_id, course_index)
590
+ if normalized_target is None:
591
+ flash("课程号需要使用 1-32 位字母或数字,课序号需要使用 1-8 位字母或数字。", "danger")
592
+ return redirect(url_for("dashboard"))
593
+
594
+ normalized_course_id, normalized_course_index = normalized_target
595
+ store.add_course(user["id"], category, normalized_course_id, normalized_course_index)
596
+ flash("课程已加入任务列表。", "success")
597
+ return redirect(url_for("dashboard"))
598
+
599
+
600
+ @app.post("/dashboard/courses/<int:course_target_id>/delete")
601
+ @_login_required("user")
602
+ def delete_course(course_target_id: int):
603
+ user = _get_current_user()
604
+ if user is None:
605
+ session.clear()
606
+ return redirect(url_for("login"))
607
+ if not _user_owns_course(user["id"], course_target_id):
608
+ flash("不能删除不属于当前账号的课程。", "danger")
609
+ return redirect(url_for("dashboard"))
610
+ store.delete_course(course_target_id)
611
+ flash("课程已移除。", "success")
612
+ return redirect(url_for("dashboard"))
613
+
614
+
615
+ @app.post("/dashboard/task/start")
616
+ @_login_required("user")
617
+ def start_task():
618
+ user = _get_current_user()
619
+ if user is None:
620
+ session.clear()
621
+ return redirect(url_for("login"))
622
+ task, created = _queue_task_for_user(user, requested_by=user["student_id"], requested_by_role="user")
623
+ flash("任务已启动。" if created else f"已有任务处于 {TASK_LABELS.get(task['status'], task['status'])} 状态。", "success" if created else "warning")
624
+ return redirect(url_for("dashboard"))
625
+
626
+
627
+ @app.post("/dashboard/task/stop")
628
+ @_login_required("user")
629
+ def stop_task():
630
+ user = _get_current_user()
631
+ if user is None:
632
+ session.clear()
633
+ return redirect(url_for("login"))
634
+ active_task = store.find_active_task_for_user(user["id"])
635
+ if active_task and task_manager.stop_task(active_task["id"]):
636
+ flash("停止请求已发送。", "success")
637
+ else:
638
+ flash("当前没有可停止的任务。", "warning")
639
+ return redirect(url_for("dashboard"))
640
+
641
+
642
+ @app.get("/admin/dashboard")
643
+ @_login_required("admin")
644
+ def admin_dashboard():
645
+ return render_template("admin_dashboard.html", **_build_admin_dashboard_context())
646
+
647
+
648
+ @app.get("/admin/users")
649
+ @_login_required("admin")
650
+ def admin_users():
651
+ return render_template("admin_users.html", **_build_admin_users_context())
652
+
653
+
654
+ @app.get("/admin/schedules")
655
+ @_login_required("admin")
656
+ def admin_schedules():
657
+ return render_template("admin_schedules.html", **_build_admin_schedules_context())
658
+
659
+
660
+ @app.get("/admin/registration-codes")
661
+ @_login_required("admin")
662
+ def admin_registration_codes():
663
+ return render_template("admin_registration_codes.html", **_build_admin_registration_codes_context())
664
+
665
+
666
+ @app.get("/admin/logs")
667
+ @_login_required("admin")
668
+ def admin_logs():
669
+ return render_template("admin_logs.html", **_build_admin_logs_context())
670
+
671
+
672
+ @app.post("/admin/settings/parallel-limit")
673
+ @_login_required("admin")
674
+ def update_parallel_limit():
675
+ try:
676
+ parallel_limit = max(1, min(8, int(request.form.get("parallel_limit", str(config.default_parallel_limit)))))
677
+ except ValueError:
678
+ flash("并行数必须是 1 到 8 的整数。", "danger")
679
+ return _admin_post_redirect("admin_dashboard")
680
+ store.set_parallel_limit(parallel_limit)
681
+ flash(f"并行数已更新为 {parallel_limit}。", "success")
682
+ return _admin_post_redirect("admin_dashboard")
683
+
684
+
685
+ @app.post("/admin/users")
686
+ @_login_required("admin")
687
+ def create_user():
688
+ student_id = request.form.get("student_id", "").strip()
689
+ password = request.form.get("password", "").strip()
690
+ display_name = request.form.get("display_name", "").strip()
691
+ try:
692
+ refresh_interval_seconds = _parse_refresh_interval(
693
+ request.form.get("refresh_interval_seconds"),
694
+ default=config.poll_interval_seconds,
695
+ )
696
+ except ValueError as exc:
697
+ flash(str(exc), "danger")
698
+ return _admin_post_redirect("admin_users")
699
+ if not student_id.isdigit() or not password:
700
+ flash("请填写有效的学号和密码。", "danger")
701
+ return _admin_post_redirect("admin_users")
702
+ if store.get_user_by_student_id(student_id):
703
+ flash("该学号已经存在。", "warning")
704
+ return _admin_post_redirect("admin_users")
705
+ store.create_user(
706
+ student_id,
707
+ secret_box.encrypt(password),
708
+ display_name,
709
+ refresh_interval_seconds=refresh_interval_seconds,
710
+ )
711
+ flash("用户已创建。", "success")
712
+ return _admin_post_redirect("admin_users")
713
+
714
+
715
+ @app.post("/admin/users/<int:user_id>/update")
716
+ @_login_required("admin")
717
+ def update_user(user_id: int):
718
+ user = store.get_user(user_id)
719
+ if user is None:
720
+ flash("用户不存在。", "danger")
721
+ return _admin_post_redirect("admin_users")
722
+ display_name = request.form.get("display_name", user["display_name"]).strip()
723
+ password = request.form.get("password", "").strip()
724
+ try:
725
+ refresh_interval_seconds = _parse_refresh_interval(
726
+ request.form.get("refresh_interval_seconds"),
727
+ default=int(user.get("refresh_interval_seconds") or config.poll_interval_seconds),
728
+ )
729
+ except ValueError as exc:
730
+ flash(str(exc), "danger")
731
+ return _admin_post_redirect("admin_users")
732
+ if password:
733
+ store.update_user(
734
+ user_id,
735
+ display_name=display_name,
736
+ password_encrypted=secret_box.encrypt(password),
737
+ refresh_interval_seconds=refresh_interval_seconds,
738
+ )
739
+ else:
740
+ store.update_user(user_id, display_name=display_name, refresh_interval_seconds=refresh_interval_seconds)
741
+ flash("用户信息已更新。", "success")
742
+ return _admin_post_redirect("admin_users")
743
+
744
+
745
+ @app.post("/admin/users/<int:user_id>/toggle")
746
+ @_login_required("admin")
747
+ def toggle_user(user_id: int):
748
+ updated = store.toggle_user_active(user_id)
749
+ if updated is None:
750
+ flash("用户不存在。", "danger")
751
+ else:
752
+ flash("用户状态已切换。", "success")
753
+ return _admin_post_redirect("admin_users")
754
+
755
+
756
+ @app.post("/admin/users/<int:user_id>/delete")
757
+ @_login_required("admin")
758
+ def delete_user_by_admin(user_id: int):
759
+ user = store.get_user(user_id)
760
+ if user is None:
761
+ flash("用户不存在。", "danger")
762
+ return _admin_post_redirect("admin_users")
763
+ active_task = store.find_active_task_for_user(user_id)
764
+ if active_task is not None:
765
+ flash("请先停止该用户当前任务,再删除用户。", "danger")
766
+ return _admin_post_redirect("admin_users")
767
+ store.delete_user(user_id)
768
+ flash("用户及其课程、日志、定时设置已删除。", "success")
769
+ return _admin_post_redirect("admin_users")
770
+
771
+
772
+ @app.post("/admin/users/<int:user_id>/schedule")
773
+ @_login_required("admin")
774
+ def update_user_schedule(user_id: int):
775
+ if store.get_user(user_id) is None:
776
+ flash("用户不存在。", "danger")
777
+ return _admin_post_redirect("admin_schedules")
778
+ try:
779
+ schedule_payload = _parse_schedule_form(request.form)
780
+ except ValueError as exc:
781
+ flash(str(exc), "danger")
782
+ return _admin_post_redirect("admin_schedules")
783
+ store.upsert_user_schedule(user_id, **schedule_payload)
784
+ flash("定时启动终止设置已更新。", "success")
785
+ return _admin_post_redirect("admin_schedules")
786
+
787
+
788
+ @app.post("/admin/users/<int:user_id>/courses")
789
+ @_login_required("admin")
790
+ def admin_add_course(user_id: int):
791
+ if store.get_user(user_id) is None:
792
+ flash("用户不存在。", "danger")
793
+ return _admin_post_redirect("admin_users")
794
+ category = request.form.get("category", "free")
795
+ course_id = request.form.get("course_id", "")
796
+ course_index = request.form.get("course_index", "")
797
+ normalized_target = _validate_course_target(course_id, course_index)
798
+ if normalized_target is None:
799
+ flash("课程号需要使用 1-32 位字母或数字,课序号需要使用 1-8 位字母或数字。", "danger")
800
+ return _admin_post_redirect("admin_users")
801
+ normalized_course_id, normalized_course_index = normalized_target
802
+ store.add_course(user_id, category, normalized_course_id, normalized_course_index)
803
+ flash("课程已添加到对应用户。", "success")
804
+ return _admin_post_redirect("admin_users")
805
+
806
+
807
+ @app.post("/admin/courses/<int:course_target_id>/delete")
808
+ @_login_required("admin")
809
+ def admin_delete_course(course_target_id: int):
810
+ store.delete_course(course_target_id)
811
+ flash("课程已删除。", "success")
812
+ return _admin_post_redirect("admin_users")
813
+
814
+
815
+ @app.post("/admin/users/<int:user_id>/task/start")
816
+ @_login_required("admin")
817
+ def admin_start_user_task(user_id: int):
818
+ user = store.get_user(user_id)
819
+ if user is None:
820
+ flash("用户不存在。", "danger")
821
+ return _admin_post_redirect("admin_users")
822
+ admin_identity = _get_admin_identity()
823
+ task, created = _queue_task_for_user(user, requested_by=admin_identity["username"], requested_by_role="admin")
824
+ flash("任务已加入队列。" if created else f"该用户已有任务处于 {TASK_LABELS.get(task['status'], task['status'])} 状态。", "success" if created else "warning")
825
+ return _admin_post_redirect("admin_users")
826
+
827
+
828
+ @app.post("/admin/users/<int:user_id>/task/stop")
829
+ @_login_required("admin")
830
+ def admin_stop_user_task(user_id: int):
831
+ active_task = store.find_active_task_for_user(user_id)
832
+ if active_task and task_manager.stop_task(active_task["id"]):
833
+ flash("已发送停止请求。", "success")
834
+ else:
835
+ flash("当前没有可停止任务。", "warning")
836
+ return _admin_post_redirect("admin_users")
837
+
838
+
839
+ @app.post("/admin/admins")
840
+ @_login_required("admin")
841
+ def create_admin():
842
+ if not session.get("is_super_admin", False):
843
+ flash("只有超级管理员可以新增管理员。", "danger")
844
+ return _admin_post_redirect("admin_dashboard")
845
+ username = request.form.get("username", "").strip()
846
+ password = request.form.get("password", "").strip()
847
+ if not username or not password:
848
+ flash("请填写管理员账号和密码。", "danger")
849
+ return _admin_post_redirect("admin_dashboard")
850
+ if username == config.super_admin_username or store.get_admin_by_username(username):
851
+ flash("该管理员账号已存在。", "warning")
852
+ return _admin_post_redirect("admin_dashboard")
853
+ store.create_admin(username, hash_password(password))
854
+ flash("管理员已创建。", "success")
855
+ return _admin_post_redirect("admin_dashboard")
856
+
857
+
858
+ @app.post("/admin/registration-codes")
859
+ @_login_required("admin")
860
+ def create_registration_code():
861
+ note = request.form.get("note", "").strip()
862
+ try:
863
+ max_uses = _parse_registration_code_max_uses(request.form.get("max_uses"))
864
+ except ValueError as exc:
865
+ flash(str(exc), "danger")
866
+ return _admin_post_redirect("admin_registration_codes")
867
+ admin_identity = _get_admin_identity()
868
+ created = store.create_registration_code(created_by=admin_identity["username"], note=note, max_uses=max_uses)
869
+ flash(f"注册码已创建:{created['code']}", "success")
870
+ return _admin_post_redirect("admin_registration_codes")
871
+
872
+
873
+ @app.post("/admin/registration-codes/<int:registration_code_id>/toggle")
874
+ @_login_required("admin")
875
+ def toggle_registration_code(registration_code_id: int):
876
+ updated = store.toggle_registration_code_active(registration_code_id)
877
+ if updated is None:
878
+ flash("注册码不存在。", "danger")
879
+ else:
880
+ flash("注册码状态已更新。", "success")
881
+ return _admin_post_redirect("admin_registration_codes")
882
+
883
+ @app.get("/api/user/status")
884
+ @_login_required("user")
885
+ def user_status():
886
+ user = _get_current_user()
887
+ if user is None:
888
+ return jsonify({"ok": False}), 401
889
+ task = store.get_latest_task_for_user(user["id"])
890
+ return jsonify(
891
+ {
892
+ "ok": True,
893
+ "task": task,
894
+ "courses": store.list_courses_for_user(user["id"]),
895
+ "user": {
896
+ "refresh_interval_seconds": int(user.get("refresh_interval_seconds") or config.poll_interval_seconds),
897
+ },
898
+ }
899
+ )
900
+
901
+
902
+ @app.get("/api/admin/status")
903
+ @_login_required("admin")
904
+ def admin_status():
905
+ return jsonify(
906
+ {
907
+ "ok": True,
908
+ "stats": store.get_admin_stats(),
909
+ "parallel_limit": store.get_parallel_limit(),
910
+ "recent_tasks": store.list_recent_tasks(limit=12),
911
+ }
912
+ )
913
+
914
+
915
+ @app.get("/api/user/logs/stream")
916
+ @_login_required("user")
917
+ def stream_user_logs():
918
+ user = _get_current_user()
919
+ if user is None:
920
+ return jsonify({"ok": False}), 401
921
+ last_id = int(request.args.get("last_id", _latest_log_id(store.list_recent_logs(user_id=user["id"], limit=1))))
922
+
923
+ @stream_with_context
924
+ def generate():
925
+ current_last_id = last_id
926
+ while True:
927
+ logs = store.list_logs_after(current_last_id, user_id=user["id"], limit=60)
928
+ if logs:
929
+ for log in logs:
930
+ current_last_id = int(log["id"])
931
+ yield f"id: {log['id']}\ndata: {json.dumps(log, ensure_ascii=False)}\n\n"
932
+ else:
933
+ yield ": keep-alive\n\n"
934
+ time.sleep(1)
935
+
936
+ response = Response(generate(), mimetype="text/event-stream")
937
+ response.headers["Cache-Control"] = "no-cache"
938
+ response.headers["X-Accel-Buffering"] = "no"
939
+ return response
940
+
941
+
942
+ @app.get("/api/admin/logs/stream")
943
+ @_login_required("admin")
944
+ def stream_admin_logs():
945
+ last_id = int(request.args.get("last_id", _latest_log_id(store.list_recent_logs(limit=1))))
946
+
947
+ @stream_with_context
948
+ def generate():
949
+ current_last_id = last_id
950
+ while True:
951
+ logs = store.list_logs_after(current_last_id, limit=80)
952
+ if logs:
953
+ for log in logs:
954
+ current_last_id = int(log["id"])
955
+ yield f"id: {log['id']}\ndata: {json.dumps(log, ensure_ascii=False)}\n\n"
956
+ else:
957
+ yield ": keep-alive\n\n"
958
+ time.sleep(1)
959
+
960
+ response = Response(generate(), mimetype="text/event-stream")
961
+ response.headers["Cache-Control"] = "no-cache"
962
+ response.headers["X-Accel-Buffering"] = "no"
963
  return response
static/style.css CHANGED
@@ -1,704 +1,760 @@
1
- :root {
2
- --bg: #07131d;
3
- --bg-2: #0d2030;
4
- --panel: rgba(10, 24, 36, 0.82);
5
- --panel-strong: rgba(8, 18, 28, 0.94);
6
- --line: rgba(151, 204, 213, 0.18);
7
- --text: #eef6f6;
8
- --muted: #9ab7bd;
9
- --primary: #2ed3ad;
10
- --primary-deep: #109a88;
11
- --secondary: #ffb648;
12
- --danger: #ff6f61;
13
- --shadow: 0 24px 70px rgba(0, 0, 0, 0.36);
14
- --radius-lg: 28px;
15
- --radius-md: 20px;
16
- --radius-sm: 14px;
17
- --font-display: "Space Grotesk", sans-serif;
18
- --font-body: "Noto Sans SC", sans-serif;
19
- }
20
-
21
- * {
22
- box-sizing: border-box;
23
- }
24
-
25
- html {
26
- scroll-behavior: smooth;
27
- }
28
-
29
- body {
30
- margin: 0;
31
- min-height: 100vh;
32
- font-family: var(--font-body);
33
- color: var(--text);
34
- background:
35
- radial-gradient(circle at top left, rgba(20, 110, 116, 0.34), transparent 34%),
36
- radial-gradient(circle at top right, rgba(255, 182, 72, 0.16), transparent 28%),
37
- linear-gradient(135deg, #041019 0%, #07131d 36%, #0d2030 100%);
38
- }
39
-
40
- button,
41
- input,
42
- select {
43
- font: inherit;
44
- }
45
-
46
- code {
47
- padding: 0.15rem 0.45rem;
48
- border-radius: 999px;
49
- background: rgba(255, 255, 255, 0.08);
50
- color: #fff5dd;
51
- }
52
-
53
- .app-body {
54
- position: relative;
55
- overflow-x: hidden;
56
- }
57
-
58
- .bg-orb {
59
- position: fixed;
60
- width: 28rem;
61
- height: 28rem;
62
- border-radius: 50%;
63
- filter: blur(20px);
64
- opacity: 0.42;
65
- pointer-events: none;
66
- animation: drift 14s ease-in-out infinite;
67
- }
68
-
69
- .bg-orb-a {
70
- top: -10rem;
71
- left: -6rem;
72
- background: radial-gradient(circle, rgba(46, 211, 173, 0.48), transparent 68%);
73
- }
74
-
75
- .bg-orb-b {
76
- right: -8rem;
77
- bottom: -10rem;
78
- background: radial-gradient(circle, rgba(255, 182, 72, 0.32), transparent 68%);
79
- animation-delay: -6s;
80
- }
81
-
82
- .bg-grid {
83
- position: fixed;
84
- inset: 0;
85
- background-image:
86
- linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
87
- linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
88
- background-size: 42px 42px;
89
- mask-image: radial-gradient(circle at center, black 42%, transparent 100%);
90
- pointer-events: none;
91
- }
92
-
93
- .page-shell {
94
- position: relative;
95
- z-index: 1;
96
- width: min(1240px, calc(100% - 2rem));
97
- margin: 0 auto;
98
- padding: 2rem 0 3rem;
99
- }
100
-
101
- .flash-stack {
102
- display: grid;
103
- gap: 0.75rem;
104
- margin-bottom: 1rem;
105
- }
106
-
107
- .flash {
108
- border: 1px solid rgba(255, 255, 255, 0.12);
109
- border-radius: var(--radius-sm);
110
- padding: 0.9rem 1rem;
111
- backdrop-filter: blur(10px);
112
- box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
113
- }
114
-
115
- .flash-success {
116
- background: rgba(46, 211, 173, 0.16);
117
- }
118
-
119
- .flash-warning {
120
- background: rgba(255, 182, 72, 0.16);
121
- }
122
-
123
- .flash-danger {
124
- background: rgba(255, 111, 97, 0.16);
125
- }
126
-
127
- .auth-layout,
128
- .dashboard-shell {
129
- display: grid;
130
- gap: 1.4rem;
131
- }
132
-
133
- .auth-layout {
134
- min-height: calc(100vh - 7rem);
135
- align-items: center;
136
- grid-template-columns: 1.15fr 0.9fr;
137
- }
138
-
139
- .hero-panel,
140
- .auth-card,
141
- .card,
142
- .metric-card,
143
- .user-card,
144
- .empty-state-card {
145
- position: relative;
146
- border: 1px solid var(--line);
147
- border-radius: var(--radius-lg);
148
- background: var(--panel);
149
- backdrop-filter: blur(18px);
150
- box-shadow: var(--shadow);
151
- }
152
-
153
- .hero-panel,
154
- .auth-card,
155
- .card,
156
- .empty-state-card {
157
- padding: 1.7rem;
158
- }
159
-
160
- .hero-panel {
161
- overflow: hidden;
162
- }
163
-
164
- .hero-panel::after,
165
- .card::after,
166
- .auth-card::after {
167
- content: "";
168
- position: absolute;
169
- inset: 0;
170
- border-radius: inherit;
171
- background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), transparent 22%, transparent 78%, rgba(255, 255, 255, 0.05));
172
- pointer-events: none;
173
- }
174
-
175
- .eyebrow,
176
- .kicker {
177
- display: inline-flex;
178
- align-items: center;
179
- gap: 0.5rem;
180
- font-family: var(--font-display);
181
- letter-spacing: 0.12em;
182
- text-transform: uppercase;
183
- font-size: 0.75rem;
184
- color: #9fe8da;
185
- }
186
-
187
- .hero-panel h1,
188
- .topbar h1,
189
- .card-head h2,
190
- .auth-card h2 {
191
- margin: 0.5rem 0 0;
192
- font-family: var(--font-display);
193
- line-height: 1.05;
194
- }
195
-
196
- .hero-panel h1 {
197
- max-width: 13ch;
198
- font-size: clamp(2.8rem, 6vw, 5rem);
199
- }
200
-
201
- .hero-panel p,
202
- .card-head p,
203
- .topbar p,
204
- .auth-card p,
205
- .metric-card small,
206
- .auth-footnote {
207
- color: var(--muted);
208
- line-height: 1.7;
209
- }
210
-
211
- .hero-metrics {
212
- display: grid;
213
- grid-template-columns: repeat(3, minmax(0, 1fr));
214
- gap: 1rem;
215
- margin-top: 2rem;
216
- }
217
-
218
- .hero-metrics article {
219
- padding: 1rem;
220
- border-radius: var(--radius-md);
221
- background: rgba(255, 255, 255, 0.05);
222
- border: 1px solid rgba(255, 255, 255, 0.08);
223
- }
224
-
225
- .hero-metrics strong,
226
- .metric-card strong {
227
- display: block;
228
- font-family: var(--font-display);
229
- font-size: 1.2rem;
230
- margin-bottom: 0.25rem;
231
- }
232
-
233
- .auth-card {
234
- max-width: 520px;
235
- justify-self: end;
236
- }
237
-
238
- .card-head {
239
- display: grid;
240
- gap: 0.35rem;
241
- margin-bottom: 1.2rem;
242
- }
243
-
244
- .card-head.compact {
245
- margin-bottom: 1rem;
246
- }
247
-
248
- .card-head.split,
249
- .topbar {
250
- display: flex;
251
- align-items: flex-start;
252
- justify-content: space-between;
253
- gap: 1rem;
254
- }
255
-
256
- .form-grid {
257
- display: grid;
258
- gap: 1rem;
259
- }
260
-
261
- .form-grid-compact {
262
- grid-template-columns: repeat(2, minmax(0, 1fr));
263
- }
264
-
265
- .slim-form {
266
- margin-top: 1rem;
267
- }
268
-
269
- .field {
270
- display: grid;
271
- gap: 0.45rem;
272
- }
273
-
274
- .field span {
275
- color: #d4ece6;
276
- font-size: 0.92rem;
277
- }
278
-
279
- .field input,
280
- .field select {
281
- width: 100%;
282
- border: 1px solid rgba(255, 255, 255, 0.08);
283
- border-radius: 16px;
284
- padding: 0.95rem 1rem;
285
- background: rgba(5, 14, 22, 0.66);
286
- color: var(--text);
287
- outline: none;
288
- transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
289
- }
290
-
291
- .field input:focus,
292
- .field select:focus {
293
- border-color: rgba(46, 211, 173, 0.85);
294
- box-shadow: 0 0 0 4px rgba(46, 211, 173, 0.12);
295
- transform: translateY(-1px);
296
- }
297
-
298
- .span-2 {
299
- grid-column: span 2;
300
- }
301
-
302
- .btn {
303
- display: inline-flex;
304
- align-items: center;
305
- justify-content: center;
306
- gap: 0.55rem;
307
- min-height: 48px;
308
- border: 0;
309
- border-radius: 999px;
310
- padding: 0 1.2rem;
311
- cursor: pointer;
312
- transition: transform 180ms ease, box-shadow 180ms ease, opacity 180ms ease;
313
- text-decoration: none;
314
- }
315
-
316
- .btn:hover {
317
- transform: translateY(-2px);
318
- box-shadow: 0 14px 30px rgba(0, 0, 0, 0.24);
319
- }
320
-
321
- .btn-primary {
322
- background: linear-gradient(135deg, var(--primary) 0%, #4be9c3 100%);
323
- color: #052119;
324
- font-weight: 800;
325
- }
326
-
327
- .btn-secondary {
328
- background: linear-gradient(135deg, var(--secondary) 0%, #ffd07a 100%);
329
- color: #24160a;
330
- font-weight: 800;
331
- }
332
-
333
- .btn-ghost {
334
- background: rgba(255, 255, 255, 0.06);
335
- color: var(--text);
336
- border: 1px solid rgba(255, 255, 255, 0.09);
337
- }
338
-
339
- .btn-ghost.danger {
340
- color: #ffd0ca;
341
- border-color: rgba(255, 111, 97, 0.34);
342
- }
343
-
344
- .btn-lg {
345
- min-height: 54px;
346
- }
347
-
348
- .auth-footnote {
349
- margin-top: 1rem;
350
- padding-top: 1rem;
351
- border-top: 1px solid rgba(255, 255, 255, 0.08);
352
- }
353
-
354
- .topbar {
355
- padding: 1rem 0 0.3rem;
356
- }
357
-
358
- .metric-grid {
359
- display: grid;
360
- grid-template-columns: repeat(4, minmax(0, 1fr));
361
- gap: 1rem;
362
- }
363
-
364
- .metric-card {
365
- padding: 1.25rem;
366
- }
367
-
368
- .metric-card span {
369
- color: var(--muted);
370
- font-size: 0.92rem;
371
- }
372
-
373
- .metric-card strong {
374
- font-size: clamp(1.4rem, 4vw, 2.2rem);
375
- margin: 0.35rem 0;
376
- }
377
-
378
- .content-grid {
379
- display: grid;
380
- gap: 1rem;
381
- }
382
-
383
- .dashboard-grid {
384
- grid-template-columns: repeat(2, minmax(0, 1fr));
385
- }
386
-
387
- .admin-grid {
388
- grid-template-columns: repeat(2, minmax(0, 1fr));
389
- }
390
-
391
- .status-strip,
392
- .button-row,
393
- .chip-row,
394
- .course-chip-row {
395
- display: flex;
396
- align-items: center;
397
- gap: 0.7rem;
398
- flex-wrap: wrap;
399
- }
400
-
401
- .wrap-row {
402
- margin-top: 1rem;
403
- }
404
-
405
- .status-strip {
406
- margin-bottom: 1rem;
407
- color: var(--muted);
408
- }
409
-
410
- .status-pill,
411
- .chip,
412
- .live-dot {
413
- display: inline-flex;
414
- align-items: center;
415
- justify-content: center;
416
- border-radius: 999px;
417
- padding: 0.45rem 0.9rem;
418
- font-size: 0.84rem;
419
- border: 1px solid rgba(255, 255, 255, 0.1);
420
- }
421
-
422
- .status-idle,
423
- .chip {
424
- background: rgba(255, 255, 255, 0.05);
425
- }
426
-
427
- .status-running,
428
- .status-completed,
429
- .highlight,
430
- .live-dot {
431
- background: rgba(46, 211, 173, 0.14);
432
- color: #96f2dd;
433
- }
434
-
435
- .status-pending,
436
- .status-cancel_requested {
437
- background: rgba(255, 182, 72, 0.14);
438
- color: #ffd48d;
439
- }
440
-
441
- .status-stopped,
442
- .status-failed,
443
- .danger {
444
- background: rgba(255, 111, 97, 0.14);
445
- color: #ffcec7;
446
- }
447
-
448
- .live-dot {
449
- position: relative;
450
- gap: 0.4rem;
451
- font-family: var(--font-display);
452
- letter-spacing: 0.08em;
453
- }
454
-
455
- .live-dot::before {
456
- content: "";
457
- width: 8px;
458
- height: 8px;
459
- border-radius: 50%;
460
- background: currentColor;
461
- box-shadow: 0 0 0 0 rgba(150, 242, 221, 0.65);
462
- animation: pulse 2.1s infinite;
463
- }
464
-
465
- .course-table-wrap {
466
- overflow-x: auto;
467
- }
468
-
469
- .data-table {
470
- width: 100%;
471
- border-collapse: collapse;
472
- }
473
-
474
- .data-table th,
475
- .data-table td {
476
- text-align: left;
477
- padding: 0.95rem 0.85rem;
478
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
479
- }
480
-
481
- .data-table th {
482
- color: #b8d4d4;
483
- font-size: 0.9rem;
484
- }
485
-
486
- .inline-action {
487
- border: 0;
488
- background: transparent;
489
- color: #ffd0ca;
490
- cursor: pointer;
491
- padding: 0;
492
- }
493
-
494
- .empty-cell,
495
- .empty-mini,
496
- .empty-state-card {
497
- color: var(--muted);
498
- }
499
-
500
- .log-console {
501
- min-height: 360px;
502
- max-height: 540px;
503
- overflow: auto;
504
- padding: 1rem;
505
- border-radius: 22px;
506
- background: rgba(4, 10, 16, 0.92);
507
- border: 1px solid rgba(255, 255, 255, 0.06);
508
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
509
- }
510
-
511
- .log-line {
512
- display: grid;
513
- gap: 0.25rem;
514
- padding: 0.8rem 0;
515
- border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
516
- font-size: 0.92rem;
517
- }
518
-
519
- .log-line:last-child {
520
- border-bottom: 0;
521
- }
522
-
523
- .log-meta {
524
- color: #7ea4aa;
525
- font-size: 0.78rem;
526
- }
527
-
528
- .level-error {
529
- color: #ffb5ac;
530
- }
531
-
532
- .level-warning {
533
- color: #ffd59a;
534
- }
535
-
536
- .level-info {
537
- color: #d8ece9;
538
- }
539
-
540
- .level-success {
541
- color: #97f4dd;
542
- }
543
-
544
- .muted {
545
- color: var(--muted);
546
- }
547
-
548
- .user-card-grid {
549
- display: grid;
550
- gap: 1rem;
551
- }
552
-
553
- .user-card {
554
- padding: 1.25rem;
555
- }
556
-
557
- .user-card-head {
558
- display: flex;
559
- align-items: flex-start;
560
- justify-content: space-between;
561
- gap: 1rem;
562
- }
563
-
564
- .user-card h3 {
565
- margin: 0;
566
- font-family: var(--font-display);
567
- }
568
-
569
- .user-card p {
570
- margin: 0.35rem 0 0;
571
- color: var(--muted);
572
- }
573
-
574
- .course-list {
575
- display: grid;
576
- gap: 0.65rem;
577
- margin-top: 1rem;
578
- }
579
-
580
- .course-chip-row {
581
- justify-content: space-between;
582
- padding: 0.8rem 0.95rem;
583
- border-radius: 16px;
584
- background: rgba(255, 255, 255, 0.05);
585
- }
586
-
587
- .chip-row.tight {
588
- margin-top: 0.85rem;
589
- }
590
-
591
- .accent-amber {
592
- box-shadow: 0 24px 70px rgba(255, 182, 72, 0.16);
593
- }
594
-
595
- .reveal-up {
596
- opacity: 0;
597
- transform: translateY(22px);
598
- animation: revealUp 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
599
- }
600
-
601
- .delay-1 {
602
- animation-delay: 0.08s;
603
- }
604
-
605
- .delay-2 {
606
- animation-delay: 0.16s;
607
- }
608
-
609
- .delay-3 {
610
- animation-delay: 0.24s;
611
- }
612
-
613
- .delay-4 {
614
- animation-delay: 0.32s;
615
- }
616
-
617
- @keyframes revealUp {
618
- to {
619
- opacity: 1;
620
- transform: translateY(0);
621
- }
622
- }
623
-
624
- @keyframes drift {
625
- 0%,
626
- 100% {
627
- transform: translate3d(0, 0, 0) scale(1);
628
- }
629
- 50% {
630
- transform: translate3d(16px, -18px, 0) scale(1.05);
631
- }
632
- }
633
-
634
- @keyframes pulse {
635
- 0% {
636
- box-shadow: 0 0 0 0 rgba(150, 242, 221, 0.65);
637
- }
638
- 70% {
639
- box-shadow: 0 0 0 14px rgba(150, 242, 221, 0);
640
- }
641
- 100% {
642
- box-shadow: 0 0 0 0 rgba(150, 242, 221, 0);
643
- }
644
- }
645
-
646
- @media (max-width: 1100px) {
647
- .auth-layout,
648
- .dashboard-grid,
649
- .admin-grid,
650
- .metric-grid,
651
- .hero-metrics {
652
- grid-template-columns: 1fr;
653
- }
654
-
655
- .auth-card {
656
- justify-self: stretch;
657
- max-width: none;
658
- }
659
-
660
- .span-2 {
661
- grid-column: auto;
662
- }
663
- }
664
-
665
- @media (max-width: 760px) {
666
- .page-shell {
667
- width: min(100% - 1rem, 100%);
668
- padding-top: 1rem;
669
- padding-bottom: 2rem;
670
- }
671
-
672
- .hero-panel,
673
- .auth-card,
674
- .card,
675
- .user-card,
676
- .empty-state-card {
677
- padding: 1.2rem;
678
- border-radius: 22px;
679
- }
680
-
681
- .card-head.split,
682
- .topbar,
683
- .user-card-head {
684
- flex-direction: column;
685
- }
686
-
687
- .form-grid-compact {
688
- grid-template-columns: 1fr;
689
- }
690
-
691
- .button-row form,
692
- .button-row .btn,
693
- .btn {
694
- width: 100%;
695
- }
696
-
697
- .button-row {
698
- width: 100%;
699
- }
700
-
701
- .log-console {
702
- min-height: 280px;
703
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  }
 
1
+ :root {
2
+ --bg: #07131d;
3
+ --bg-2: #0d2030;
4
+ --panel: rgba(10, 24, 36, 0.82);
5
+ --panel-strong: rgba(8, 18, 28, 0.94);
6
+ --line: rgba(151, 204, 213, 0.18);
7
+ --text: #eef6f6;
8
+ --muted: #9ab7bd;
9
+ --primary: #2ed3ad;
10
+ --primary-deep: #109a88;
11
+ --secondary: #ffb648;
12
+ --danger: #ff6f61;
13
+ --shadow: 0 24px 70px rgba(0, 0, 0, 0.36);
14
+ --radius-lg: 28px;
15
+ --radius-md: 20px;
16
+ --radius-sm: 14px;
17
+ --font-display: "Space Grotesk", sans-serif;
18
+ --font-body: "Noto Sans SC", sans-serif;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ html {
26
+ scroll-behavior: smooth;
27
+ }
28
+
29
+ body {
30
+ margin: 0;
31
+ min-height: 100vh;
32
+ font-family: var(--font-body);
33
+ color: var(--text);
34
+ background:
35
+ radial-gradient(circle at top left, rgba(20, 110, 116, 0.34), transparent 34%),
36
+ radial-gradient(circle at top right, rgba(255, 182, 72, 0.16), transparent 28%),
37
+ linear-gradient(135deg, #041019 0%, #07131d 36%, #0d2030 100%);
38
+ }
39
+
40
+ button,
41
+ input,
42
+ select {
43
+ font: inherit;
44
+ }
45
+
46
+ code {
47
+ padding: 0.15rem 0.45rem;
48
+ border-radius: 999px;
49
+ background: rgba(255, 255, 255, 0.08);
50
+ color: #fff5dd;
51
+ }
52
+
53
+ .app-body {
54
+ position: relative;
55
+ overflow-x: hidden;
56
+ }
57
+
58
+ .bg-orb {
59
+ position: fixed;
60
+ width: 28rem;
61
+ height: 28rem;
62
+ border-radius: 50%;
63
+ filter: blur(20px);
64
+ opacity: 0.42;
65
+ pointer-events: none;
66
+ animation: drift 14s ease-in-out infinite;
67
+ }
68
+
69
+ .bg-orb-a {
70
+ top: -10rem;
71
+ left: -6rem;
72
+ background: radial-gradient(circle, rgba(46, 211, 173, 0.48), transparent 68%);
73
+ }
74
+
75
+ .bg-orb-b {
76
+ right: -8rem;
77
+ bottom: -10rem;
78
+ background: radial-gradient(circle, rgba(255, 182, 72, 0.32), transparent 68%);
79
+ animation-delay: -6s;
80
+ }
81
+
82
+ .bg-grid {
83
+ position: fixed;
84
+ inset: 0;
85
+ background-image:
86
+ linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
87
+ linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
88
+ background-size: 42px 42px;
89
+ mask-image: radial-gradient(circle at center, black 42%, transparent 100%);
90
+ pointer-events: none;
91
+ }
92
+
93
+ .page-shell {
94
+ position: relative;
95
+ z-index: 1;
96
+ width: min(1240px, calc(100% - 2rem));
97
+ margin: 0 auto;
98
+ padding: 2rem 0 3rem;
99
+ }
100
+
101
+ .flash-stack {
102
+ display: grid;
103
+ gap: 0.75rem;
104
+ margin-bottom: 1rem;
105
+ }
106
+
107
+ .flash {
108
+ border: 1px solid rgba(255, 255, 255, 0.12);
109
+ border-radius: var(--radius-sm);
110
+ padding: 0.9rem 1rem;
111
+ backdrop-filter: blur(10px);
112
+ box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
113
+ }
114
+
115
+ .flash-success {
116
+ background: rgba(46, 211, 173, 0.16);
117
+ }
118
+
119
+ .flash-warning {
120
+ background: rgba(255, 182, 72, 0.16);
121
+ }
122
+
123
+ .flash-danger {
124
+ background: rgba(255, 111, 97, 0.16);
125
+ }
126
+
127
+ .auth-layout,
128
+ .dashboard-shell {
129
+ display: grid;
130
+ gap: 1.4rem;
131
+ }
132
+
133
+ .auth-layout {
134
+ min-height: calc(100vh - 7rem);
135
+ align-items: center;
136
+ grid-template-columns: 1.15fr 0.9fr;
137
+ }
138
+
139
+ .hero-panel,
140
+ .auth-card,
141
+ .card,
142
+ .metric-card,
143
+ .user-card,
144
+ .empty-state-card {
145
+ position: relative;
146
+ border: 1px solid var(--line);
147
+ border-radius: var(--radius-lg);
148
+ background: var(--panel);
149
+ backdrop-filter: blur(18px);
150
+ box-shadow: var(--shadow);
151
+ }
152
+
153
+ .hero-panel,
154
+ .auth-card,
155
+ .card,
156
+ .empty-state-card {
157
+ padding: 1.7rem;
158
+ }
159
+
160
+ .hero-panel {
161
+ overflow: hidden;
162
+ }
163
+
164
+ .hero-panel::after,
165
+ .card::after,
166
+ .auth-card::after {
167
+ content: "";
168
+ position: absolute;
169
+ inset: 0;
170
+ border-radius: inherit;
171
+ background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), transparent 22%, transparent 78%, rgba(255, 255, 255, 0.05));
172
+ pointer-events: none;
173
+ }
174
+
175
+ .eyebrow,
176
+ .kicker {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ gap: 0.5rem;
180
+ font-family: var(--font-display);
181
+ letter-spacing: 0.12em;
182
+ text-transform: uppercase;
183
+ font-size: 0.75rem;
184
+ color: #9fe8da;
185
+ }
186
+
187
+ .hero-panel h1,
188
+ .topbar h1,
189
+ .card-head h2,
190
+ .auth-card h2 {
191
+ margin: 0.5rem 0 0;
192
+ font-family: var(--font-display);
193
+ line-height: 1.05;
194
+ }
195
+
196
+ .hero-panel h1 {
197
+ max-width: 13ch;
198
+ font-size: clamp(2.8rem, 6vw, 5rem);
199
+ }
200
+
201
+ .hero-panel p,
202
+ .card-head p,
203
+ .topbar p,
204
+ .auth-card p,
205
+ .metric-card small,
206
+ .auth-footnote {
207
+ color: var(--muted);
208
+ line-height: 1.7;
209
+ }
210
+
211
+ .hero-metrics {
212
+ display: grid;
213
+ grid-template-columns: repeat(3, minmax(0, 1fr));
214
+ gap: 1rem;
215
+ margin-top: 2rem;
216
+ }
217
+
218
+ .hero-metrics article {
219
+ padding: 1rem;
220
+ border-radius: var(--radius-md);
221
+ background: rgba(255, 255, 255, 0.05);
222
+ border: 1px solid rgba(255, 255, 255, 0.08);
223
+ }
224
+
225
+ .hero-metrics strong,
226
+ .metric-card strong {
227
+ display: block;
228
+ font-family: var(--font-display);
229
+ font-size: 1.2rem;
230
+ margin-bottom: 0.25rem;
231
+ }
232
+
233
+ .auth-card {
234
+ max-width: 520px;
235
+ justify-self: end;
236
+ }
237
+
238
+ .card-head {
239
+ display: grid;
240
+ gap: 0.35rem;
241
+ margin-bottom: 1.2rem;
242
+ }
243
+
244
+ .card-head.compact {
245
+ margin-bottom: 1rem;
246
+ }
247
+
248
+ .card-head.split,
249
+ .topbar {
250
+ display: flex;
251
+ align-items: flex-start;
252
+ justify-content: space-between;
253
+ gap: 1rem;
254
+ }
255
+
256
+ .form-grid {
257
+ display: grid;
258
+ gap: 1rem;
259
+ }
260
+
261
+ .form-grid-compact {
262
+ grid-template-columns: repeat(2, minmax(0, 1fr));
263
+ }
264
+
265
+ .slim-form {
266
+ margin-top: 1rem;
267
+ }
268
+
269
+ .field {
270
+ display: grid;
271
+ gap: 0.45rem;
272
+ }
273
+
274
+ .field span {
275
+ color: #d4ece6;
276
+ font-size: 0.92rem;
277
+ }
278
+
279
+ .field input,
280
+ .field select {
281
+ width: 100%;
282
+ border: 1px solid rgba(255, 255, 255, 0.08);
283
+ border-radius: 16px;
284
+ padding: 0.95rem 1rem;
285
+ background: rgba(5, 14, 22, 0.66);
286
+ color: var(--text);
287
+ outline: none;
288
+ transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
289
+ }
290
+
291
+ .field input:focus,
292
+ .field select:focus {
293
+ border-color: rgba(46, 211, 173, 0.85);
294
+ box-shadow: 0 0 0 4px rgba(46, 211, 173, 0.12);
295
+ transform: translateY(-1px);
296
+ }
297
+
298
+ .span-2 {
299
+ grid-column: span 2;
300
+ }
301
+
302
+ .btn {
303
+ display: inline-flex;
304
+ align-items: center;
305
+ justify-content: center;
306
+ gap: 0.55rem;
307
+ min-height: 48px;
308
+ border: 0;
309
+ border-radius: 999px;
310
+ padding: 0 1.2rem;
311
+ cursor: pointer;
312
+ transition: transform 180ms ease, box-shadow 180ms ease, opacity 180ms ease;
313
+ text-decoration: none;
314
+ }
315
+
316
+ .btn:hover {
317
+ transform: translateY(-2px);
318
+ box-shadow: 0 14px 30px rgba(0, 0, 0, 0.24);
319
+ }
320
+
321
+ .btn-primary {
322
+ background: linear-gradient(135deg, var(--primary) 0%, #4be9c3 100%);
323
+ color: #052119;
324
+ font-weight: 800;
325
+ }
326
+
327
+ .btn-secondary {
328
+ background: linear-gradient(135deg, var(--secondary) 0%, #ffd07a 100%);
329
+ color: #24160a;
330
+ font-weight: 800;
331
+ }
332
+
333
+ .btn-ghost {
334
+ background: rgba(255, 255, 255, 0.06);
335
+ color: var(--text);
336
+ border: 1px solid rgba(255, 255, 255, 0.09);
337
+ }
338
+
339
+ .btn-ghost.danger {
340
+ color: #ffd0ca;
341
+ border-color: rgba(255, 111, 97, 0.34);
342
+ }
343
+
344
+ .btn-lg {
345
+ min-height: 54px;
346
+ }
347
+
348
+ .auth-footnote {
349
+ margin-top: 1rem;
350
+ padding-top: 1rem;
351
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
352
+ }
353
+
354
+ .topbar {
355
+ padding: 1rem 0 0.3rem;
356
+ }
357
+
358
+ .admin-nav {
359
+ display: flex;
360
+ flex-wrap: wrap;
361
+ gap: 0.75rem;
362
+ }
363
+
364
+ .admin-nav-link {
365
+ display: inline-flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ min-height: 44px;
369
+ padding: 0 1rem;
370
+ border-radius: 999px;
371
+ border: 1px solid rgba(255, 255, 255, 0.08);
372
+ background: rgba(255, 255, 255, 0.04);
373
+ color: var(--muted);
374
+ text-decoration: none;
375
+ transition: transform 180ms ease, background 180ms ease, color 180ms ease, border-color 180ms ease;
376
+ }
377
+
378
+ .admin-nav-link:hover {
379
+ transform: translateY(-1px);
380
+ color: var(--text);
381
+ border-color: rgba(255, 255, 255, 0.16);
382
+ }
383
+
384
+ .admin-nav-link.active {
385
+ background: rgba(46, 211, 173, 0.14);
386
+ color: #96f2dd;
387
+ border-color: rgba(46, 211, 173, 0.32);
388
+ }
389
+
390
+ .metric-grid {
391
+ display: grid;
392
+ grid-template-columns: repeat(4, minmax(0, 1fr));
393
+ gap: 1rem;
394
+ }
395
+
396
+ .metric-card {
397
+ padding: 1.25rem;
398
+ }
399
+
400
+ .metric-card span {
401
+ color: var(--muted);
402
+ font-size: 0.92rem;
403
+ }
404
+
405
+ .metric-card strong {
406
+ font-size: clamp(1.4rem, 4vw, 2.2rem);
407
+ margin: 0.35rem 0;
408
+ }
409
+
410
+ .content-grid {
411
+ display: grid;
412
+ gap: 1rem;
413
+ }
414
+
415
+ .dashboard-grid {
416
+ grid-template-columns: repeat(2, minmax(0, 1fr));
417
+ }
418
+
419
+ .admin-grid {
420
+ grid-template-columns: repeat(2, minmax(0, 1fr));
421
+ }
422
+
423
+ .status-strip,
424
+ .button-row,
425
+ .chip-row,
426
+ .course-chip-row {
427
+ display: flex;
428
+ align-items: center;
429
+ gap: 0.7rem;
430
+ flex-wrap: wrap;
431
+ }
432
+
433
+ .wrap-row {
434
+ margin-top: 1rem;
435
+ }
436
+
437
+ .compact-row {
438
+ margin-top: 0.9rem;
439
+ }
440
+
441
+ .status-strip {
442
+ margin-bottom: 1rem;
443
+ color: var(--muted);
444
+ }
445
+
446
+ .status-pill,
447
+ .chip,
448
+ .live-dot {
449
+ display: inline-flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ border-radius: 999px;
453
+ padding: 0.45rem 0.9rem;
454
+ font-size: 0.84rem;
455
+ border: 1px solid rgba(255, 255, 255, 0.1);
456
+ }
457
+
458
+ .status-idle,
459
+ .chip {
460
+ background: rgba(255, 255, 255, 0.05);
461
+ }
462
+
463
+ .status-running,
464
+ .status-completed,
465
+ .highlight,
466
+ .live-dot {
467
+ background: rgba(46, 211, 173, 0.14);
468
+ color: #96f2dd;
469
+ }
470
+
471
+ .status-pending,
472
+ .status-cancel_requested {
473
+ background: rgba(255, 182, 72, 0.14);
474
+ color: #ffd48d;
475
+ }
476
+
477
+ .status-stopped,
478
+ .status-failed,
479
+ .danger {
480
+ background: rgba(255, 111, 97, 0.14);
481
+ color: #ffcec7;
482
+ }
483
+
484
+ .live-dot {
485
+ position: relative;
486
+ gap: 0.4rem;
487
+ font-family: var(--font-display);
488
+ letter-spacing: 0.08em;
489
+ }
490
+
491
+ .live-dot::before {
492
+ content: "";
493
+ width: 8px;
494
+ height: 8px;
495
+ border-radius: 50%;
496
+ background: currentColor;
497
+ box-shadow: 0 0 0 0 rgba(150, 242, 221, 0.65);
498
+ animation: pulse 2.1s infinite;
499
+ }
500
+
501
+ .course-table-wrap {
502
+ overflow-x: auto;
503
+ }
504
+
505
+ .data-table {
506
+ width: 100%;
507
+ border-collapse: collapse;
508
+ }
509
+
510
+ .data-table th,
511
+ .data-table td {
512
+ text-align: left;
513
+ padding: 0.95rem 0.85rem;
514
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
515
+ }
516
+
517
+ .data-table th {
518
+ color: #b8d4d4;
519
+ font-size: 0.9rem;
520
+ }
521
+
522
+ .inline-action {
523
+ border: 0;
524
+ background: transparent;
525
+ color: #ffd0ca;
526
+ cursor: pointer;
527
+ padding: 0;
528
+ }
529
+
530
+ .empty-cell,
531
+ .empty-mini,
532
+ .empty-state-card {
533
+ color: var(--muted);
534
+ }
535
+
536
+ .log-console {
537
+ min-height: 360px;
538
+ max-height: 540px;
539
+ overflow: auto;
540
+ padding: 1rem;
541
+ border-radius: 22px;
542
+ background: rgba(4, 10, 16, 0.92);
543
+ border: 1px solid rgba(255, 255, 255, 0.06);
544
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
545
+ }
546
+
547
+ .log-line {
548
+ display: grid;
549
+ gap: 0.25rem;
550
+ padding: 0.8rem 0;
551
+ border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
552
+ font-size: 0.92rem;
553
+ }
554
+
555
+ .log-line:last-child {
556
+ border-bottom: 0;
557
+ }
558
+
559
+ .log-meta {
560
+ color: #7ea4aa;
561
+ font-size: 0.78rem;
562
+ }
563
+
564
+ .level-error {
565
+ color: #ffb5ac;
566
+ }
567
+
568
+ .level-warning {
569
+ color: #ffd59a;
570
+ }
571
+
572
+ .level-info {
573
+ color: #d8ece9;
574
+ }
575
+
576
+ .level-success {
577
+ color: #97f4dd;
578
+ }
579
+
580
+ .muted {
581
+ color: var(--muted);
582
+ }
583
+
584
+ .user-card-grid {
585
+ display: grid;
586
+ gap: 1rem;
587
+ }
588
+
589
+ .user-card {
590
+ padding: 1.25rem;
591
+ scroll-margin-top: 1.5rem;
592
+ }
593
+
594
+ .subsection-head {
595
+ margin-top: 1rem;
596
+ padding-top: 1rem;
597
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
598
+ }
599
+
600
+ .subsection-head h3 {
601
+ margin: 0.25rem 0 0;
602
+ font-size: 1.05rem;
603
+ }
604
+
605
+ .subsection-head p {
606
+ margin: 0.4rem 0 0;
607
+ }
608
+
609
+ .schedule-form {
610
+ margin-top: 0.9rem;
611
+ }
612
+
613
+ .user-card-head {
614
+ display: flex;
615
+ align-items: flex-start;
616
+ justify-content: space-between;
617
+ gap: 1rem;
618
+ }
619
+
620
+ .user-card h3 {
621
+ margin: 0;
622
+ font-family: var(--font-display);
623
+ }
624
+
625
+ .user-card p {
626
+ margin: 0.35rem 0 0;
627
+ color: var(--muted);
628
+ }
629
+
630
+ .course-list {
631
+ display: grid;
632
+ gap: 0.65rem;
633
+ margin-top: 1rem;
634
+ }
635
+
636
+ .course-chip-row {
637
+ justify-content: space-between;
638
+ padding: 0.8rem 0.95rem;
639
+ border-radius: 16px;
640
+ background: rgba(255, 255, 255, 0.05);
641
+ }
642
+
643
+ .chip-row.tight {
644
+ margin-top: 0.85rem;
645
+ }
646
+
647
+ .accent-amber {
648
+ box-shadow: 0 24px 70px rgba(255, 182, 72, 0.16);
649
+ }
650
+
651
+ .reveal-up {
652
+ opacity: 0;
653
+ transform: translateY(22px);
654
+ animation: revealUp 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
655
+ }
656
+
657
+ .delay-1 {
658
+ animation-delay: 0.08s;
659
+ }
660
+
661
+ .delay-2 {
662
+ animation-delay: 0.16s;
663
+ }
664
+
665
+ .delay-3 {
666
+ animation-delay: 0.24s;
667
+ }
668
+
669
+ .delay-4 {
670
+ animation-delay: 0.32s;
671
+ }
672
+
673
+ @keyframes revealUp {
674
+ to {
675
+ opacity: 1;
676
+ transform: translateY(0);
677
+ }
678
+ }
679
+
680
+ @keyframes drift {
681
+ 0%,
682
+ 100% {
683
+ transform: translate3d(0, 0, 0) scale(1);
684
+ }
685
+ 50% {
686
+ transform: translate3d(16px, -18px, 0) scale(1.05);
687
+ }
688
+ }
689
+
690
+ @keyframes pulse {
691
+ 0% {
692
+ box-shadow: 0 0 0 0 rgba(150, 242, 221, 0.65);
693
+ }
694
+ 70% {
695
+ box-shadow: 0 0 0 14px rgba(150, 242, 221, 0);
696
+ }
697
+ 100% {
698
+ box-shadow: 0 0 0 0 rgba(150, 242, 221, 0);
699
+ }
700
+ }
701
+
702
+ @media (max-width: 1100px) {
703
+ .auth-layout,
704
+ .dashboard-grid,
705
+ .admin-grid,
706
+ .metric-grid,
707
+ .hero-metrics {
708
+ grid-template-columns: 1fr;
709
+ }
710
+
711
+ .auth-card {
712
+ justify-self: stretch;
713
+ max-width: none;
714
+ }
715
+
716
+ .span-2 {
717
+ grid-column: auto;
718
+ }
719
+ }
720
+
721
+ @media (max-width: 760px) {
722
+ .page-shell {
723
+ width: min(100% - 1rem, 100%);
724
+ padding-top: 1rem;
725
+ padding-bottom: 2rem;
726
+ }
727
+
728
+ .hero-panel,
729
+ .auth-card,
730
+ .card,
731
+ .user-card,
732
+ .empty-state-card {
733
+ padding: 1.2rem;
734
+ border-radius: 22px;
735
+ }
736
+
737
+ .card-head.split,
738
+ .topbar,
739
+ .user-card-head {
740
+ flex-direction: column;
741
+ }
742
+
743
+ .form-grid-compact {
744
+ grid-template-columns: 1fr;
745
+ }
746
+
747
+ .button-row form,
748
+ .button-row .btn,
749
+ .btn {
750
+ width: 100%;
751
+ }
752
+
753
+ .button-row {
754
+ width: 100%;
755
+ }
756
+
757
+ .log-console {
758
+ min-height: 280px;
759
+ }
760
  }
templates/admin_dashboard.html CHANGED
@@ -1,377 +1,136 @@
1
- {% extends "base.html" %}
2
- {% block title %}管理后台 | SCU 选课控制台{% endblock %}
3
- {% block body_class %}admin-theme{% endblock %}
4
- {% block content %}
5
- <section class="dashboard-shell admin-dashboard" data-log-stream-url="{{ url_for('stream_admin_logs', last_id=recent_logs[-1].id if recent_logs else 0) }}" data-status-url="{{ url_for('admin_status') }}">
6
- <header class="topbar reveal-up">
7
- <div>
8
- <span class="eyebrow">Admin Console</span>
9
- <h1>管理员后台</h1>
10
- <p>当前管理员:{{ admin_identity.username }}{% if is_super_admin %} · 超级管理员{% endif %}</p>
11
- </div>
12
- <form method="post" action="{{ url_for('admin_logout') }}">
13
- <button type="submit" class="btn btn-ghost">退出后台</button>
14
- </form>
15
- </header>
16
-
17
- <section class="metric-grid reveal-up delay-1">
18
- <article class="metric-card">
19
- <span>用户数</span>
20
- <strong id="stat-users">{{ stats.users_count }}</strong>
21
- <small>已录入的学生账号</small>
22
- </article>
23
- <article class="metric-card">
24
- <span>运行中任务</span>
25
- <strong id="stat-running">{{ stats.running_count }}</strong>
26
- <small>排队中:<span id="stat-pending">{{ stats.pending_count }}</span></small>
27
- </article>
28
- <article class="metric-card">
29
- <span>总课程目标</span>
30
- <strong>{{ stats.courses_count }}</strong>
31
- <small>管理员可见全部课程号与课序号</small>
32
- </article>
33
- <article class="metric-card">
34
- <span>有效定时任务</span>
35
- <strong>{{ stats.active_schedule_count }}</strong>
36
- <small>管理员配置的每日自动启动与停止</small>
37
- </article>
38
- <article class="metric-card">
39
- <span>注册码总数</span>
40
- <strong>{{ stats.registration_code_count }}</strong>
41
- <small>支持用户按注册码自助注册</small>
42
- </article>
43
- </section>
44
-
45
- <section class="content-grid admin-grid">
46
- <article class="card reveal-up delay-2">
47
- <div class="card-head">
48
- <span class="kicker">调度设置</span>
49
- <h2>并行数</h2>
50
- <p>默认并行数已调整为 4,建议根据 Hugging Face Space 的资源情况适当调节。</p>
51
- </div>
52
- <form method="post" action="{{ url_for('update_parallel_limit') }}" class="form-grid form-grid-compact">
53
- <label class="field">
54
- <span>当前并行数</span>
55
- <input type="number" id="parallel-limit-input" name="parallel_limit" min="1" max="8" value="{{ parallel_limit }}" required>
56
- </label>
57
- <button type="submit" class="btn btn-primary">更新并行数</button>
58
- </form>
59
- </article>
60
 
61
- <article class="card reveal-up delay-2">
62
- <div class="card-head">
63
- <span class="kicker">新增用户</span>
64
- <h2>手动录入用户信息</h2>
65
- <p>管理员可以直接录学生账号,也可以只发注册码让学生自行注册。</p>
66
- </div>
67
- <form method="post" action="{{ url_for('create_user') }}" class="form-grid form-grid-compact">
68
- <label class="field">
69
- <span>学号</span>
70
- <input type="text" name="student_id" inputmode="numeric" placeholder="13 位学号" required>
71
- </label>
72
- <label class="field">
73
- <span>显示名称</span>
74
- <input type="text" name="display_name" placeholder="可选备注">
75
- </label>
76
- <label class="field">
77
- <span>刷新间隔</span>
78
- <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ default_refresh_interval_seconds }}" required>
79
- </label>
80
- <label class="field span-2">
81
- <span>密码</span>
82
- <input type="password" name="password" placeholder="教务处密码" required>
83
- </label>
84
- <button type="submit" class="btn btn-secondary">创建用户</button>
85
- </form>
86
- </article>
87
 
88
- <article class="card reveal-up delay-2">
89
- <div class="card-head">
90
- <span class="kicker">注册码</span>
91
- <h2>创建注册码</h2>
92
- <p>学生拿到注册码后即可在 <code>/register</code> 页面使用学号和教务处密码完成注册。</p>
93
- </div>
94
- <form method="post" action="{{ url_for('create_registration_code') }}" class="form-grid form-grid-compact">
95
- <label class="field span-2">
96
- <span>备注</span>
97
- <input type="text" name="note" placeholder="例如 2025 春季新用户批次">
98
- </label>
99
- <label class="field">
100
- <span>可用次数</span>
101
- <input type="number" name="max_uses" min="1" max="99" value="{{ default_registration_code_max_uses }}" required>
102
- </label>
103
- <button type="submit" class="btn btn-secondary">生成注册码</button>
104
- </form>
105
- </article>
106
 
107
- {% if is_super_admin %}
108
- <article class="card reveal-up delay-2">
109
- <div class="card-head">
110
- <span class="kicker">管理员管理</span>
111
- <h2>新增管理员</h2>
112
- <p>只有超级管理员可以继续创建普通管理员。</p>
113
- </div>
114
- <form method="post" action="{{ url_for('create_admin') }}" class="form-grid form-grid-compact">
115
- <label class="field">
116
- <span>管理员账号</span>
117
- <input type="text" name="username" placeholder="输入管理员账号" required>
118
- </label>
119
- <label class="field">
120
- <span>管理员密码</span>
121
- <input type="password" name="password" placeholder="输入管理员密码" required>
122
- </label>
123
- <button type="submit" class="btn btn-ghost">创建管理员</button>
124
- </form>
125
- <div class="chip-row">
126
- <span class="chip highlight">超级管理员:{{ admin_identity.username }}</span>
127
- {% for admin in admins %}
128
- <span class="chip">{{ admin.username }}</span>
129
- {% endfor %}
130
- </div>
131
- </article>
132
- {% endif %}
133
 
134
- <article class="card reveal-up delay-3 span-2">
135
- <div class="card-head split">
136
- <div>
137
- <span class="kicker">任务总览</span>
138
- <h2>最近任务</h2>
139
- <p>用于快速确认任务是否正在排队、执行、停止或失败。</p>
140
- </div>
141
- <span class="status-pill status-running">实时刷新</span>
142
  </div>
143
- <div class="course-table-wrap">
144
- <table class="data-table">
145
- <thead>
146
- <tr>
147
- <th>任务</th>
148
- <th>学号</th>
149
- <th>状态</th>
150
- <th>尝试</th>
151
- <th>错误</th>
152
- <th>刷新间隔</th>
153
- <th>触发者</th>
154
- <th>间</th>
155
- </tr>
156
- </thead>
157
- <tbody>
158
- {% if recent_tasks %}
159
- {% for task in recent_tasks %}
160
- <tr>
161
- <td>#{{ task.id }}</td>
162
- <td>{{ task.student_id }}</td>
163
- <td><span class="status-pill status-{{ task.status }}">{{ task_labels.get(task.status, task.status) }}</span></td>
164
- <td>{{ task.total_attempts }}</td>
165
- <td>{{ task.total_errors }}</td>
166
- <td>{{ task.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</td>
167
- <td>{{ task.requested_by_role }}:{{ task.requested_by }}</td>
168
- <td>{{ task.updated_at }}</td>
169
- </tr>
170
- {% endfor %}
171
- {% else %}
172
  <tr>
173
- <td colspan="8" class="empty-cell">还没有任务记录。</td>
 
 
 
 
 
 
 
174
  </tr>
175
- {% endif %}
176
- </tbody>
177
- </table>
178
- </div>
179
- </article>
180
-
181
- <article class="card reveal-up delay-3 span-2">
182
- <div class="card-head split">
183
- <div>
184
- <span class="kicker">注册码清单</span>
185
- <h2>注册码状态</h2>
186
- <p>可以查看注册码是否启用、可用次数、已用次数以及最近一次使用���况。</p>
187
- </div>
188
- </div>
189
- <div class="course-table-wrap">
190
- <table class="data-table">
191
- <thead>
192
  <tr>
193
- <th>注册码</th>
194
- <th>备注</th>
195
- <th>状态</th>
196
- <th>使用</th>
197
- <th>最近使用者</th>
198
- <th>操作</th>
199
  </tr>
200
- </thead>
201
- <tbody>
202
- {% if registration_codes %}
203
- {% for code in registration_codes %}
204
- <tr>
205
- <td><code>{{ code.code }}</code></td>
206
- <td>{{ code.note or '无' }}</td>
207
- <td>{{ '启用' if code.is_active else '停用' }}</td>
208
- <td>{{ code.used_count }}/{{ code.max_uses }}</td>
209
- <td>{{ code.used_by_student_id or '暂无' }}</td>
210
- <td>
211
- <form method="post" action="{{ url_for('toggle_registration_code', registration_code_id=code.id) }}">
212
- <button type="submit" class="inline-action">{{ '停用' if code.is_active else '启用' }}</button>
213
- </form>
214
- </td>
215
- </tr>
216
- {% endfor %}
217
- {% else %}
218
- <tr>
219
- <td colspan="6" class="empty-cell">还没有创建注册码。</td>
220
- </tr>
221
- {% endif %}
222
- </tbody>
223
- </table>
224
- </div>
225
- </article>
226
-
227
- <article class="card reveal-up delay-3 span-2">
228
- <div class="card-head split">
229
- <div>
230
- <span class="kicker">全局日志</span>
231
- <h2>所有用户的运行日志</h2>
232
- <p>日志会持续流入,便于管理员确认登录、查课、提交结果、定时启动终止与错误信息。</p>
233
- </div>
234
- <span class="live-dot">LIVE</span>
235
- </div>
236
- <div class="log-console" id="log-console">
237
- {% if recent_logs %}
238
- {% for log in recent_logs %}
239
- <div class="log-line level-{{ log.level|lower }}">
240
- <span class="log-meta">{{ log.created_at }} · {{ log.student_id or 'system' }} · {{ log.scope }} · {{ log.level }}</span>
241
- <span>{{ log.message }}</span>
242
- </div>
243
- {% endfor %}
244
- {% else %}
245
- <div class="log-line level-info muted">暂无日志,用户启动任务后这里会自动刷新。</div>
246
- {% endif %}
247
- </div>
248
- </article>
249
-
250
- <article class="card reveal-up delay-4 span-2">
251
- <div class="card-head">
252
- <span class="kicker">用户管理</span>
253
- <h2>所有用户与课程详情</h2>
254
- <p>可以直接修改用户信息、配置定时任务、增减课程,或代替用户启动和停止任务。</p>
255
- </div>
256
- <div class="user-card-grid">
257
- {% for user in users %}
258
- <section class="user-card">
259
- <div class="user-card-head">
260
- <div>
261
- <h3>{{ user.display_name or user.student_id }}</h3>
262
- <p>{{ user.student_id }}</p>
263
- </div>
264
- <span class="status-pill status-{{ user.latest_task.status if user.latest_task else 'idle' }}">
265
- {{ task_labels.get(user.latest_task.status, '未启动') if user.latest_task else '未启动' }}
266
- </span>
267
- </div>
268
-
269
- <div class="chip-row tight">
270
- <span class="chip {% if user.is_active %}highlight{% endif %}">{{ '启用中' if user.is_active else '已禁用' }}</span>
271
- <span class="chip">课程 {{ user.course_count }}</span>
272
- <span class="chip">最近任务 {{ user.latest_task.id if user.latest_task else '--' }}</span>
273
- <span class="chip">刷新 {{ user.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</span>
274
- <span class="chip">尝试 {{ user.latest_task.total_attempts if user.latest_task else 0 }}</span>
275
- <span class="chip">错误 {{ user.latest_task.total_errors if user.latest_task else 0 }}</span>
276
- <span class="chip">定时 {{ '开启' if user.schedule and user.schedule.is_enabled else '关闭' }}</span>
277
- </div>
278
-
279
- <form method="post" action="{{ url_for('update_user', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
280
- <label class="field span-2">
281
- <span>显示名称</span>
282
- <input type="text" name="display_name" value="{{ user.display_name }}" placeholder="备注名称">
283
- </label>
284
- <label class="field">
285
- <span>刷新间隔</span>
286
- <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ user.refresh_interval_seconds or default_refresh_interval_seconds }}" required>
287
- </label>
288
- <label class="field span-2">
289
- <span>重置密码</span>
290
- <input type="password" name="password" placeholder="留空表示不修改">
291
- </label>
292
- <button type="submit" class="btn btn-ghost">保存用户</button>
293
- </form>
294
-
295
- <form method="post" action="{{ url_for('update_user_schedule', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
296
- <label class="field">
297
- <span>启用定时</span>
298
- <input type="checkbox" name="schedule_enabled" value="1" {% if user.schedule and user.schedule.is_enabled %}checked{% endif %}>
299
- </label>
300
- <label class="field">
301
- <span>开始日期</span>
302
- <input type="date" name="start_date" value="{{ user.schedule.start_date if user.schedule else '' }}">
303
- </label>
304
- <label class="field">
305
- <span>结束日期</span>
306
- <input type="date" name="end_date" value="{{ user.schedule.end_date if user.schedule else '' }}">
307
- </label>
308
- <label class="field">
309
- <span>每日启动</span>
310
- <input type="time" name="daily_start_time" value="{{ user.schedule.daily_start_time if user.schedule else '' }}">
311
- </label>
312
- <label class="field">
313
- <span>每日停止</span>
314
- <input type="time" name="daily_stop_time" value="{{ user.schedule.daily_stop_time if user.schedule else '' }}">
315
- </label>
316
- <button type="submit" class="btn btn-secondary">保存定时设置</button>
317
- </form>
318
-
319
- <div class="button-row wrap-row">
320
- <form method="post" action="{{ url_for('toggle_user', user_id=user.id) }}">
321
- <button type="submit" class="btn btn-ghost {% if not user.is_active %}danger{% endif %}">{{ '禁用' if user.is_active else '启用' }}</button>
322
- </form>
323
- <form method="post" action="{{ url_for('admin_start_user_task', user_id=user.id) }}">
324
- <button type="submit" class="btn btn-primary">代启动任务</button>
325
- </form>
326
- <form method="post" action="{{ url_for('admin_stop_user_task', user_id=user.id) }}">
327
- <button type="submit" class="btn btn-ghost danger">代停止任务</button>
328
- </form>
329
- <form method="post" action="{{ url_for('delete_user_by_admin', user_id=user.id) }}">
330
- <button type="submit" class="btn btn-ghost danger">删除用户</button>
331
- </form>
332
- </div>
333
-
334
- <form method="post" action="{{ url_for('admin_add_course', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
335
- <label class="field">
336
- <span>类型</span>
337
- <select name="category">
338
- <option value="free">自由选课</option>
339
- <option value="plan">方案选课</option>
340
- </select>
341
- </label>
342
- <label class="field">
343
- <span>课��号</span>
344
- <input type="text" name="course_id" placeholder="例如 888005010A59" autocapitalize="characters" required>
345
- </label>
346
- <label class="field">
347
- <span>课序号</span>
348
- <input type="text" name="course_index" placeholder="例如 01 或 666" autocapitalize="characters" required>
349
- </label>
350
- <button type="submit" class="btn btn-secondary">为该用户加课</button>
351
- </form>
352
-
353
- <div class="course-list">
354
- {% if user.courses %}
355
- {% for course in user.courses %}
356
- <div class="course-chip-row">
357
- <span>{{ category_labels.get(course.category, course.category) }} · {{ course.course_id }}_{{ course.course_index }}</span>
358
- <form method="post" action="{{ url_for('admin_delete_course', course_target_id=course.id) }}">
359
- <button type="submit" class="inline-action">删除</button>
360
- </form>
361
- </div>
362
- {% endfor %}
363
- {% else %}
364
- <div class="empty-mini">当前没有课程目标。</div>
365
- {% endif %}
366
- </div>
367
- </section>
368
- {% else %}
369
- <div class="empty-state-card">
370
- 还没有录入任何用户,请先通过上方表单创建或发放注册码。
371
- </div>
372
- {% endfor %}
373
- </div>
374
- </article>
375
- </section>
376
  </section>
377
  {% endblock %}
 
1
+ {% extends "admin_layout.html" %}
2
+ {% block admin_title %}后台总览{% endblock %}
3
+ {% block admin_page_content %}
4
+ <section class="metric-grid reveal-up delay-2">
5
+ <article class="metric-card">
6
+ <span>用户数</span>
7
+ <strong id="stat-users">{{ stats.users_count }}</strong>
8
+ <small>已录入的学生账号</small>
9
+ </article>
10
+ <article class="metric-card">
11
+ <span>运行中任务</span>
12
+ <strong id="stat-running">{{ stats.running_count }}</strong>
13
+ <small>排队中:<span id="stat-pending">{{ stats.pending_count }}</span></small>
14
+ </article>
15
+ <article class="metric-card">
16
+ <span>总课程目标</span>
17
+ <strong>{{ stats.courses_count }}</strong>
18
+ <small>管理员可见全部课程号与课序号</small>
19
+ </article>
20
+ <article class="metric-card">
21
+ <span>有效定时任务</span>
22
+ <strong>{{ stats.active_schedule_count }}</strong>
23
+ <small>管理员配置的每日自动启动与停止</small>
24
+ </article>
25
+ <article class="metric-card">
26
+ <span>注册码总数</span>
27
+ <strong>{{ stats.registration_code_count }}</strong>
28
+ <small>支持用户按注册码自助注册</small>
29
+ </article>
30
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
+ <section class="content-grid admin-grid">
33
+ <article class="card reveal-up delay-2">
34
+ <div class="card-head">
35
+ <span class="kicker">分页面管理</span>
36
+ <h2>功能</h2>
37
+ <p>不同功能已经拆分到独立页面,避免全部堆在首页。</p>
38
+ </div>
39
+ <div class="button-row wrap-row">
40
+ <a href="{{ url_for('admin_users') }}" class="btn btn-primary">进入用户管理</a>
41
+ <a href="{{ url_for('admin_schedules') }}" class="btn btn-secondary">进入定时任务</a>
42
+ <a href="{{ url_for('admin_registration_codes') }}" class="btn btn-secondary">进入注册码</a>
43
+ <a href="{{ url_for('admin_logs') }}" class="btn btn-ghost">查看运行日志</a>
44
+ </div>
45
+ </article>
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
+ <article class="card reveal-up delay-2">
48
+ <div class="card-head">
49
+ <span class="kicker">调度设置</span>
50
+ <h2>并行数</h2>
51
+ <p>默认并行数已调整为 4,建议根据 Hugging Face Space 的资源情况适当调节。</p>
52
+ </div>
53
+ <form method="post" action="{{ url_for('update_parallel_limit') }}" class="form-grid form-grid-compact">
54
+ <label class="field">
55
+ <span>当前并行数</span>
56
+ <input type="number" id="parallel-limit-input" name="parallel_limit" min="1" max="8" value="{{ parallel_limit }}" required>
57
+ </label>
58
+ <button type="submit" class="btn btn-primary">更新并行数</button>
59
+ </form>
60
+ </article>
 
 
 
 
61
 
62
+ {% if is_super_admin %}
63
+ <article class="card reveal-up delay-2">
64
+ <div class="card-head">
65
+ <span class="kicker">管理员管理</span>
66
+ <h2>新增管理员</h2>
67
+ <p>只有超级管理员可以继续创建普通管理员。</p>
68
+ </div>
69
+ <form method="post" action="{{ url_for('create_admin') }}" class="form-grid form-grid-compact">
70
+ <label class="field">
71
+ <span>管理员账号</span>
72
+ <input type="text" name="username" placeholder="输入管理员账号" required>
73
+ </label>
74
+ <label class="field">
75
+ <span>管理员密码</span>
76
+ <input type="password" name="password" placeholder="输入管理员密码" required>
77
+ </label>
78
+ <button type="submit" class="btn btn-ghost">创建管理员</button>
79
+ </form>
80
+ <div class="chip-row">
81
+ <span class="chip highlight">超级管理员:{{ admin_identity.username }}</span>
82
+ {% for admin in admins %}
83
+ <span class="chip">{{ admin.username }}</span>
84
+ {% endfor %}
85
+ </div>
86
+ </article>
87
+ {% endif %}
88
 
89
+ <article class="card reveal-up delay-3 span-2">
90
+ <div class="card-head split">
91
+ <div>
92
+ <span class="kicker">任务总览</span>
93
+ <h2>最近任务</h2>
94
+ <p>用于快速确认任务是否正在排队、执行、停止或失败。</p>
 
 
95
  </div>
96
+ <span class="status-pill status-running">实时刷新</span>
97
+ </div>
98
+ <div class="course-table-wrap">
99
+ <table class="data-table">
100
+ <thead>
101
+ <tr>
102
+ <th>任务</th>
103
+ <th>学号</th>
104
+ <th>状态</th>
105
+ <th>尝试</th>
106
+ <th>错误</th>
107
+ <th>新间</th>
108
+ <th>触发者</th>
109
+ <th>更新时间</th>
110
+ </tr>
111
+ </thead>
112
+ <tbody>
113
+ {% if recent_tasks %}
114
+ {% for task in recent_tasks %}
 
 
 
 
 
 
 
 
 
 
115
  <tr>
116
+ <td>#{{ task.id }}</td>
117
+ <td>{{ task.student_id }}</td>
118
+ <td><span class="status-pill status-{{ task.status }}">{{ task_labels.get(task.status, task.status) }}</span></td>
119
+ <td>{{ task.total_attempts }}</td>
120
+ <td>{{ task.total_errors }}</td>
121
+ <td>{{ task.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</td>
122
+ <td>{{ task.requested_by_role }}:{{ task.requested_by }}</td>
123
+ <td>{{ task.updated_at }}</td>
124
  </tr>
125
+ {% endfor %}
126
+ {% else %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  <tr>
128
+ <td colspan="8" class="empty-cell">还没有任务记录。</td>
 
 
 
 
 
129
  </tr>
130
+ {% endif %}
131
+ </tbody>
132
+ </table>
133
+ </div>
134
+ </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </section>
136
  {% endblock %}
templates/admin_layout.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}{% block admin_title %}管理后台{% endblock %} | SCU 选课控制台{% endblock %}
3
+ {% block body_class %}admin-theme{% endblock %}
4
+ {% block content %}
5
+ <section class="dashboard-shell admin-dashboard"{% if log_stream_url %} data-log-stream-url="{{ log_stream_url }}"{% endif %}{% if status_url %} data-status-url="{{ status_url }}"{% endif %}>
6
+ <header class="topbar reveal-up">
7
+ <div>
8
+ <span class="eyebrow">Admin Console</span>
9
+ <h1>管理员后台</h1>
10
+ <p>当前管理员:{{ admin_identity.username }}{% if is_super_admin %} · 超级管理员{% endif %}</p>
11
+ </div>
12
+ <form method="post" action="{{ url_for('admin_logout') }}">
13
+ <button type="submit" class="btn btn-ghost">退出后台</button>
14
+ </form>
15
+ </header>
16
+
17
+ <nav class="admin-nav reveal-up delay-1" aria-label="管理员后台导航">
18
+ <a href="{{ url_for('admin_dashboard') }}" class="admin-nav-link {% if admin_page == 'overview' %}active{% endif %}">总览</a>
19
+ <a href="{{ url_for('admin_users') }}" class="admin-nav-link {% if admin_page == 'users' %}active{% endif %}">用户管理</a>
20
+ <a href="{{ url_for('admin_schedules') }}" class="admin-nav-link {% if admin_page == 'schedules' %}active{% endif %}">定时任务</a>
21
+ <a href="{{ url_for('admin_registration_codes') }}" class="admin-nav-link {% if admin_page == 'registration_codes' %}active{% endif %}">注册码</a>
22
+ <a href="{{ url_for('admin_logs') }}" class="admin-nav-link {% if admin_page == 'logs' %}active{% endif %}">运行日志</a>
23
+ </nav>
24
+
25
+ {% block admin_page_content %}{% endblock %}
26
+ </section>
27
+ {% endblock %}
templates/admin_logs.html ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin_layout.html" %}
2
+ {% block admin_title %}运行日志{% endblock %}
3
+ {% block admin_page_content %}
4
+ <section class="content-grid admin-grid reveal-up delay-2">
5
+ <article class="card span-2">
6
+ <div class="card-head split">
7
+ <div>
8
+ <span class="kicker">全局日志</span>
9
+ <h2>所有用户的运行日志</h2>
10
+ <p>日志会持续流入,便于管理员确认登录、查课、提交结果、定时启动终止与错误信息。</p>
11
+ </div>
12
+ <span class="live-dot">LIVE</span>
13
+ </div>
14
+ <div class="log-console" id="log-console">
15
+ {% if recent_logs %}
16
+ {% for log in recent_logs %}
17
+ <div class="log-line level-{{ log.level|lower }}">
18
+ <span class="log-meta">{{ log.created_at }} · {{ log.student_id or 'system' }} · {{ log.scope }} · {{ log.level }}</span>
19
+ <span>{{ log.message }}</span>
20
+ </div>
21
+ {% endfor %}
22
+ {% else %}
23
+ <div class="log-line level-info muted">暂无日志,用户启动任务后这里会自动刷新。</div>
24
+ {% endif %}
25
+ </div>
26
+ </article>
27
+ </section>
28
+ {% endblock %}
templates/admin_registration_codes.html ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin_layout.html" %}
2
+ {% block admin_title %}注册码{% endblock %}
3
+ {% block admin_page_content %}
4
+ <section class="content-grid admin-grid reveal-up delay-2">
5
+ <article class="card">
6
+ <div class="card-head">
7
+ <span class="kicker">注册码</span>
8
+ <h2>创建注册码</h2>
9
+ <p>学生拿到注册码后即可在 <code>/register</code> 页面使用学号和教务处密码完成注册。</p>
10
+ </div>
11
+ <form method="post" action="{{ url_for('create_registration_code') }}" class="form-grid form-grid-compact">
12
+ <label class="field span-2">
13
+ <span>备注</span>
14
+ <input type="text" name="note" placeholder="例如 2025 春季新用户批次">
15
+ </label>
16
+ <label class="field">
17
+ <span>可用次数</span>
18
+ <input type="number" name="max_uses" min="1" max="99" value="{{ default_registration_code_max_uses }}" required>
19
+ </label>
20
+ <button type="submit" class="btn btn-secondary">生成注册码</button>
21
+ </form>
22
+ </article>
23
+
24
+ <article class="card">
25
+ <div class="card-head">
26
+ <span class="kicker">使用说明</span>
27
+ <h2>给学生的注册提示</h2>
28
+ <p>建议提示用户使用学号和教务处密码注册;注册码只负责开通本系统账号,不替代教务处认证。</p>
29
+ </div>
30
+ <div class="button-row wrap-row">
31
+ <a href="{{ url_for('admin_users') }}" class="btn btn-ghost">返回用户管理</a>
32
+ </div>
33
+ </article>
34
+ </section>
35
+
36
+ <section class="card reveal-up delay-3 span-2">
37
+ <div class="card-head split">
38
+ <div>
39
+ <span class="kicker">注册码清单</span>
40
+ <h2>注册码状态</h2>
41
+ <p>可以查看注册码是否启用、可用次数、已用次数以及最近一次使用情况。</p>
42
+ </div>
43
+ </div>
44
+ <div class="course-table-wrap">
45
+ <table class="data-table">
46
+ <thead>
47
+ <tr>
48
+ <th>注册码</th>
49
+ <th>备注</th>
50
+ <th>状态</th>
51
+ <th>使用</th>
52
+ <th>最近使用者</th>
53
+ <th>操作</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ {% if registration_codes %}
58
+ {% for code in registration_codes %}
59
+ <tr>
60
+ <td><code>{{ code.code }}</code></td>
61
+ <td>{{ code.note or '无' }}</td>
62
+ <td>{{ '启用' if code.is_active else '停用' }}</td>
63
+ <td>{{ code.used_count }}/{{ code.max_uses }}</td>
64
+ <td>{{ code.used_by_student_id or '暂无' }}</td>
65
+ <td>
66
+ <form method="post" action="{{ url_for('toggle_registration_code', registration_code_id=code.id) }}">
67
+ <button type="submit" class="inline-action">{{ '停用' if code.is_active else '启用' }}</button>
68
+ </form>
69
+ </td>
70
+ </tr>
71
+ {% endfor %}
72
+ {% else %}
73
+ <tr>
74
+ <td colspan="6" class="empty-cell">还没有创建注册码。</td>
75
+ </tr>
76
+ {% endif %}
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+ </section>
81
+ {% endblock %}
templates/admin_schedules.html ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin_layout.html" %}
2
+ {% block admin_title %}定时任务{% endblock %}
3
+ {% block admin_page_content %}
4
+ <section class="content-grid admin-grid reveal-up delay-2">
5
+ <article class="card span-2">
6
+ <div class="card-head split">
7
+ <div>
8
+ <span class="kicker">定时任务</span>
9
+ <h2>定时任务总览</h2>
10
+ <p>这里会列出每个用户当前的定时状态,并可直接在下方卡片中修改配置。</p>
11
+ </div>
12
+ <a href="{{ url_for('admin_users') }}" class="btn btn-ghost">返回用户管理</a>
13
+ </div>
14
+ <div class="course-table-wrap">
15
+ <table class="data-table">
16
+ <thead>
17
+ <tr>
18
+ <th>用户</th>
19
+ <th>状态</th>
20
+ <th>日期范围</th>
21
+ <th>每日时段</th>
22
+ <th>快速定位</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ {% if users %}
27
+ {% for user in users %}
28
+ <tr>
29
+ <td>{{ user.display_name or user.student_id }}<br><small>{{ user.student_id }}</small></td>
30
+ <td>{{ '启用' if user.schedule and user.schedule.is_enabled else '关闭' }}</td>
31
+ <td>
32
+ {% if user.schedule and user.schedule.start_date and user.schedule.end_date %}
33
+ {{ user.schedule.start_date }} 至 {{ user.schedule.end_date }}
34
+ {% else %}
35
+ 未设置
36
+ {% endif %}
37
+ </td>
38
+ <td>
39
+ {% if user.schedule and user.schedule.daily_start_time and user.schedule.daily_stop_time %}
40
+ {{ user.schedule.daily_start_time }} - {{ user.schedule.daily_stop_time }}
41
+ {% else %}
42
+ 未设置
43
+ {% endif %}
44
+ </td>
45
+ <td><a href="#user-{{ user.id }}" class="inline-action">前往设置</a></td>
46
+ </tr>
47
+ {% endfor %}
48
+ {% else %}
49
+ <tr>
50
+ <td colspan="5" class="empty-cell">还没有用户,暂时无法配置定时任务。</td>
51
+ </tr>
52
+ {% endif %}
53
+ </tbody>
54
+ </table>
55
+ </div>
56
+ </article>
57
+ </section>
58
+
59
+ <section class="card reveal-up delay-3 span-2">
60
+ <div class="card-head">
61
+ <span class="kicker">按用户配置</span>
62
+ <h2>定时启动终止设置</h2>
63
+ <p>仅管理员可以配置。支持设置从几月几日开始、几月几日结束,以及每天自动启动和停止时间。</p>
64
+ </div>
65
+ <div class="user-card-grid">
66
+ {% for user in users %}
67
+ <section class="user-card" id="user-{{ user.id }}">
68
+ <div class="user-card-head">
69
+ <div>
70
+ <h3>{{ user.display_name or user.student_id }}</h3>
71
+ <p>{{ user.student_id }}</p>
72
+ </div>
73
+ <span class="status-pill status-{{ user.latest_task.status if user.latest_task else 'idle' }}">
74
+ {{ task_labels.get(user.latest_task.status, '未启动') if user.latest_task else '未启动' }}
75
+ </span>
76
+ </div>
77
+
78
+ <div class="chip-row tight">
79
+ <span class="chip">定时 {{ '开启' if user.schedule and user.schedule.is_enabled else '关闭' }}</span>
80
+ <span class="chip">最近任务 {{ user.latest_task.id if user.latest_task else '--' }}</span>
81
+ <span class="chip">尝试 {{ user.latest_task.total_attempts if user.latest_task else 0 }}</span>
82
+ <span class="chip">错误 {{ user.latest_task.total_errors if user.latest_task else 0 }}</span>
83
+ </div>
84
+
85
+ <form method="post" action="{{ url_for('update_user_schedule', user_id=user.id) }}" class="form-grid form-grid-compact slim-form schedule-form">
86
+ <label class="field">
87
+ <span>启用定时</span>
88
+ <input type="checkbox" name="schedule_enabled" value="1" {% if user.schedule and user.schedule.is_enabled %}checked{% endif %}>
89
+ </label>
90
+ <label class="field">
91
+ <span>开始日期</span>
92
+ <input type="date" name="start_date" value="{{ user.schedule.start_date if user.schedule else '' }}">
93
+ </label>
94
+ <label class="field">
95
+ <span>结束日期</span>
96
+ <input type="date" name="end_date" value="{{ user.schedule.end_date if user.schedule else '' }}">
97
+ </label>
98
+ <label class="field">
99
+ <span>每日启动</span>
100
+ <input type="time" name="daily_start_time" value="{{ user.schedule.daily_start_time if user.schedule else '' }}">
101
+ </label>
102
+ <label class="field">
103
+ <span>每日停止</span>
104
+ <input type="time" name="daily_stop_time" value="{{ user.schedule.daily_stop_time if user.schedule else '' }}">
105
+ </label>
106
+ <button type="submit" class="btn btn-secondary">保存定时设置</button>
107
+ </form>
108
+ </section>
109
+ {% else %}
110
+ <div class="empty-state-card">
111
+ 还没有录入任何用户,请先到“用户管理”页面创建用户。
112
+ </div>
113
+ {% endfor %}
114
+ </div>
115
+ </section>
116
+ {% endblock %}
templates/admin_users.html ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin_layout.html" %}
2
+ {% block admin_title %}用户管理{% endblock %}
3
+ {% block admin_page_content %}
4
+ <section class="content-grid admin-grid reveal-up delay-2">
5
+ <article class="card">
6
+ <div class="card-head">
7
+ <span class="kicker">新增用户</span>
8
+ <h2>手动录入用户信息</h2>
9
+ <p>管理员可以直接录入学生账号,课程和定时任务则在对应页面分别管理。</p>
10
+ </div>
11
+ <form method="post" action="{{ url_for('create_user') }}" class="form-grid form-grid-compact">
12
+ <label class="field">
13
+ <span>学号</span>
14
+ <input type="text" name="student_id" inputmode="numeric" placeholder="13 位学号" required>
15
+ </label>
16
+ <label class="field">
17
+ <span>显示名称</span>
18
+ <input type="text" name="display_name" placeholder="可选备注">
19
+ </label>
20
+ <label class="field">
21
+ <span>刷新间隔</span>
22
+ <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ default_refresh_interval_seconds }}" required>
23
+ </label>
24
+ <label class="field span-2">
25
+ <span>密码</span>
26
+ <input type="password" name="password" placeholder="教务处密码" required>
27
+ </label>
28
+ <button type="submit" class="btn btn-secondary">创建用户</button>
29
+ </form>
30
+ </article>
31
+
32
+ <article class="card">
33
+ <div class="card-head">
34
+ <span class="kicker">跳转提示</span>
35
+ <h2>功能已拆分</h2>
36
+ <p>这里负责用户资料、课程和任务操作。定时任务请进入“定时任务”页面,注册码请进入“注册码”页面。</p>
37
+ </div>
38
+ <div class="button-row wrap-row">
39
+ <a href="{{ url_for('admin_schedules') }}" class="btn btn-secondary">去定时任务页</a>
40
+ <a href="{{ url_for('admin_registration_codes') }}" class="btn btn-ghost">去注册码页</a>
41
+ </div>
42
+ </article>
43
+ </section>
44
+
45
+ <section class="card reveal-up delay-3 span-2">
46
+ <div class="card-head">
47
+ <span class="kicker">用户管理</span>
48
+ <h2>所有用户与课程详情</h2>
49
+ <p>可以直接修改用户信息、增减课程,或代替用户启动和停止任务。</p>
50
+ </div>
51
+ <div class="user-card-grid">
52
+ {% for user in users %}
53
+ <section class="user-card" id="user-{{ user.id }}">
54
+ <div class="user-card-head">
55
+ <div>
56
+ <h3>{{ user.display_name or user.student_id }}</h3>
57
+ <p>{{ user.student_id }}</p>
58
+ </div>
59
+ <span class="status-pill status-{{ user.latest_task.status if user.latest_task else 'idle' }}">
60
+ {{ task_labels.get(user.latest_task.status, '未启动') if user.latest_task else '未启动' }}
61
+ </span>
62
+ </div>
63
+
64
+ <div class="chip-row tight">
65
+ <span class="chip {% if user.is_active %}highlight{% endif %}">{{ '启用中' if user.is_active else '已禁用' }}</span>
66
+ <span class="chip">课程 {{ user.course_count }}</span>
67
+ <span class="chip">最近任务 {{ user.latest_task.id if user.latest_task else '--' }}</span>
68
+ <span class="chip">刷新 {{ user.refresh_interval_seconds or default_refresh_interval_seconds }} 秒</span>
69
+ <span class="chip">尝试 {{ user.latest_task.total_attempts if user.latest_task else 0 }}</span>
70
+ <span class="chip">错误 {{ user.latest_task.total_errors if user.latest_task else 0 }}</span>
71
+ <span class="chip">定时 {{ '开启' if user.schedule and user.schedule.is_enabled else '关闭' }}</span>
72
+ </div>
73
+
74
+ <form method="post" action="{{ url_for('update_user', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
75
+ <label class="field span-2">
76
+ <span>显示名称</span>
77
+ <input type="text" name="display_name" value="{{ user.display_name }}" placeholder="备注名称">
78
+ </label>
79
+ <label class="field">
80
+ <span>刷新间隔</span>
81
+ <input type="number" name="refresh_interval_seconds" min="{{ refresh_interval_min }}" max="{{ refresh_interval_max }}" value="{{ user.refresh_interval_seconds or default_refresh_interval_seconds }}" required>
82
+ </label>
83
+ <label class="field span-2">
84
+ <span>重置密码</span>
85
+ <input type="password" name="password" placeholder="留空表示不修改">
86
+ </label>
87
+ <button type="submit" class="btn btn-ghost">保存用户</button>
88
+ </form>
89
+
90
+ <div class="button-row wrap-row compact-row">
91
+ <a href="{{ url_for('admin_schedules') }}#user-{{ user.id }}" class="btn btn-secondary">去设定时任务</a>
92
+ <form method="post" action="{{ url_for('toggle_user', user_id=user.id) }}">
93
+ <button type="submit" class="btn btn-ghost {% if not user.is_active %}danger{% endif %}">{{ '禁用' if user.is_active else '启用' }}</button>
94
+ </form>
95
+ <form method="post" action="{{ url_for('admin_start_user_task', user_id=user.id) }}">
96
+ <button type="submit" class="btn btn-primary">代启动任务</button>
97
+ </form>
98
+ <form method="post" action="{{ url_for('admin_stop_user_task', user_id=user.id) }}">
99
+ <button type="submit" class="btn btn-ghost danger">代停止任务</button>
100
+ </form>
101
+ <form method="post" action="{{ url_for('delete_user_by_admin', user_id=user.id) }}">
102
+ <button type="submit" class="btn btn-ghost danger">删除用户</button>
103
+ </form>
104
+ </div>
105
+
106
+ <form method="post" action="{{ url_for('admin_add_course', user_id=user.id) }}" class="form-grid form-grid-compact slim-form">
107
+ <label class="field">
108
+ <span>类型</span>
109
+ <select name="category">
110
+ <option value="free">自由选课</option>
111
+ <option value="plan">方案选课</option>
112
+ </select>
113
+ </label>
114
+ <label class="field">
115
+ <span>课程号</span>
116
+ <input type="text" name="course_id" placeholder="例如 888005010A59" autocapitalize="characters" required>
117
+ </label>
118
+ <label class="field">
119
+ <span>课序号</span>
120
+ <input type="text" name="course_index" placeholder="例如 01 或 666" autocapitalize="characters" required>
121
+ </label>
122
+ <button type="submit" class="btn btn-secondary">为该用户加课</button>
123
+ </form>
124
+
125
+ <div class="course-list">
126
+ {% if user.courses %}
127
+ {% for course in user.courses %}
128
+ <div class="course-chip-row">
129
+ <span>{{ category_labels.get(course.category, course.category) }} · {{ course.course_id }}_{{ course.course_index }}</span>
130
+ <form method="post" action="{{ url_for('admin_delete_course', course_target_id=course.id) }}">
131
+ <button type="submit" class="inline-action">删除</button>
132
+ </form>
133
+ </div>
134
+ {% endfor %}
135
+ {% else %}
136
+ <div class="empty-mini">当前没有课程目标。</div>
137
+ {% endif %}
138
+ </div>
139
+ </section>
140
+ {% else %}
141
+ <div class="empty-state-card">
142
+ 还没有录入任何用户,请先通过上方表单创建用户。
143
+ </div>
144
+ {% endfor %}
145
+ </div>
146
+ </section>
147
+ {% endblock %}