Split admin features into separate pages
Browse files- space_app.py +961 -842
- static/style.css +759 -703
- templates/admin_dashboard.html +125 -366
- templates/admin_layout.html +27 -0
- templates/admin_logs.html +28 -0
- templates/admin_registration_codes.html +81 -0
- templates/admin_schedules.html +116 -0
- templates/admin_users.html +147 -0
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 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
from core.
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
from core.
|
| 23 |
-
from core.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
app
|
| 73 |
-
app.
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
config.
|
| 78 |
-
config.
|
| 79 |
-
config.
|
| 80 |
-
config.
|
| 81 |
-
config.
|
| 82 |
-
config.
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
"
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
"
|
| 92 |
-
"
|
| 93 |
-
"
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
"/
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
request.
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
"
|
| 151 |
-
"
|
| 152 |
-
"
|
| 153 |
-
"
|
| 154 |
-
"
|
| 155 |
-
"
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
"
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
"
|
| 262 |
-
"
|
| 263 |
-
"
|
| 264 |
-
"
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
"
|
| 278 |
-
"
|
| 279 |
-
"
|
| 280 |
-
"
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
"
|
| 292 |
-
"
|
| 293 |
-
"
|
| 294 |
-
"
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
"
|
| 315 |
-
"
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
def
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 554 |
-
@_login_required("admin")
|
| 555 |
-
def
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
@
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
)
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
flash("用户
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
store.
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
return
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
@app.post("/admin/
|
| 721 |
-
@_login_required("admin")
|
| 722 |
-
def
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
@
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
return
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 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=
|
| 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 |
-
.
|
| 359 |
-
display:
|
| 360 |
-
|
| 361 |
-
gap:
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
.
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
}
|
| 377 |
-
|
| 378 |
-
.
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
.
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
}
|
| 409 |
-
|
| 410 |
-
.
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
.
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
.
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
.status-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
.live-dot {
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
background:
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
.
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
border:
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
.
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
.
|
| 533 |
-
color:
|
| 534 |
-
}
|
| 535 |
-
|
| 536 |
-
.
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
}
|
| 563 |
-
|
| 564 |
-
.
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
}
|
| 579 |
-
|
| 580 |
-
.
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
.
|
| 602 |
-
|
| 603 |
-
}
|
| 604 |
-
|
| 605 |
-
.
|
| 606 |
-
|
| 607 |
-
}
|
| 608 |
-
|
| 609 |
-
.
|
| 610 |
-
|
| 611 |
-
}
|
| 612 |
-
|
| 613 |
-
.
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
}
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
}
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 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 "
|
| 2 |
-
{% block
|
| 3 |
-
{% block
|
| 4 |
-
|
| 5 |
-
<
|
| 6 |
-
|
| 7 |
-
<
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
</
|
| 12 |
-
<
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
<
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
<
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
</
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
</
|
| 28 |
-
<
|
| 29 |
-
|
| 30 |
-
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
</
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
</label>
|
| 103 |
-
<button type="submit" class="btn btn-secondary">生成注册码</button>
|
| 104 |
-
</form>
|
| 105 |
-
</article>
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
</div>
|
| 141 |
-
<span class="status-pill status-running">实时刷新</span>
|
| 142 |
</div>
|
| 143 |
-
<
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
</
|
| 156 |
-
|
| 157 |
-
<
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
</tr>
|
| 175 |
-
{%
|
| 176 |
-
|
| 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 |
-
<
|
| 194 |
-
<th>备注</th>
|
| 195 |
-
<th>状态</th>
|
| 196 |
-
<th>使用</th>
|
| 197 |
-
<th>最近使用者</th>
|
| 198 |
-
<th>操作</th>
|
| 199 |
</tr>
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 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 %}
|