diff --git a/shortcut_trainer/README.md b/shortcut_trainer/README.md new file mode 100644 index 0000000..6d2fae1 --- /dev/null +++ b/shortcut_trainer/README.md @@ -0,0 +1,85 @@ +# 快捷键训练比赛系统 + +这是一个独立的小程序。学生账号直接使用原分组系统的 `selfgroup.db`;训练相关数据表也创建在同一个数据库里,但都带 `trainer_` 前缀,不会改动原来的分组表。 + +## 启动 + +在本仓库根目录执行: + +```bash +python3 shortcut_trainer/app.py +``` + +- 学生端: +- 教师端: + +程序默认用 IPv4/IPv6 双栈监听所有网卡。启动日志会尽量列出本机可访问地址;学生从局域网访问时,选择老师电脑的局域网 IPv4,例如 `http://192.168.1.20:8010/`。 + +训练页使用 6 种高区分度整屏背景色,并显示超大页码水印,老师从学生屏幕缩略图即可快速确认是否切到了指定页面。 + +## 共享数据库 + +程序默认直接使用项目根目录: + +```text +selfgroup.db +``` + +它读取其中的 `students` 表,登录会话写入原来的 `sessions` 表;训练数据保存在这些新表: + +```text +trainer_presence +trainer_rounds +trainer_scores +trainer_events +``` + +所以你在原分组系统教师后台修改学生密码后,学生立刻可以用新密码登录快捷键训练系统。 + +如果你把 `selfgroup.db` 拷贝到了 `shortcut_trainer/` 目录,这个拷贝文件不会再被使用。可以移走它,避免以后混淆。如需指定其它共享数据库,可这样启动: + +```bash +SHARED_DB=/path/to/selfgroup.db python3 shortcut_trainer/app.py +``` + +## 学生账号 + +教师端「学生账号」面板可以搜索学生、重置密码、添加新账号,改动直接写入共享的 `selfgroup.db`。 + +程序启动时会自动补齐这些测试账号(若学号已存在则跳过): + +| 学号 | 姓名 | 班级 | 密码 | +| --- | --- | --- | --- | +| test01 | 测试甲 | 测试 | test1234 | +| test02 | 测试乙 | 测试 | test1234 | +| test03 | 测试丙 | 测试 | test1234 | +| test04 | 测试丁 | 测试 | test1234 | +| test05 | 测试戊 | 测试 | test1234 | + + +程序启动时会自动读取学生名单库,优先使用: + +1. `shortcut_trainer/selfgroup.db`(如果你把数据库拷贝到了这里) +2. 项目根目录的 `selfgroup.db` + +教师端使用同一个 `settings` 表里的 `teacher_username` 和 `teacher_password`。如果原分组系统已修改过教师密码,快捷键教师端也使用修改后的密码。 + +## 课堂流程 + +1. 学生登录首页,允许 Chrome 弹出窗口。 +2. 学生打开 4–6 个训练页;也可按 `Alt+P/Q/R/T/Y/U`。 +3. 教师端用可视化表单发布指令:先选目标页面,再选“点击指定按钮”“多步快捷键”“右键菜单”“窗口操作”或“演示链接”,最后选具体目标;不需要手写 JSON。 +4. 学生完成操作后,教师端实时显示前 10 名。 + +训练页按钮做成 Word 式功能面板:`Alt+K` 展开检测面板(`C` 燃料检查、`D` 系统诊断),`Alt+H` 展开推进面板(`R` 火箭准备、`T` 通讯开启),`Alt+B` 展开保障面板(`P` 备用电源、`L` 外部链接)。`L` 之后再按 `A` 打开百度,或按 `B` 打开 Bilibili。也可以继续用 `Alt+1` 到 `Alt+6` 或鼠标点击,判定相同。 + +## 可自动判定的操作 + +- 打开指定训练页; +- 点击指定页面按钮(支持按钮序号或名称:火箭准备、燃料检查、通讯开启、系统诊断、备用电源、外部链接); +- 页面自定义右键菜单 + `E/R/C`; +- 外部链接菜单:`Alt+B` 后按 `L`,再按 `A` 打开百度或 `B` 打开 Bilibili。 + +## 浏览器限制 + +Chrome/Windows 会优先处理 `Ctrl+T`、`Ctrl+W`、`Ctrl+N`、`Alt+F4`、`Win+D` 等系统级快捷键;普通网页通常无法可靠拦截这些按键,也无法检测学生手工输入网址或真实窗口菜单。这些内容适合演示和人工观察。训练系统用自定义组合键和模拟窗口完成可计分练习。 diff --git a/shortcut_trainer/app.py b/shortcut_trainer/app.py new file mode 100644 index 0000000..fc05e8b --- /dev/null +++ b/shortcut_trainer/app.py @@ -0,0 +1,795 @@ +#!/usr/bin/env python3 +"""快捷键公开课训练系统。 + +零第三方依赖。学生账号直接使用原分组系统的 selfgroup.db; +训练相关数据表也创建在同一个数据库中,并使用 trainer_ 前缀。 +""" + +from __future__ import annotations + +import errno +import hashlib +import hmac +import json +import logging +import os +import re +import secrets +import shutil +import socket +import sqlite3 +import subprocess +import threading +import time +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlsplit + +BASE_DIR = Path(__file__).resolve().parent +STATIC_DIR = BASE_DIR / "static" +DB_PATH = Path(os.environ.get("SHARED_DB", BASE_DIR.parent / "selfgroup.db")) +PORT = int(os.environ.get("PORT", "8010")) +HOST = os.environ.get("HOST", "0.0.0.0") +TEACHER_USER = os.environ.get("TEACHER_USER", "teacher") +TEACHER_PASSWORD = os.environ.get("TEACHER_PASSWORD", "teacher123") +SESSION_DAYS = 2 +PAGES = [ + {"page": 1, "key": "P", "title": "红色火星站", "color": "#ff3b52"}, + {"page": 2, "key": "Q", "title": "蓝色深海舱", "color": "#357bff"}, + {"page": 3, "key": "R", "title": "绿色能量站", "color": "#00d164"}, + {"page": 4, "key": "T", "title": "黄色指挥所", "color": "#ffdb00"}, + {"page": 5, "key": "Y", "title": "紫色观测台", "color": "#c159ff"}, + {"page": 6, "key": "U", "title": "橙色发射场", "color": "#ff7a18"}, +] +BUTTON_NAMES = ["火箭准备", "燃料检查", "通讯开启", "系统诊断", "备用电源", "外部链接"] +TEST_PASSWORD = "test1234" +TEST_STUDENTS = [ + ("测试", "test01", "测试甲"), + ("测试", "test02", "测试乙"), + ("测试", "test03", "测试丙"), + ("测试", "test04", "测试丁"), + ("测试", "test05", "测试戊"), +] + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +LOG = logging.getLogger("shortcut-trainer") + + +def hash_password(password: str, salt: bytes | None = None) -> str: + salt = salt or secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 180_000) + return salt.hex() + "$" + digest.hex() + + +def verify_password(password: str, stored: str | None) -> bool: + if not stored or "$" not in stored: + return False + try: + salt_hex, digest_hex = stored.split("$", 1) + salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(digest_hex) + # 原分组系统使用 180000 次;早期训练库使用过 120000 次。 + return any( + hmac.compare_digest( + hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations), + expected, + ) + for iterations in (180_000, 120_000) + ) + except (ValueError, TypeError): + return False + + +class Store: + def __init__(self, path: Path): + self.path = path + self.lock = threading.RLock() + self.conn = sqlite3.connect(str(path), check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA foreign_keys = ON") + self.conn.execute("PRAGMA busy_timeout = 5000") + self.init_db() + + def init_db(self) -> None: + with self.lock, self.conn: + self.conn.executescript( + """ + CREATE TABLE IF NOT EXISTS trainer_presence ( + student_id INTEGER PRIMARY KEY REFERENCES students(id) ON DELETE CASCADE, + last_seen INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS trainer_rounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + expected_json TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS trainer_scores ( + round_id INTEGER NOT NULL REFERENCES trainer_rounds(id) ON DELETE CASCADE, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + correct INTEGER NOT NULL, + finished_at INTEGER NOT NULL, + payload TEXT NOT NULL, + UNIQUE(round_id, student_id) + ); + CREATE TABLE IF NOT EXISTS trainer_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round_id INTEGER REFERENCES trainer_rounds(id) ON DELETE SET NULL, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + page_no INTEGER, + item_index INTEGER, + value TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_trainer_events_round + ON trainer_events(round_id, created_at); + """ + ) + self.conn.execute( + "INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_username',?)", (TEACHER_USER,) + ) + self.conn.execute( + "INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_password',?)", + (hash_password(TEACHER_PASSWORD),), + ) + self.ensure_test_students() + + def setting(self, key: str) -> str | None: + with self.lock: + row = self.conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + return row["value"] if row else None + + def login_student(self, username: str, password: str) -> tuple[dict | None, str]: + username = username.strip() + with self.lock: + row = self.conn.execute( + "SELECT * FROM students WHERE student_no=?", (username,) + ).fetchone() + if not row or not verify_password(password, row["password_hash"]): + return None, "学号或密码不正确" + token = self.create_session("student", username, row["id"]) + return {"token": token, "student": dict(row)}, "登录成功" + + def login_teacher(self, username: str, password: str) -> tuple[dict | None, str]: + if username.strip() != (self.setting("teacher_username") or TEACHER_USER): + return None, "教师账号或密码不正确" + if not verify_password(password, self.setting("teacher_password")): + return None, "教师账号或密码不正确" + token = self.create_session("teacher", username) + return {"token": token}, "登录成功" + + def create_session(self, role: str, username: str, student_id: int | None = None) -> str: + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode()).hexdigest() + expires = int(time.time() + SESSION_DAYS * 86400) + with self.lock, self.conn: + self.conn.execute( + """INSERT INTO sessions(token_hash,role,username,student_id,created_at,expires_at) + VALUES(?,?,?,?,?,?)""", + (token_hash, role, username, student_id, int(time.time()), expires), + ) + return token + + def session(self, token: str | None) -> dict | None: + if not token: + return None + token_hash = hashlib.sha256(token.encode()).hexdigest() + now = int(time.time()) + with self.lock: + row = self.conn.execute( + "SELECT * FROM sessions WHERE token_hash=? AND expires_at>?", (token_hash, now) + ).fetchone() + return dict(row) if row else None + + def logout(self, token: str | None) -> None: + if not token: + return + with self.lock, self.conn: + self.conn.execute("DELETE FROM sessions WHERE token_hash=?", (hashlib.sha256(token.encode()).hexdigest(),)) + + def student(self, student_id: int) -> dict | None: + with self.lock: + row = self.conn.execute("SELECT * FROM students WHERE id=?", (student_id,)).fetchone() + return dict(row) if row else None + + def ensure_test_students(self) -> None: + with self.lock, self.conn: + for class_name, student_no, name in TEST_STUDENTS: + exists = self.conn.execute( + "SELECT id FROM students WHERE student_no=?", (student_no,) + ).fetchone() + if exists: + continue + self.conn.execute( + """INSERT INTO students + (class_name,student_no,name,gender,admission_no,remark,password_hash,must_change_password) + VALUES(?,?,?,'','','测试账号',?,0)""", + (class_name, student_no, name, hash_password(TEST_PASSWORD)), + ) + LOG.info("已创建测试账号 %s / %s", student_no, TEST_PASSWORD) + + def teacher_set_student_password(self, student_id: int, new_password: str) -> tuple[bool, str]: + new_password = new_password or "" + if len(new_password) < 8: + return False, "新密码至少 8 位" + with self.lock, self.conn: + student = self.conn.execute( + "SELECT student_no,name FROM students WHERE id=?", (student_id,) + ).fetchone() + if student is None: + return False, "学生不存在" + if new_password == student["student_no"]: + return False, "新密码不能与学号相同" + self.conn.execute( + "UPDATE students SET password_hash=?, must_change_password=0 WHERE id=?", + (hash_password(new_password), student_id), + ) + self.conn.execute("DELETE FROM sessions WHERE student_id=?", (student_id,)) + return True, f"已修改 {student['student_no']} {student['name']} 的密码" + + def create_student(self, class_name: str, student_no: str, name: str, password: str) -> tuple[bool, str]: + class_name = (class_name or "").strip() + student_no = (student_no or "").strip() + name = (name or "").strip() + password = password or "" + if not class_name or not student_no or not name: + return False, "班级、学号和姓名不能为空" + if len(password) < 8: + return False, "密码至少 8 位" + if password == student_no: + return False, "密码不能与学号相同" + with self.lock, self.conn: + exists = self.conn.execute( + "SELECT 1 FROM students WHERE student_no=?", (student_no,) + ).fetchone() + if exists: + return False, "学号已存在" + self.conn.execute( + """INSERT INTO students + (class_name,student_no,name,gender,admission_no,remark,password_hash,must_change_password) + VALUES(?,?,?,'','','教师添加',?,0)""", + (class_name, student_no, name, hash_password(password)), + ) + return True, f"已添加 {student_no} {name}" + + def change_password(self, student_id: int, old: str, new: str) -> tuple[bool, str]: + if len(new) < 6: + return False, "新密码至少 6 位" + row = self.student(student_id) + if not row or not verify_password(old, row["password_hash"]): + return False, "原密码不正确" + with self.lock, self.conn: + self.conn.execute( + "UPDATE students SET password_hash=?, must_change_password=0 WHERE id=?", + (hash_password(new), student_id), + ) + return True, "密码已修改" + + def touch(self, student_id: int) -> None: + with self.lock, self.conn: + self.conn.execute( + "INSERT INTO trainer_presence(student_id,last_seen) VALUES(?,?) " + "ON CONFLICT(student_id) DO UPDATE SET last_seen=excluded.last_seen", + (student_id, int(time.time())), + ) + + def active_round(self) -> dict | None: + with self.lock: + row = self.conn.execute( + "SELECT * FROM trainer_rounds WHERE active=1 ORDER BY id DESC LIMIT 1" + ).fetchone() + if not row: + return None + return {**dict(row), "expected": json.loads(row["expected_json"])} + + def start_round(self, label: str, expected: dict) -> dict: + now = int(time.time()) + with self.lock, self.conn: + self.conn.execute("UPDATE trainer_rounds SET active=0 WHERE active=1") + self._progress = {} + cur = self.conn.execute( + "INSERT INTO trainer_rounds(label,expected_json,active,created_at) VALUES(?,?,1,?)", + (label, json.dumps(expected, ensure_ascii=False), now), + ) + round_id = cur.lastrowid + return {"id": round_id, "label": label, "expected": expected, "active": 1, "created_at": now} + + def stop_round(self) -> None: + with self.lock, self.conn: + self.conn.execute("UPDATE trainer_rounds SET active=0 WHERE active=1") + + def add_event(self, student_id: int, data: dict) -> dict: + kind = str(data.get("type", "")).lower() + aliases = {"page": "page", "button": "button", "shortcut": "shortcut", "key": "shortcut", + "menu": "menu", "window": "window", "url": "url", "link": "url"} + kind = aliases.get(kind, "") + if not kind: + return {"ok": False, "message": "未知操作类型"} + round_id = self.active_round()["id"] if self.active_round() else None + page_no = int(data.get("page") or data.get("pageNo") or 0) + try: + item_index = int(data.get("index") or data.get("button") or 0) + except (TypeError, ValueError): + item_index = 0 + value = str(data.get("key") or data.get("action") or data.get("url") or data.get("name") or data.get("item") or "") + now = int(time.time()) + with self.lock, self.conn: + self.conn.execute( + "INSERT INTO trainer_events(round_id,student_id,kind,page_no,item_index,value,created_at) VALUES(?,?,?,?,?,?,?)", + (round_id, student_id, kind, page_no, item_index, value, now), + ) + result = self.score(student_id, round_id, kind, page_no, item_index, value) if round_id else None + return { + "ok": True, + "accepted": True, + "correct": bool(result), + "roundId": round_id, + "result": result, + } + + @staticmethod + def normalize_key(value: str) -> str: + parts = [p.strip().upper() for p in str(value).replace(" ", "").split("+") if p.strip()] + mods = [m for m in ("CTRL", "CONTROL", "ALT", "SHIFT", "META", "CMD") if m in parts or (m == "CTRL" and "CONTROL" in parts)] + mods = ["CTRL" if m in ("CTRL", "CONTROL") else m for m in mods] + rest = [p for p in parts if p not in ("CTRL", "CONTROL", "ALT", "SHIFT", "META", "CMD")] + return "+".join(mods + rest) + + def _step_match(self, step: dict, kind: str, page_no: int, item_index: int, value: str) -> bool: + step_type = str(step.get("type", "")).lower() + if kind != step_type: + return False + if kind == "page": + return page_no == int(step.get("page", 0) or 0) + if kind == "button": + return ( + (not step.get("index") or item_index == int(step["index"])) + and (not step.get("name") or value == str(step["name"])) + and (not step.get("page") or page_no == int(step["page"])) + ) + if kind == "shortcut": + return self.normalize_key(value) == self.normalize_key(step.get("key", "")) + if kind == "menu": + return str(step.get("item", "")).lower() == value.lower() + if kind == "window": + return str(step.get("action", "")).lower() == value.lower() + if kind == "url": + expected = str(step.get("url", "")).lower().replace("https://", "").replace("http://", "").lstrip("www.") + actual = value.lower().replace("https://", "").replace("http://", "").lstrip("www.") + return expected in actual + return False + + def score(self, student_id: int, round_id: int, kind: str, page_no: int, item_index: int, value: str) -> dict | None: + with self.lock: + row = self.conn.execute( + "SELECT * FROM trainer_rounds WHERE id=? AND active=1", (round_id,) + ).fetchone() + if not row: + return None + steps = json.loads(row["expected_json"]).get("steps", []) + key = f"{round_id}:{student_id}" + if not hasattr(self, "_progress"): + self._progress = {} + progress = self._progress.get(key, 0) + if progress >= len(steps): + return None + while progress < len(steps): + step = steps[progress] + if ( + kind != "page" + and str(step.get("type", "")).lower() == "page" + and page_no == int(step.get("page") or 0) + ): + progress += 1 + continue + break + if progress < len(steps): + if not self._step_match(steps[progress], kind, page_no, item_index, value): + self._progress[key] = progress + return None + progress += 1 + if progress < len(steps): + self._progress[key] = progress + return None + self._progress.pop(key, None) + + now = int(time.time()) + with self.lock, self.conn: + self.conn.execute( + """INSERT OR IGNORE INTO trainer_scores(round_id,student_id,correct,finished_at,payload) + VALUES(?,?,?,?,?)""", + (round_id, student_id, 1, now, json.dumps({"page": page_no, "index": item_index, "value": value}, ensure_ascii=False)), + ) + row = self.conn.execute( + """SELECT rank_no AS rank, total, finished_at FROM ( + SELECT student_id, finished_at, + ROW_NUMBER() OVER(ORDER BY finished_at, student_id) AS rank_no, + COUNT(*) OVER() AS total + FROM trainer_scores WHERE round_id=? AND correct=1 + ) ranked WHERE student_id=?""", + (round_id, student_id), + ).fetchone() + if not row: + return {"rank": 1, "total": 1, "finishedAt": now} + return {"rank": int(row["rank"]), "total": int(row["total"]), "finishedAt": int(row["finished_at"])} + + def student_result(self, student_id: int, round_id: int | None = None) -> dict | None: + if round_id is None: + active = self.active_round() + round_id = active["id"] if active else None + if not round_id: + return None + with self.lock: + row = self.conn.execute( + "SELECT finished_at FROM trainer_scores WHERE round_id=? AND student_id=? AND correct=1", + (round_id, student_id), + ).fetchone() + if not row: + return None + total = self.conn.execute( + "SELECT COUNT(*) AS n FROM trainer_scores WHERE round_id=? AND correct=1", + (round_id,), + ).fetchone()["n"] + rank = self.conn.execute( + """SELECT COUNT(*) AS n FROM trainer_scores + WHERE round_id=? AND correct=1 AND ( + finished_at dict: + cutoff = int(time.time() - 25) + active = self.active_round() + round_id = active["id"] if active else None + with self.lock: + students = self.conn.execute( + """SELECT id,class_name,student_no,name,must_change_password + FROM students ORDER BY class_name,student_no""" + ).fetchall() + presence = { + r["student_id"]: r["last_seen"] + for r in self.conn.execute( + "SELECT student_id,last_seen FROM trainer_presence WHERE last_seen>?", (cutoff,) + ) + } + scores = [] + events = [] + if round_id: + scores = self.conn.execute( + """SELECT s.rank_no AS rank, st.name, st.student_no, st.class_name, sc.finished_at + FROM (SELECT ROW_NUMBER() OVER(ORDER BY finished_at) AS rank_no, student_id, finished_at + FROM trainer_scores WHERE round_id=? AND correct=1) s + JOIN students st ON st.id=s.student_id + JOIN trainer_scores sc ON sc.round_id=? AND sc.student_id=s.student_id + ORDER BY s.rank_no LIMIT 30""", + (round_id, round_id), + ).fetchall() + events = self.conn.execute( + """SELECT e.id, e.kind, e.page_no, e.item_index, e.value, e.created_at, + st.name, st.student_no + FROM trainer_events e JOIN students st ON st.id=e.student_id + WHERE e.round_id=? ORDER BY e.id DESC LIMIT 100""", + (round_id,), + ).fetchall() + return { + "round": active, + "students": [ + {**dict(s), "online": s["id"] in presence, "finished": False} + for s in students + ], + "scores": [dict(r) for r in scores], + "events": [dict(r) for r in events], + "pages": PAGES, + } + + +STORE = Store(DB_PATH) + + +class Handler(BaseHTTPRequestHandler): + server_version = "ShortcutTrainer/1.0" + + def log_message(self, fmt, *args): + return + + def do_GET(self): + path = urlsplit(self.path).path + if path.startswith("/api/"): + self.api_get(path) + return + if path == "/": + self.serve("index.html", "text/html; charset=utf-8") + elif path.startswith("/page/"): + self.serve("page.html", "text/html; charset=utf-8") + elif path == "/teacher": + self.serve("teacher.html", "text/html; charset=utf-8") + else: + self.static(path) + + def do_POST(self): + path = urlsplit(self.path).path + if not path.startswith("/api/"): + self.json(HTTPStatus.NOT_FOUND, {"ok": False, "message": "Not Found"}) + return + try: + length = min(int(self.headers.get("Content-Length", "0")), 1024 * 256) + data = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + except Exception: + self.json(HTTPStatus.BAD_REQUEST, {"ok": False, "message": "请求格式错误"}) + return + action = path[len("/api/"):] + if action == "login": + result, msg = STORE.login_student(str(data.get("username", "")), str(data.get("password", ""))) + if result: + self.set_cookie(result["token"]) + self.json(HTTPStatus.OK, {"ok": True, "message": msg, "student": { + "name": result["student"]["name"], "studentNo": result["student"]["student_no"], + "className": result["student"]["class_name"], + "mustChangePassword": bool(result["student"]["must_change_password"]) + }}) + else: + self.json(HTTPStatus.OK, {"ok": False, "message": msg}) + elif action == "teacher/login": + result, msg = STORE.login_teacher(str(data.get("username", "")), str(data.get("password", ""))) + if result: + self.set_cookie(result["token"]) + self.json(HTTPStatus.OK, {"ok": bool(result), "message": msg}) + elif action == "logout": + STORE.logout(self.cookie_token()) + self.set_cookie("", expire=True) + self.json(HTTPStatus.OK, {"ok": True}) + elif action == "password": + session = STORE.session(self.cookie_token()) + if not session or session["role"] != "student": + self.json(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录"}) + return + ok, msg = STORE.change_password(session["student_id"], str(data.get("oldPassword", "")), str(data.get("newPassword", ""))) + self.json(HTTPStatus.OK, {"ok": ok, "message": msg}) + elif action == "event": + session = STORE.session(self.cookie_token()) + if not session or session["role"] != "student": + self.json(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "未登录"}) + return + STORE.touch(session["student_id"]) + self.json(HTTPStatus.OK, STORE.add_event(session["student_id"], data)) + elif action == "teacher/round": + if not self.require_teacher(): + return + expected = data.get("expected") + if not isinstance(expected, dict) or not isinstance(expected.get("steps"), list) or not expected["steps"]: + self.json(HTTPStatus.OK, {"ok": False, "message": "请提供 expected.steps"}) + return + round_data = STORE.start_round(str(data.get("label", "新指令")), expected) + self.json(HTTPStatus.OK, {"ok": True, "round": round_data}) + elif action == "teacher/stop": + if not self.require_teacher(): + return + STORE.stop_round() + self.json(HTTPStatus.OK, {"ok": True}) + elif action == "teacher/student-password": + if not self.require_teacher(): + return + try: + student_id = int(data.get("studentId") or data.get("student_id") or 0) + except (TypeError, ValueError): + student_id = 0 + ok, msg = STORE.teacher_set_student_password( + student_id, str(data.get("newPassword") or data.get("new_password") or "") + ) + self.json(HTTPStatus.OK, {"ok": ok, "message": msg}) + elif action == "teacher/student": + if not self.require_teacher(): + return + ok, msg = STORE.create_student( + str(data.get("className") or data.get("class_name") or ""), + str(data.get("studentNo") or data.get("student_no") or ""), + str(data.get("name") or ""), + str(data.get("password") or ""), + ) + self.json(HTTPStatus.OK, {"ok": ok, "message": msg}) + else: + self.json(HTTPStatus.NOT_FOUND, {"ok": False, "message": "接口不存在"}) + + def api_get(self, path: None): + return + + def require_teacher(self) -> bool: + session = STORE.session(self.cookie_token()) + if not session or session["role"] != "teacher": + self.json(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录教师端"}) + return False + return True + + def cookie_token(self) -> str | None: + raw = self.headers.get("Cookie", "") + for item in raw.split(";"): + if "=" in item: + k, v = item.strip().split("=", 1) + if k == "trainer_session": + return unquote(v) + return None + + def set_cookie(self, token: str, expire: bool = False): + self.cookie_to_set = (token or "", expire) + + def json(self, status: int, payload: dict): + body = json.dumps(payload, ensure_ascii=False).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + if getattr(self, "cookie_to_set", None) is not None: + value, expire = self.cookie_to_set + max_age = 0 if expire else SESSION_DAYS * 86400 + self.send_header("Set-Cookie", f"trainer_session={value}; Path=/; Max-Age={max_age}; SameSite=Lax") + self.cookie_to_set = None + self.end_headers() + self.wfile.write(body) + + def static(self, path: str): + name = path.lstrip("/") + types = {".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon"} + suffix = Path(name).suffix.lower() + safe = Path(name).name + if suffix not in types or not (STATIC_DIR / safe).is_file(): + self.send_response(HTTPStatus.NOT_FOUND) + self.end_headers() + return + self.serve(safe, types[suffix]) + + def serve(self, name: str, content_type: str): + body = (STATIC_DIR / name).read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-cache") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def student_state(session: dict) -> dict: + student = STORE.student(session["student_id"]) + STORE.touch(session["student_id"]) + active = STORE.active_round() + safe_student = None + if student: + safe_student = { + "id": student["id"], + "className": student["class_name"], + "studentNo": student["student_no"], + "name": student["name"], + "mustChangePassword": bool(student["must_change_password"]), + } + if active: + active = {k: active[k] for k in ("id", "label", "active", "created_at")} + result = STORE.student_result(session["student_id"]) if student else None + return { + "ok": True, + "student": safe_student, + "round": active, + "result": result, + "pages": PAGES, + } + + +# 把学生端接口挂到 Handler;为保持单文件清晰,这里用方法注入。 +def api_get_handler(handler, path: str): + action = path[len("/api/"):] + if action in ("me", "student/state"): + session = STORE.session(handler.cookie_token()) + if not session or session["role"] != "student": + handler.json(HTTPStatus.OK, {"ok": True, "authenticated": False}) + return + handler.json(HTTPStatus.OK, {**student_state(session), "authenticated": True}) + elif action == "teacher/state": + if not handler.require_teacher(): + return + handler.json(HTTPStatus.OK, {"ok": True, **STORE.teacher_state()}) + else: + handler.json(HTTPStatus.NOT_FOUND, {"ok": False, "message": "接口不存在"}) + + +Handler.api_get = api_get_handler + + +class DualStackHTTPServer(ThreadingHTTPServer): + """监听通配 IPv6 地址,同时接受 IPv4 连接。""" + + address_family = socket.AF_INET6 + + def server_bind(self) -> None: + if hasattr(socket, "IPPROTO_IPV6") and hasattr(socket, "IPV6_V6ONLY"): + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + super().server_bind() + + +def discover_local_addresses() -> list[str]: + """尽量列出版本库常见部署环境里的本机地址。""" + addresses: set[str] = {"127.0.0.1", "::1"} + if os.name == "nt": + try: + text = subprocess.run( + ["ipconfig"], capture_output=True, text=True, check=False, timeout=2 + ).stdout + for line in text.splitlines(): + if re.search(r"IPv4|IP Address|IP 地址", line, re.IGNORECASE): + match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", line) + if match: + addresses.add(match.group(1)) + except (OSError, subprocess.SubprocessError): + pass + else: + try: + command = ["ip", "-o", "address"] if shutil.which("ip") else ["ifconfig"] + text = subprocess.run( + command, capture_output=True, text=True, check=False, timeout=2 + ).stdout + for match in re.finditer(r"inet (?:addr:)?(\d{1,3}(?:\.\d{1,3}){3})", text): + addresses.add(match.group(1)) + for match in re.finditer(r"inet6 ([0-9a-f:]+)", text, re.IGNORECASE): + if not match.group(1).lower().startswith("fe80"): + addresses.add(match.group(1)) + except (OSError, subprocess.SubprocessError): + pass + + names = {socket.gethostname(), "localhost"} + try: + names.add(socket.getfqdn()) + except OSError: + pass + + for name in tuple(names): + try: + addresses.update(socket.gethostbyname_ex(name)[2]) + except OSError: + pass + try: + for info in socket.getaddrinfo(name, PORT, type=socket.SOCK_STREAM): + addresses.add(info[4][0]) + except OSError: + pass + + skip = {"0.0.0.0", "::", "0:0:0:0:0:0:0:0"} + return sorted((item for item in addresses if item not in skip), key=lambda x: (":" in x, x)) + + +def server_url(address: str) -> str: + display = f"[{address}]" if ":" in address else address + return f"http://{display}:{PORT}" + + +def main(): + bind_host = "::" if HOST in {"", "*", "0.0.0.0"} else HOST + try: + server = DualStackHTTPServer((bind_host, PORT), Handler) + listen_mode = "IPv4 + IPv6 全部网卡" + except OSError as exc: + # 只在系统不支持 IPv6/双栈时退回 IPv4;端口被占用仍应直接暴露错误。 + if exc.errno not in { + errno.EADDRNOTAVAIL, + errno.EAFNOSUPPORT, + errno.EPROTONOSUPPORT, + }: + raise + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + listen_mode = "IPv4 全部网卡" + + LOG.info("快捷键训练系统已启动,监听:%s", listen_mode) + for address in discover_local_addresses(): + LOG.info("学生端:%s", server_url(address)) + LOG.info("教师端:%s/teacher", server_url(address)) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/shortcut_trainer/shortcut_trainer.db-shm b/shortcut_trainer/shortcut_trainer.db-shm new file mode 100644 index 0000000..e85161c Binary files /dev/null and b/shortcut_trainer/shortcut_trainer.db-shm differ diff --git a/shortcut_trainer/shortcut_trainer.db-wal b/shortcut_trainer/shortcut_trainer.db-wal new file mode 100644 index 0000000..f1fdef3 Binary files /dev/null and b/shortcut_trainer/shortcut_trainer.db-wal differ diff --git a/shortcut_trainer/static/common.js b/shortcut_trainer/static/common.js new file mode 100644 index 0000000..dd04d0e --- /dev/null +++ b/shortcut_trainer/static/common.js @@ -0,0 +1,28 @@ +const API={async get(url){const r=await fetch(url,{cache:'no-store'});return r.json()},async post(url,data={}){const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});return r.json()},notify(el,msg){if(!el)return;el.textContent=msg;clearTimeout(el._t);el._t=setTimeout(()=>el.textContent='',3500)}}; +function shortcutFromEvent(e){const p=[];if(e.ctrlKey)p.push('Ctrl');if(e.altKey)p.push('Alt');if(e.shiftKey)p.push('Shift');if(e.metaKey)p.push('Meta');let k=e.key.toUpperCase();if(k===' ')k='Space';if(['CONTROL','ALT','SHIFT','META'].includes(k))return '';p.push(k);return p.join('+')} +function keyName(e){const code=e.code||'';if(/^Key[A-Z]$/.test(code))return code.slice(3);if(/^Digit[0-9]$/.test(code))return code.slice(5);if(/^Numpad[0-9]$/.test(code))return code.slice(6);if(code==='Space')return 'SPACE';if(code==='ArrowLeft')return 'ARROWLEFT';if(code==='ArrowRight')return 'ARROWRIGHT';if(code==='Escape')return 'ESCAPE';let k=(e.key||'').toUpperCase();if(k===' ')return 'SPACE';if(k==='ESC')return 'ESCAPE';return k} +function consumeKey(e){e.preventDefault();e.stopPropagation()} + +const RIBBON_PANELS=[ + {key:'K',title:'检测',items:[{key:'C',name:'燃料检查',index:2},{key:'D',name:'系统诊断',index:4}]}, + {key:'H',title:'推进',items:[{key:'R',name:'火箭准备',index:1},{key:'T',name:'通讯开启',index:3}]}, + {key:'B',title:'保障',items:[{key:'P',name:'备用电源',index:5},{key:'L',name:'外部链接',index:6}]} +]; +function ribbonPath(index){for(const panel of RIBBON_PANELS){for(const item of panel.items){if(item.index===Number(index)||item.name===index)return `Alt+${panel.key},然后 ${item.key}`}}return ''} +const LINK_TARGETS=[{key:'D',name:'百度',url:'https://www.baidu.com',aliases:['A','1']},{key:'F',name:'Bilibili',url:'https://www.bilibili.com',aliases:['B','2']}]; +function linkByKey(k){return LINK_TARGETS.find(item=>item.key===k||(item.aliases||[]).includes(k))} +function studentNoOf(student){return (student&&(student.studentNo||student.student_no))||''} +function studentLabel(student){return student?`${student.name||''} · ${studentNoOf(student)}`:''} +function studentMustChange(student){return !!(student&&(student.mustChangePassword||student.must_change_password))} +let lastRoundId=undefined; +let shownResult=null; +let shownRoundId=null; +function formatClock(ts){const d=new Date(Number(ts||0)*1000);if(!ts||Number.isNaN(d.getTime()))return '';return d.toLocaleString('zh-CN',{hour12:false})} +function renderTaskResult(){const box=document.getElementById('taskResult');if(!box)return;const current=shownResult&&shownRoundId===lastRoundId?shownResult:null;if(!current){box.classList.add('hidden');box.style.display='none';box.innerHTML='';return}const rank=Number(current.rank);const total=Number(current.total);box.classList.remove('hidden');box.style.display='flex';box.innerHTML=`操作成功完成时间:${formatClock(current.finishedAt)}当前排名:第 ${Number.isFinite(rank)&&rank>0?rank:1} 名 / 共 ${Number.isFinite(total)&&total>0?total:1} 人`} +function keepTaskResult(result,roundId){if(!result)return;shownResult=result;shownRoundId=roundId??lastRoundId;renderTaskResult()} +function clearTaskResult(){shownResult=null;shownRoundId=null;renderTaskResult()} +async function applyStudentState(s){if(!s||!s.authenticated)return s;showRound(s.round);const roundId=s.round?s.round.id:null;const roundChanged=lastRoundId!==undefined&&lastRoundId!==roundId;lastRoundId=roundId;const me=document.getElementById('userChip')||document.getElementById('who');if(me&&s.student)me.textContent=studentLabel(s.student);if(roundChanged){clearTaskResult();if(roundId){flash('老师发布了新任务','success');if(typeof no==='number'&&no>=1&&no<=6)await reportEvent({type:'page',page:no})}else flash('老师已结束本轮任务')}if(s.result)keepTaskResult(s.result,roundId);else renderTaskResult();return s} +async function reportEvent(data){try{const r=await API.post('/api/event',data);if(r.correct)keepTaskResult(r.result||{rank:1,total:1,finishedAt:Math.floor(Date.now()/1000)},r.roundId??lastRoundId);return r}catch(e){return {ok:false}}} +function flash(msg,type='warn'){let el=document.getElementById('globalFlash');if(!el){el=document.createElement('div');el.id='globalFlash';el.style.cssText='position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:999;padding:14px 22px;border-radius:12px;font-weight:800;color:#fff';document.body.appendChild(el)}el.textContent=msg;el.style.background=type==='success'?'#16a34a':'#d97706';clearTimeout(el._t);el._t=setTimeout(()=>el.remove(),2200)} +function showRound(round){const text=document.getElementById('commandText'),state=document.getElementById('roundState');if(text)text.textContent=round?round.label:'等待老师开始比赛…';if(state)state.textContent=round?`比赛进行中 · #${round.id}`:'未开始'} +async function refreshStudent(){const s=await API.get('/api/student/state');if(!s.authenticated)return false;if(studentMustChange(s.student))flash('请先回控制台修改密码');await applyStudentState(s);return true} diff --git a/shortcut_trainer/static/home.js b/shortcut_trainer/static/home.js new file mode 100644 index 0000000..2df93b7 --- /dev/null +++ b/shortcut_trainer/static/home.js @@ -0,0 +1,9 @@ +const PAGE_DEFS=[{page:1,key:'P',title:'红色火星站',color:'linear-gradient(145deg,#ff8a9b,#ff3b52 42%,#c40f31 74%,#4a0416)',desc:'按钮:火箭准备 / 燃料检查 / 通讯开启'},{page:2,key:'Q',title:'蓝色深海舱',color:'linear-gradient(145deg,#8ab4ff,#357bff 42%,#1146d6 74%,#041348)',desc:'按钮:声呐 / 压载舱 / 探照灯'},{page:3,key:'R',title:'绿色能量站',color:'linear-gradient(145deg,#7dffb4,#00d164 42%,#009e49 74%,#02321a)',desc:'按钮:风机 / 储能 / 调度'},{page:4,key:'T',title:'黄色指挥所',color:'linear-gradient(145deg,#fff176,#ffdb00 42%,#ff9800 74%,#4a2c00)',desc:'按钮:地图 / 队伍 / 命令'},{page:5,key:'Y',title:'紫色观测台',color:'linear-gradient(145deg,#e4a6ff,#c159ff 42%,#7c3aed 74%,#22065a)',desc:'按钮:星图 / 光谱 / 记录'},{page:6,key:'U',title:'橙色发射场',color:'linear-gradient(145deg,#ffb377,#ff7a18 42%,#e03a00 74%,#451000)',desc:'按钮:倒计时 / 气象 / 外部链接'}]; +let logged=false; +async function init(){const s=await API.get('/api/student/state');logged=!!s.authenticated;render();if(logged){await applyStudentState(s);setInterval(refreshStudent,1000)}} +function render(){document.getElementById('loginArea').classList.toggle('hidden',logged);document.getElementById('mainArea').classList.toggle('hidden',!logged);document.getElementById('toolbar').classList.toggle('hidden',!logged);if(!logged)return;const wrap=document.getElementById('pages');wrap.innerHTML='';PAGE_DEFS.forEach(p=>{const el=document.createElement('article');el.className='page-card';el.style.background=p.color;el.dataset.page=p.page;el.innerHTML=`Alt + ${p.key}

${p.page}. ${p.title}

${p.desc}

`;const b=document.createElement('button');b.textContent='在新标签页打开';b.onclick=()=>openPage(p.page);el.appendChild(b);wrap.appendChild(el)})} +function openPage(n){window.open(`/page/${n}`,'_blank')} +document.getElementById('loginForm').onsubmit=async e=>{e.preventDefault();const r=await API.post('/api/login',{username:username.value,password:password.value});if(!r.ok){API.notify(notice,r.message);return}const s=await API.get('/api/student/state');if(studentMustChange(s.student)){document.getElementById('passwordChange').classList.remove('hidden');API.notify(notice,'首次使用,请先修改密码');return}logged=true;notice.textContent='';render();await applyStudentState(s);setInterval(refreshStudent,1000)}; +document.getElementById('passwordChange').onsubmit=async e=>{e.preventDefault();const r=await API.post('/api/password',{oldPassword:oldPassword.value,newPassword:newPassword.value});API.notify(notice,r.message);if(r.ok){logged=true;document.getElementById('passwordChange').classList.add('hidden');render()}}; +document.getElementById('logout').onclick=async()=>{await API.post('/api/logout');location.reload()}; +document.addEventListener('keydown',e=>{if(!logged||e.ctrlKey||e.metaKey)return;const k=keyName(e);if(e.altKey&&'PQRTYU'.includes(k)){consumeKey(e);openPage('PQRTYU'.indexOf(k)+1);reportEvent({type:'page',page:'PQRTYU'.indexOf(k)+1})}},true); diff --git a/shortcut_trainer/static/index.html b/shortcut_trainer/static/index.html new file mode 100644 index 0000000..ad5d3f4 --- /dev/null +++ b/shortcut_trainer/static/index.html @@ -0,0 +1,13 @@ +快捷键训练场 +
键Windows 快捷键训练比赛
+
+
+ +
diff --git a/shortcut_trainer/static/page.html b/shortcut_trainer/static/page.html new file mode 100644 index 0000000..df349a9 --- /dev/null +++ b/shortcut_trainer/static/page.html @@ -0,0 +1,7 @@ +训练页
1训练页
加载中
+
当前指令

等待老师开始…

+

页面信息

正在准备本页内容…

+

功能面板

类似 Word 功能区:先按 Alt+K / H / B 展开对应面板,再按面板里的字母。

+

本页练习

外部链接:按 Alt+B 展开保障面板,再按 L,然后按 D 打开百度,或按 F 打开 Bilibili。不必继续按着 Alt。

在页面空白处右键,可打开练习菜单;菜单打开后按 E/R/C。

+ + diff --git a/shortcut_trainer/static/page.js b/shortcut_trainer/static/page.js new file mode 100644 index 0000000..d445bdd --- /dev/null +++ b/shortcut_trainer/static/page.js @@ -0,0 +1,140 @@ +const defs=[ + {key:'P',title:'红色火星站',color:'#ff3b52',light:'#ff8a9b',dark:'#c40f31',deep:'#4a0416',desc:'火星基地监控台:检查氧气、通讯和火箭准备状态。',facts:['氧气储备 82%','基地温度 -63°C','通讯延迟 3.2 秒']}, + {key:'Q',title:'蓝色深海舱',color:'#357bff',light:'#8ab4ff',dark:'#1146d6',deep:'#041348',desc:'深海实验舱控制台:观察压载、声呐和探照灯。',facts:['水深 3820 米','舱内压强 1.0 atm','声呐信号正常']}, + {key:'R',title:'绿色能量站',color:'#00d164',light:'#7dffb4',dark:'#009e49',deep:'#02321a',desc:'绿色能源调度台:管理风机、储能和供电负载。',facts:['风机功率 46 MW','储能电量 73%','今日减排 128 吨']}, + {key:'T',title:'黄色指挥所',color:'#ffdb00',light:'#fff176',dark:'#ff9800',deep:'#4a2c00',desc:'应急指挥所大屏:核对地图、队伍和命令下达。',facts:['在线队伍 6 组','待处理事件 2 件','指挥频道已加密']}, + {key:'Y',title:'紫色观测台',color:'#c159ff',light:'#e4a6ff',dark:'#7c3aed',deep:'#22065a',desc:'星空观测台:记录星图、光谱和观测日志。',facts:['目标星等 8.4','光谱类型 K2','曝光时间 30 秒']}, + {key:'U',title:'橙色发射场',color:'#ff7a18',light:'#ffb377',dark:'#e03a00',deep:'#451000',desc:'发射场控制台:确认倒计时、气象和外部通讯。',facts:['倒计时 T-12:00','风速 4.6 m/s','发射窗口 14:40']} +]; +const pathPage=Number((location.pathname.match(/\/page\/([1-6])\/?$/)||[])[1]); +const no=pathPage||Number(document.body.dataset.trainerPage)||1; +const def=defs[no-1]; +document.body.classList.add('training'); +document.body.style.setProperty('--page-color',def.color); +document.body.style.setProperty('--page-light',def.light); +document.body.style.setProperty('--page-dark',def.dark); +document.body.style.setProperty('--page-deep',def.deep); +let themeMeta=document.querySelector('meta[name="theme-color"]'); +if(!themeMeta){themeMeta=document.createElement('meta');themeMeta.name='theme-color';document.head.appendChild(themeMeta)} +themeMeta.content=def.dark; +document.body.dataset.trainerPage=String(no); +document.title=`${no}. ${def.title}`; +document.getElementById('pageTitle').textContent=`${no}. ${def.title}`; +document.getElementById('pageHeading').textContent=`${no}. ${def.title}`; +document.getElementById('pageDesc').textContent=def.desc; +const facts=document.getElementById('pageFacts');facts.innerHTML='';def.facts.forEach(text=>{const el=document.createElement('div');el.textContent=text;facts.appendChild(el)}); +const mark=document.getElementById('mark');mark.textContent=no;mark.style.background='rgba(255,255,255,.18)';mark.style.boxShadow='inset 0 0 0 2px #ffffff70'; +const buttons=['火箭准备','燃料检查','通讯开启','系统诊断','备用电源','外部链接']; +const ribbon=document.getElementById('ribbon'); +let ribbonKey='K'; +function openLink(item){ + window._linkMenu=false; + window._panel=null; + const popup=window.open(item.url,'_blank','noopener'); + renderRibbon('B',false); + flash(popup?`正在打开 ${item.name}`:'浏览器拦截了弹窗,请允许本站弹出式窗口'); + reportEvent({type:'url',url:item.url,page:no}); +} +function openLinkMenu(){ + window._panel=null; + window._linkMenu=true; + renderRibbon('B',true); + flash('请按 D 打开百度,或按 F 打开 Bilibili(不必按着 Alt)'); +} +function activateButton(index,via='click'){ + const name=buttons[index-1]; + if(name==='外部链接'){ + flash(via==='key'?'已打开外部链接菜单':'已点击:外部链接'); + reportEvent({type:'button',page:no,index,name}); + openLinkMenu(); + return; + } + flash(via==='key'?`快捷键已触发:${index}. ${name}`:`已点击:${name}`); + reportEvent({type:'button',page:no,index,name}); +} +function renderRibbon(activeKey=ribbonKey,keyboardOn=!!window._panel||!!window._linkMenu){ + ribbonKey=activeKey; + ribbon.className='ribbon'+(keyboardOn?' keyboard-on':''); + ribbon.innerHTML=''; + const tabs=document.createElement('div'); + tabs.className='ribbon-tabs'; + RIBBON_PANELS.forEach(panel=>{ + const tab=document.createElement('button'); + tab.type='button'; + tab.className='ribbon-tab'+(panel.key===activeKey?' active':'')+(window._panel===panel.key||(window._linkMenu&&panel.key==='B')?' armed':''); + tab.innerHTML=`Alt+${panel.key}${panel.title}`; + tab.onclick=()=>{window._panel=null;window._linkMenu=false;renderRibbon(panel.key,false)}; + tabs.appendChild(tab); + }); + const body=document.createElement('div'); + body.className='ribbon-body'; + if(window._linkMenu){ + const pane=document.createElement('div'); + pane.className='ribbon-panel active'; + const title=document.createElement('div'); + title.className='ribbon-subhint'; + title.textContent='外部链接:按 D 打开百度,按 F 打开 Bilibili'; + body.appendChild(title); + LINK_TARGETS.forEach(item=>{ + const b=document.createElement('button'); + b.type='button'; + b.className='ribbon-cmd '+(item.key==='A'?'success':'ghost'); + b.innerHTML=`${item.key}${item.name}`; + b.onclick=()=>openLink(item); + pane.appendChild(b); + }); + body.appendChild(pane); + }else{ + RIBBON_PANELS.forEach(panel=>{ + const pane=document.createElement('div'); + pane.className='ribbon-panel'+(panel.key===activeKey?' active':''); + panel.items.forEach(item=>{ + const b=document.createElement('button'); + b.type='button'; + b.className='ribbon-cmd '+(item.index%2?'success':'ghost'); + b.innerHTML=`${item.key}${item.index}. ${item.name}`; + b.onclick=()=>{window._panel=null;window._linkMenu=false;renderRibbon(panel.key,false);activateButton(item.index,'click')}; + pane.appendChild(b); + }); + body.appendChild(pane); + }); + } + ribbon.appendChild(tabs); + ribbon.appendChild(body); +} +function openRibbonPanel(key){ + const panel=RIBBON_PANELS.find(item=>item.key===key); + if(!panel)return; + window._linkMenu=false; + window._panel=key; + renderRibbon(key,true); + const letters=panel.items.map(item=>item.key).join(' 或 '); + flash(`已展开「${panel.title}」面板,请按 ${letters}`); +} +renderRibbon('K',false); +(async()=>{const s=await API.get('/api/student/state');if(s.authenticated){await applyStudentState(s);setInterval(refreshStudent,1000)}else location.href='/'})(); +reportEvent({type:'page',page:no}); +const ctx=document.getElementById('contextMenu'); +document.addEventListener('keydown',e=>{ + if(e.repeat)return; + const k=keyName(e); + const menu=!ctx.classList.contains('hidden'); + if(menu){const map={E:'refresh',R:'rename',C:'copy'};if(map[k]){consumeKey(e);ctx.classList.add('hidden');flash(`快捷菜单:${map[k]}`);reportEvent({type:'menu',item:map[k],page:no});return}} + if(window._linkMenu){ + const item=linkByKey(k); + if(item){consumeKey(e);openLink(item);return} + if(k==='ESCAPE'){consumeKey(e);window._linkMenu=false;renderRibbon('B',false);return} + if(!e.altKey){consumeKey(e);return} + } + if(e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey&&/^[1-6]$/.test(k)){consumeKey(e);window._panel=null;window._linkMenu=false;renderRibbon(ribbonKey,false);activateButton(Number(k),'key');return} + if(e.altKey&&!e.ctrlKey&&!e.metaKey&&RIBBON_PANELS.some(panel=>panel.key===k)){consumeKey(e);openRibbonPanel(k);return} + if(window._panel){ + const panel=RIBBON_PANELS.find(item=>item.key===window._panel); + const item=panel.items.find(cmd=>cmd.key===k); + if(item){consumeKey(e);window._panel=null;renderRibbon(panel.key,false);activateButton(item.index,'key');return} + if(k==='ESCAPE'){consumeKey(e);window._panel=null;renderRibbon(panel.key,false);return} + } +},true); +document.addEventListener('contextmenu',e=>{e.preventDefault();ctx.style.left=Math.min(e.clientX,innerWidth-210)+'px';ctx.style.top=Math.min(e.clientY,innerHeight-160)+'px';ctx.classList.remove('hidden')}); +document.addEventListener('click',e=>{if(!ctx.contains(e.target))ctx.classList.add('hidden');if((window._panel||window._linkMenu)&&ribbon&&!ribbon.contains(e.target)){window._panel=null;window._linkMenu=false;renderRibbon(ribbonKey,false)}}); +document.querySelectorAll('[data-menu]').forEach(b=>b.onclick=async()=>{ctx.classList.add('hidden');flash('快捷菜单:'+b.dataset.menu);await reportEvent({type:'menu',item:b.dataset.menu,page:no})}); diff --git a/shortcut_trainer/static/style.css b/shortcut_trainer/static/style.css new file mode 100644 index 0000000..22e5cc4 --- /dev/null +++ b/shortcut_trainer/static/style.css @@ -0,0 +1,61 @@ +:root{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:#eef2f7;background:#08111f} +*{box-sizing:border-box} body{margin:0;min-height:100vh;background:radial-gradient(circle at 20% 10%,#20304f 0,transparent 45rem),#08111f} +.hidden{display:none!important} +.topbar{position:sticky;top:0;z-index:50;display:flex;align-items:center;justify-content:space-between;padding:14px 22px;background:rgba(8,17,31,.9);backdrop-filter:blur(12px);border-bottom:1px solid #253349} +body.training{min-height:100vh;color:#fff;background:repeating-linear-gradient(135deg,rgba(255,255,255,.065) 0 56px,transparent 56px 112px),radial-gradient(circle at 82% 0,var(--page-light) 0,transparent 30rem),linear-gradient(155deg,var(--page-color) 0,var(--page-dark) 49%,var(--page-deep) 100%)!important;background-attachment:fixed} +body.training::after{content:attr(data-trainer-page);position:fixed;right:-2vw;bottom:-10vh;z-index:-1;font-size:min(72vh,86vw);font-weight:1000;line-height:.72;color:rgba(255,255,255,.24);text-shadow:0 0 48px rgba(0,0,0,.28);pointer-events:none;user-select:none} +body.training .topbar{background:rgba(0,0,0,.20);border-bottom:1px solid rgba(255,255,255,.22);backdrop-filter:blur(10px)} +body.training .brand-mark{box-shadow:inset 0 0 0 2px rgba(255,255,255,.45)} +body.training .chip{background:rgba(255,255,255,.15);border-color:rgba(255,255,255,.28)} +body.training .panel,body.training .modal{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.25);box-shadow:0 20px 54px rgba(0,0,0,.22)} +body.training .command{background:rgba(0,0,0,.25);border:3px solid rgba(255,255,255,.7);box-shadow:0 18px 48px rgba(0,0,0,.24)} +body.training .command h2{color:#fff;text-shadow:0 2px 10px rgba(0,0,0,.4)} +.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:20px} +.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#00c2ff,#7c3aed);color:#fff} +.toolbar{display:flex;gap:10px;align-items:center}.chip{padding:6px 10px;border-radius:99px;background:#182639;border:1px solid #31445f} +.page{max-width:1200px;margin:auto;padding:24px} +.panel,.login-card,.modal{background:rgba(16,28,46,.94);border:1px solid #303f58;border-radius:18px;padding:22px;box-shadow:0 18px 50px #0005} +.title{margin:0 0 6px;font-size:28px}.subtitle{color:#9fb0c8;margin-top:0} +label{display:block;margin:14px 0 6px;font-weight:600}input,select,textarea{width:100%;padding:11px;border-radius:10px;border:1px solid #42536e;background:#0d1a2c;color:#fff;font-size:16px} +button{border:0;border-radius:10px;padding:10px 16px;font-size:15px;font-weight:700;cursor:pointer;background:#2563eb;color:#fff;transition:.2s} +button:hover{transform:translateY(-1px)}.ghost{background:#1f2937}.success{background:#16a34a}.danger{background:#dc2626}.warn{background:#d97706}.full{width:100%} +.notice{min-height:22px;color:#fbbf24}.hint{color:#8fa1b8;font-size:14px} +.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.pages-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px} +.page-card{position:relative;overflow:hidden;border-radius:16px;padding:18px;min-height:145px;display:flex;flex-direction:column;justify-content:space-between;color:#fff;text-shadow:0 1px 2px #0008;border:2px solid #ffffff45;box-shadow:0 14px 34px #0004} +.page-card::after{content:attr(data-page);position:absolute;right:-14px;bottom:-34px;font-size:104px;font-weight:1000;line-height:1;color:#ffffff28;pointer-events:none} +.page-card .kb{align-self:flex-start;background:#0009;padding:3px 8px;border-radius:7px;font-weight:900} +.page-card h3{margin:8px 0}.page-card p{margin:0;opacity:.9}.page-card button{background:#0008} +.command{border:3px dashed #f59e0b;border-radius:16px;padding:18px;background:#101c2c}.command h2{margin:0;font-size:34px;color:#ffd580} +.task-result{display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:14px;padding:12px 14px;border-radius:12px;background:#16a34a;color:#fff;font-weight:800} +.task-result .rank{font-size:20px} +.help{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:18px}.help div{background:#172640;border-radius:10px;padding:10px}.kbd{display:inline-block;min-width:26px;text-align:center;padding:2px 6px;border-radius:5px;background:#2b3a52;font-weight:800} +.teacher-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:18px} +.accounts-panel{margin-top:18px}.accounts-actions{display:flex;align-items:end}.list{display:grid;gap:8px;max-height:440px;overflow:auto}.row{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;background:#16233a;padding:9px 12px;border-radius:9px} +.dot{width:10px;height:10px;border-radius:50%;background:#64748b}.online .dot{background:#22c55e}.winner{background:#143c28;border:1px solid #22c55e} +.event{padding:8px;border-bottom:1px solid #2b3a52;font-family:ui-monospace,monospace;font-size:13px} +.preset{display:grid;grid-template-columns:1fr auto auto;gap:8px;margin-bottom:8px}.score{font-size:22px;font-weight:900;color:#7ef0b0} +.builder{margin-bottom:18px}.builder-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}.builder-tip{min-height:38px;margin:10px 0 4px;padding:9px 12px;border-radius:10px;background:#132338;border:1px solid #33465f;color:#b9c9de;font-size:14px} +.expected-preview{margin:14px 0;padding:12px;border-radius:12px;background:#101d30;border:1px solid #354962}.expected-preview small{display:block;margin-bottom:5px;color:#8fa1b8}.expected-preview div{font-weight:800;color:#fff} +.advanced summary{cursor:pointer;color:#9fb0c8;margin-bottom:8px}.preset-panel{margin-top:18px;padding-top:14px;border-top:1px solid #2b3a52}.preset-panel h3{margin:0 0 10px} +.page-main{grid-template-columns:1.15fr .85fr} +.ribbon{display:flex;flex-direction:column;overflow:hidden;border:1px solid rgba(255,255,255,.22);border-radius:14px;background:rgba(0,0,0,.16)} +.ribbon-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:0;background:rgba(0,0,0,.18)} +.ribbon-tab{position:relative;border-radius:0;padding:18px 10px 14px;background:transparent;color:#fff;box-shadow:none} +.ribbon-tab:hover{transform:none;background:rgba(255,255,255,.08)} +.ribbon-tab.active{background:rgba(255,255,255,.14);box-shadow:inset 0 -3px 0 #fff} +.ribbon-tab.armed{background:#1d4ed8;box-shadow:inset 0 -3px 0 #fbbf24} +.ribbon-tab strong{display:block;font-size:16px} +.ribbon-tab .keytip,.ribbon-cmd .keytip{position:absolute;top:7px;right:8px;min-width:22px;padding:1px 6px;border-radius:4px;background:#f59e0b;color:#111;font-size:12px;font-weight:900} +.ribbon-body{padding:14px;min-height:128px;background:rgba(255,255,255,.04)} +.ribbon-subhint{grid-column:1/-1;margin-bottom:8px;color:#fff;font-weight:700} +.ribbon-panel{display:none;grid-template-columns:1fr 1fr;gap:12px} +.ribbon-panel.active{display:grid;animation:ribbonIn .16s ease} +.ribbon-cmd{position:relative;min-height:78px;display:flex;align-items:flex-end;justify-content:flex-start;text-align:left;padding:18px 14px 12px} +.ribbon.keyboard-on .keytip{box-shadow:0 0 0 3px #fbbf2480} +@keyframes ribbonIn{from{opacity:.4;transform:translateY(8px)}to{opacity:1;transform:none}} +body.training .ribbon-tab{background:rgba(0,0,0,.18)} +body.training .ribbon-tab.active{background:rgba(255,255,255,.18)} +body.training .ribbon-cmd.success{background:#16a34a} +body.training .ribbon-tab.armed{background:#1d4ed8} +body.training .ribbon-cmd.ghost{background:#1f2937} +@media(max-width:800px){.grid,.teacher-grid,.help,.builder-grid,.page-main,.ribbon-tabs,.ribbon-panel{grid-template-columns:1fr}.pages-grid{grid-template-columns:1fr}.command h2{font-size:25px}} diff --git a/shortcut_trainer/static/teacher.html b/shortcut_trainer/static/teacher.html new file mode 100644 index 0000000..0bc4162 --- /dev/null +++ b/shortcut_trainer/static/teacher.html @@ -0,0 +1,34 @@ +教师比赛控制台
师快捷键比赛控制台
+
+ +
diff --git a/shortcut_trainer/static/teacher.js b/shortcut_trainer/static/teacher.js new file mode 100644 index 0000000..3f71eb8 --- /dev/null +++ b/shortcut_trainer/static/teacher.js @@ -0,0 +1,251 @@ +const PAGE_META=[ + {page:1,key:'P',title:'红色火星站'}, + {page:2,key:'Q',title:'蓝色深海舱'}, + {page:3,key:'R',title:'绿色能量站'}, + {page:4,key:'T',title:'黄色指挥所'}, + {page:5,key:'Y',title:'紫色观测台'}, + {page:6,key:'U',title:'橙色发射场'} +]; +const BUTTONS=['火箭准备','燃料检查','通讯开启','系统诊断','备用电源','外部链接']; +const ACTIONS={ + page:{label:'只打开页面',choices:[]}, + button:{label:'点击指定按钮',choices:BUTTONS.map((name,index)=>({value:String(index+1),label:`${index+1}. ${name}(${ribbonPath(index+1)},或 Alt+${index+1})`}))}, + menu:{label:'使用页面右键菜单',choices:[ + {value:'refresh',label:'按 E:刷新'}, + {value:'rename',label:'按 R:重命名'}, + {value:'copy',label:'按 C:复制'} + ]}, + url:{label:'打开演示链接',choices:[ + {value:'baidu.com',label:'打开百度演示'}, + {value:'bilibili.com',label:'打开 Bilibili 演示'} + ]} +}; +const PRESETS=[ + {name:'打开第1页,点击第3个按钮',page:1,action:'button',choice:'3'}, + {name:'打开第2页,点击第1个按钮',page:2,action:'button',choice:'1'}, + {name:'打开第3页,点击燃料检查',page:3,action:'button',choice:'2'}, + {name:'打开第4页,右键菜单刷新',page:4,action:'menu',choice:'refresh'}, + {name:'打开第5页,打开百度演示',page:5,action:'url',choice:'baidu.com'}, + {name:'打开第6页,打开演示网站',page:6,action:'url',choice:'bilibili.com'} +]; +let serverPages=[]; +let timer; +let accountStudents=[]; + +const element=id=>document.getElementById(id); +const pages=()=>serverPages.length?serverPages:PAGE_META; +const pageBy=no=>pages().find(item=>Number(item.page)===Number(no))||PAGE_META[0]; + +function option(value,text,selected=false){return ``} + +function renderBuilder(){ + element('builderPage').innerHTML=pages().map(p=>option(p.page,`${p.page}. ${p.title}(Alt+${p.key})`)).join(''); + element('builderAction').innerHTML=Object.entries(ACTIONS).map(([value,item])=>option(value,item.label)).join(''); + element('builderAction').value='button'; + syncBuilder(); +} + +function renderChoices(preferred){ + const action=element('builderAction').value; + const choices=ACTIONS[action]?ACTIONS[action].choices:[]; + element('choiceField').classList.toggle('hidden',!choices.length); + if(!choices.length){element('builderChoice').innerHTML='';return} + element('choiceLabel').textContent=action==='button'?'目标按钮':action==='menu'?'菜单动作':'演示链接'; + const previous=preferred??element('builderChoice').value; + element('builderChoice').innerHTML=choices.map(item=>option(item.value,item.label)).join(''); + if(choices.some(item=>String(item.value)===String(previous))) element('builderChoice').value=previous; +} + +function instruction(pageNo,action,choice){ + const page=pageBy(pageNo); + const steps=[{type:'page',page:Number(page.page)}]; + let text=`打开第${page.page}页(${page.title})`; + const hint=['学生可点击控制台页面卡片,或直接按 Alt+'+page.key+' 打开页面。']; + if(action==='button'){ + const index=Number(choice); + const name=BUTTONS[index-1]; + text+=`,点击第${index}个按钮(${name})`; + hint.push(`在训练页点击按钮,或按 ${ribbonPath(index)},或按 Alt+${index},判定相同。`); + if(index===6) hint.push('打开外部链接后,再按 D 选择百度,或按 F 选择 Bilibili。'); + steps.push({type:'button',page:Number(page.page),index,name}); + }else if(action==='menu'){ + const names={refresh:'刷新(E)',rename:'重命名(R)',copy:'复制(C)'}; + text+=`,右键菜单执行${names[choice]}`; + hint.push('学生在页面空白处右键,再按对应字母。'); + steps.push({type:'menu',item:choice}); + }else if(action==='url'){ + const names={'baidu.com':'百度演示','bilibili.com':'Bilibili 演示'}; + text+=`,打开${names[choice]}`; + hint.push('学生按 Alt+B,再按 L 打开外部链接菜单,然后按 D 打开百度,或按 F 打开 Bilibili。'); + steps.push({type:'url',url:choice}); + } + return {label:text,steps,hint:hint.join(' ')}; +} + +function readBuilder(){ + return instruction(element('builderPage').value,element('builderAction').value,element('builderChoice').value); +} + +function updatePreview(){ + const built=readBuilder(); + element('label').value=built.label; + element('expectedPreview').textContent=built.label; + element('builderTip').textContent=built.hint; + element('expected').value=JSON.stringify({steps:built.steps},null,2); +} + +function syncBuilder(){ + renderChoices(); + updatePreview(); +} + +function renderPresets(){ + element('presets').innerHTML=''; + PRESETS.forEach(item=>{ + const row=document.createElement('div'); + row.className='preset'; + row.innerHTML=``; + const [name,fill,go]=row.querySelectorAll('button'); + name.onclick=()=>setBuilder(item); + fill.onclick=()=>setBuilder(item); + go.onclick=()=>{setBuilder(item);publish()}; + element('presets').appendChild(row); + }); +} + +function setBuilder(item){ + element('builderPage').value=item.page; + element('builderAction').value=item.action; + renderChoices(item.choice); + updatePreview(); +} + +async function publish(){ + const built=readBuilder(); + const label=element('label').value.trim()||built.label; + const r=await API.post('/api/teacher/round',{label,expected:{steps:built.steps}}); + API.notify(element('message'),r.message||(r.ok?'已发布':'指令格式错误')); + loadState(); +} + +function eventText(e){ + const names={refresh:'刷新',rename:'重命名',copy:'复制',maximize:'最大化',minimize:'最小化',move:'移动'}; + if(e.kind==='page')return `打开第${e.page_no}页`; + if(e.kind==='button')return `点击第${e.item_index}个按钮`; + if(e.kind==='shortcut')return `快捷键 ${e.value}`; + if(e.kind==='menu')return `右键菜单:${names[e.value]||e.value}`; + if(e.kind==='window')return `窗口:${names[e.value]||e.value}`; + if(e.kind==='url')return `打开 ${e.value}`; + return e.kind; +} + +async function init(){ + const s=await API.get('/api/teacher/state'); + const authed=s.ok; + element('loginArea').classList.toggle('hidden',authed); + element('mainArea').classList.toggle('hidden',!authed); + element('toolbar').classList.toggle('hidden',!authed); + if(!authed)return; + serverPages=s.pages||[]; + renderBuilder(); + renderPresets(); + loadState(); + timer=setInterval(loadState,1200); +} + +async function loadState(refreshAccounts=false){ + const s=await API.get('/api/teacher/state'); + if(!s.ok){clearInterval(timer);location.reload();return} + const hadAccounts=accountStudents.length; + accountStudents=s.students||[]; + if(refreshAccounts||!hadAccounts)renderAccounts(); + showRound(s.round); + element('roundState').textContent=s.round?'进行中':'未开始'; + element('students').innerHTML=''; + s.students.forEach(st=>{ + const row=document.createElement('div'); + row.className='row'+(st.online?' online':''); + row.innerHTML=`${st.name} · ${st.student_no} · ${st.class_name}${st.online?'在线':'离线'}`; + element('students').appendChild(row); + }); + element('scores').innerHTML=''; + if(!s.scores.length)element('scores').innerHTML='
暂无完成记录
'; + s.scores.forEach(x=>{ + const row=document.createElement('div'); + row.className='row winner'; + row.innerHTML=`#${x.rank}${x.name} · ${x.student_no} · ${x.class_name}${new Date(x.finished_at*1000).toLocaleTimeString()}`; + element('scores').appendChild(row); + }); + element('events').innerHTML=''; + if(!s.events.length)element('events').innerHTML='
暂无操作
'; + s.events.forEach(e=>{ + const item=document.createElement('div'); + item.className='event'; + item.textContent=`${new Date(e.created_at*1000).toLocaleTimeString()} ${e.name} · ${eventText(e)}`; + element('events').appendChild(item); + }); +} + +function text(value){return String(value??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]))} + +function renderAccounts(){ + const keyword=element('studentNoSearch').value.trim().toLowerCase(); + const className=element('accountClass').value; + const classes=[...new Set(accountStudents.map(item=>item.class_name))]; + const classSelect=element('accountClass'); + const previousClass=classSelect.value; + classSelect.innerHTML=''+classes.map(name=>option(name,`${name}班`)).join(''); + if(classes.includes(previousClass)||previousClass==='') classSelect.value=previousClass; + const students=accountStudents.filter(item=>{ + const hit=!keyword||String(item.student_no).toLowerCase().includes(keyword)||String(item.name).toLowerCase().includes(keyword); + return hit&&(!className||item.class_name===className); + }); + const select=element('studentSelect'); + const previous=select.value; + select.innerHTML=students.length?students.map(item=>option(item.id,`${item.student_no} · ${item.name} · ${item.class_name}班${item.must_change_password?' · 待改密':''}`)).join(''):''; + if(students.some(item=>String(item.id)===previous)) select.value=previous; +} + +element('studentNoSearch').oninput=renderAccounts; +element('accountClass').onchange=renderAccounts; +element('studentPasswordForm').onsubmit=async e=>{ + e.preventDefault(); + const notice=element('studentPasswordNotice'); + const student=accountStudents.find(item=>String(item.id)===element('studentSelect').value); + const password=element('studentNewPassword').value; + const confirmPassword=element('studentConfirmPassword').value; + notice.textContent=''; + if(!student){API.notify(notice,'请选择要修改密码的学生');return} + if(password!==confirmPassword){API.notify(notice,'两次输入的新密码不一致');return} + if(!confirm(`确定修改 ${student.student_no} ${student.name} 的登录密码吗?修改后该学生需要重新登录。`))return; + const result=await API.post('/api/teacher/student-password',{studentId:Number(student.id),newPassword:password}); + API.notify(notice,result.message||(result.ok?'已修改密码':'修改失败')); + if(result.ok)element('studentPasswordForm').reset(); +}; +element('studentCreateForm').onsubmit=async e=>{ + e.preventDefault(); + const notice=element('studentCreateNotice'); + const result=await API.post('/api/teacher/student',{ + className:element('newClassName').value, + studentNo:element('newStudentNo').value, + name:element('newStudentName').value, + password:element('newStudentPassword').value + }); + API.notify(notice,result.message||(result.ok?'已添加':'添加失败')); + if(result.ok){element('studentCreateForm').reset();loadState(true)} +}; + +element('loginForm').onsubmit=async e=>{ + e.preventDefault(); + const r=await API.post('/api/teacher/login',{username:element('username').value,password:element('password').value}); + if(!r.ok)return API.notify(element('notice'),r.message); + element('notice').textContent=''; + init(); +}; +element('builderPage').onchange=updatePreview; +element('builderAction').onchange=syncBuilder; +element('builderChoice').onchange=updatePreview; +element('start').onclick=publish; +element('stop').onclick=async()=>{await API.post('/api/teacher/stop');loadState()}; +element('logout').onclick=async()=>{await API.post('/api/logout');location.reload()}; +init();